Compare commits

..
11 Commits
Author SHA1 Message Date
thread c89ff2b68e LLMIFY THIS MOFO
CI / Test (push) Successful in 58s
2026-09-01 01:09:59 -04:00
Leonardo Trapani dad09d1109 Merge pull request #28 from LeonardoTrapani/docs/push-to-talk-bindings
document push-to-talk keybinding patterns
2026-04-17 16:51:50 +02:00
leonardotrapani 36b252b30b remove bindr troubleshooting hint from simple toggle 2026-03-15 16:06:16 +01:00
leonardotrapani c755c6efea move bind vs bindr after push-to-talk, remove verbose explanation 2026-03-15 15:55:23 +01:00
leonardotrapani 3db74580d8 keep bind as default, document bindr as alternative 2026-03-15 15:43:14 +01:00
leonardotrapani 6a64ba700f document push-to-talk and keybinding patterns in readme and config docs 2026-03-14 15:23:35 +01:00
leonardotrapani 76f66f3996 feat: release v1.0.2 to AUR 2026-02-25 16:12:05 +01:00
Leonardo Trapani 2b1c948245 Merge pull request #25 from gmoigneu/main
fix: sanitize line-terminating characters during injection
2026-02-25 16:06:29 +01:00
Guillaume G/ Moigneu b0fbe012a1 fix: sanitize line-terminating characters during injection to prevent unintended form submissions 2026-02-23 17:43:00 -06:00
Leonardo Trapani 7fa1ad3a8b feat: merge pull request #23 from burakgizlice/fix/stale-pid-detection
fix: detect stale PID files from recycled PIDs
2026-02-17 09:20:02 +01:00
burakgizliceandClaude Opus 4.6 13217f52f8 fix: detect stale PID files from recycled PIDs
The daemon startup check only used kill -0 to verify if the PID from
the PID file was alive. If the OS recycled that PID for an unrelated
process, hyprvoice would refuse to start with "daemon already running"
even though no hyprvoice instance was running.

Now also reads /proc/<pid>/cmdline to verify the process is actually
hyprvoice before treating it as a running daemon. If it's a different
process, the PID file is treated as stale and removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:50:55 +03:00
21 changed files with 385 additions and 42 deletions
+34
View File
@@ -83,6 +83,8 @@ systemctl --user enable --now hyprvoice.service
bind = SUPER, R, exec, hyprvoice toggle
```
See [Hyprland Keybindings](#hyprland-keybindings) for push-to-talk and other patterns.
4. Test voice input:
```bash
@@ -91,6 +93,38 @@ hyprvoice toggle
Run `hyprvoice configure` anytime for advanced settings.
## Hyprland Keybindings
### Simple toggle
```bash
# ~/.config/hypr/hyprland.conf
bind = SUPER, R, exec, hyprvoice toggle
```
Each press toggles between recording and idle.
### Push-to-talk (hold-to-record)
Combine both bind types to get hold-to-record behavior — press to start, release to stop:
```bash
# ~/.config/hypr/hyprland.conf
bind = SUPER, R, exec, hyprvoice toggle # key down → start recording
bindr = SUPER, R, exec, hyprvoice toggle # key up → stop and transcribe
```
This gives a walkie-talkie feel: hold the key while speaking, release when done. The daemon receives two `toggle` commands — the first starts recording, the second stops it and triggers transcription.
### `bind` vs `bindr`
| Keyword | Fires on |
|---------|----------|
| `bind` | Key **press** (down) |
| `bindr` | Key **release** (up) |
With `bindr`, modifier keys (SUPER, CTRL, etc.) are fully released before the command executes. This can prevent modifiers from interfering with text injection.
## Commands
### Core CLI
+65 -2
View File
@@ -21,6 +21,7 @@ Configuration is stored in `~/.config/hyprvoice/config.toml` and changes are app
## Table of Contents
- [Hyprland Keybinding Patterns](#hyprland-keybinding-patterns)
- [Unified Provider System](#unified-provider-system)
- [Transcription Providers](#transcription-providers)
- [Cloud Providers](#cloud-providers)
@@ -36,6 +37,40 @@ Configuration is stored in `~/.config/hyprvoice/config.toml` and changes are app
- [Example Configurations](#example-configurations)
- [Legacy Configs](#legacy-configs)
## Hyprland Keybinding Patterns
Hyprvoice is triggered via Hyprland keybindings. The bind type you choose affects reliability and workflow.
### Simple toggle
```bash
# ~/.config/hypr/hyprland.conf
bind = SUPER, R, exec, hyprvoice toggle
```
Each press toggles between recording and idle.
### Push-to-talk (hold-to-record)
Pair `bind` (press) with `bindr` (release) on the same key for hold-to-record:
```bash
# ~/.config/hypr/hyprland.conf
bind = SUPER, R, exec, hyprvoice toggle # key down → start recording
bindr = SUPER, R, exec, hyprvoice toggle # key up → stop and transcribe
```
Hold the key while speaking, release when done. Both lines send `toggle` to the daemon — the first starts the pipeline, the second stops it. Because the stop fires on release, modifiers are clean for injection.
### `bind` vs `bindr`
| Keyword | Fires on |
|---------|----------|
| `bind` | Key **press** (down) |
| `bindr` | Key **release** (up) |
With `bindr`, modifier keys (SUPER, CTRL, etc.) are fully released before the command executes. This can prevent modifiers from interfering with text injection.
## Unified Provider System
Hyprvoice uses a unified provider system where API keys are configured once and shared between transcription and LLM features:
@@ -56,12 +91,16 @@ Hyprvoice uses a unified provider system where API keys are configured once and
[providers.deepgram]
api_key = "..." # Or set DEEPGRAM_API_KEY env var
[providers.llama-swap]
api_key = "..." # Or set LLAMA_SWAP_API_KEY
base_url = "http://llama-swap.example:8080" # Do not include /v1
```
**API key resolution order:**
1. `[providers.X]` section in config
2. Environment variable (`OPENAI_API_KEY`, `GROQ_API_KEY`, etc.)
2. Environment variable (`OPENAI_API_KEY`, `GROQ_API_KEY`, `LLAMA_SWAP_API_KEY`, etc.)
## Transcription Providers
@@ -69,6 +108,29 @@ Hyprvoice supports multiple transcription backends. See [docs/providers.md](./pr
### Cloud Providers
### LlamaSwap (network-hosted OpenAI-compatible models)
LlamaSwap proxies OpenAI-compatible endpoints, including `/v1/audio/transcriptions` and `/v1/chat/completions`. Configure its host once, then set the model IDs exactly as they appear in LlamaSwap's `/v1/models` response:
```toml
[providers.llama-swap]
api_key = "your-llama-swap-api-key"
base_url = "http://192.168.1.50:8080" # No trailing /v1
[transcription]
provider = "llama-swap"
model = "whisper-large-v3-turbo"
language = ""
streaming = false
[llm]
enabled = true
provider = "llama-swap"
model = "your-chat-model-id"
```
The transcription and chat model IDs are deliberately not restricted by Hyprvoice: LlamaSwap selects from the models configured on your server. Hyprvoice submits transcription after recording ends, so set `streaming = false`.
### OpenAI Whisper API
Cloud-based transcription using OpenAI's Whisper API:
@@ -420,7 +482,7 @@ Configurable text injection with multiple backends:
```toml
[injection]
backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain
backends = ["clipboard-paste", "ydotool", "wtype", "clipboard"] # Ordered fallback chain
ydotool_timeout = "5s"
wtype_timeout = "5s"
clipboard_timeout = "3s"
@@ -429,6 +491,7 @@ clipboard_timeout = "3s"
### Injection Backends
- **`ydotool`**: Uses ydotool (requires `ydotoold` daemon for ydotool v1.0.0+). Most compatible with Chromium/Electron apps.
- **`clipboard-paste`**: Copies text to the primary selection then sends Shift+Insert through ydotool. Fast, layout-independent, and preserves the regular clipboard; recommended for Dvorak/Colemak users.
- **`wtype`**: Uses wtype for Wayland. May have issues with some Chromium-based apps (known upstream bug).
- **`clipboard`**: Copies text to clipboard only. Most reliable, but requires manual paste.
+14
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
)
@@ -99,6 +100,19 @@ func (pm *pidManager) isProcessAlive(pid int) bool {
return false
}
// Verify the process is actually hyprvoice and not a recycled PID
cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
if err != nil {
log.Printf("Process %d alive but cannot read cmdline, assuming stale: %v", pid, err)
return false
}
exe := string(cmdline)
if len(exe) == 0 || !strings.Contains(exe, "hyprvoice") {
log.Printf("Process %d is alive but is not hyprvoice (cmdline: %q), stale PID file", pid, exe)
return false
}
return true
}
+25
View File
@@ -696,6 +696,31 @@ func TestConfig_ConversionMethods(t *testing.T) {
})
}
func TestConfig_LlamaSwap(t *testing.T) {
cfg := createTestConfig()
cfg.Transcription.Provider = "llama-swap"
cfg.Transcription.Model = "whisper-large-v3-turbo"
cfg.Providers = map[string]ProviderConfig{
"llama-swap": {APIKey: "test-key", BaseURL: "http://192.168.1.50:8080"},
}
cfg.LLM = LLMConfig{Enabled: true, Provider: "llama-swap", Model: "qwen3"}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() error = %v", err)
}
if got := cfg.ToTranscriberConfig().BaseURL; got != "http://192.168.1.50:8080" {
t.Errorf("transcriber BaseURL = %q", got)
}
if got := cfg.ToLLMConfig().BaseURL; got != "http://192.168.1.50:8080" {
t.Errorf("LLM BaseURL = %q", got)
}
cfg.Providers["llama-swap"] = ProviderConfig{APIKey: "test-key", BaseURL: "http://192.168.1.50:8080/v1"}
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "omit the /v1 suffix") {
t.Errorf("Validate() error = %v, want /v1 validation error", err)
}
}
func TestValidateModelLanguageCompatibility(t *testing.T) {
tests := []struct {
name string
+6
View File
@@ -32,6 +32,9 @@ func (c *Config) ToTranscriberConfig() transcriber.Config {
}
config.APIKey = c.resolveAPIKeyForProvider(c.Transcription.Provider)
if c.Transcription.Provider == provider.ProviderLlamaSwap {
config.BaseURL = c.Providers[provider.ProviderLlamaSwap].BaseURL
}
return config
}
@@ -74,6 +77,9 @@ func (c *Config) ToLLMConfig() LLMAdapterConfig {
if c.LLM.Provider != "" {
config.APIKey = c.resolveAPIKeyForLLMProvider(c.LLM.Provider)
}
if c.LLM.Provider == provider.ProviderLlamaSwap {
config.BaseURL = c.Providers[provider.ProviderLlamaSwap].BaseURL
}
if c.LLM.CustomPrompt.Enabled && c.LLM.CustomPrompt.Prompt != "" {
config.CustomPrompt = c.LLM.CustomPrompt.Prompt
+12 -3
View File
@@ -47,6 +47,9 @@ func Save(cfg *Config) error {
for name, pc := range cfg.Providers {
sb.WriteString(fmt.Sprintf("[providers.%s]\n", name))
sb.WriteString(fmt.Sprintf(" api_key = %q\n", pc.APIKey))
if pc.BaseURL != "" {
sb.WriteString(fmt.Sprintf(" base_url = %q\n", pc.BaseURL))
}
sb.WriteString("\n")
}
}
@@ -221,6 +224,9 @@ keywords = []
# api_key = "" # ElevenLabs API key (or set ELEVENLABS_API_KEY env var)
# [providers.deepgram]
# api_key = "" # Deepgram API key (or set DEEPGRAM_API_KEY env var)
# [providers.llama-swap]
# api_key = "" # Or set LLAMA_SWAP_API_KEY
# base_url = "http://llama-swap.example:8080" # No /v1 suffix
# ─────────────────────────────────────────────────────────────────────────────
# Audio Recording
@@ -241,7 +247,7 @@ keywords = []
# ─────────────────────────────────────────────────────────────────────────────
[transcription]
provider = "openai" # "openai", "groq-transcription", "mistral-transcription", "elevenlabs", "whisper-cpp"
provider = "openai" # Also "llama-swap" for a remote OpenAI-compatible LlamaSwap server
model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1"
language = "" # ISO 639-1 code (e.g., en, es, de). Empty for auto-detect.
threads = 0 # CPU threads for local transcription (0 = auto: uses NumCPU-1)
@@ -253,7 +259,7 @@ keywords = []
[llm]
enabled = true # Enable LLM post-processing (highly recommended)
provider = "openai" # "openai" or "groq" (must have API key configured above)
provider = "openai" # "openai", "groq", or "llama-swap" (must have API key configured above)
model = "gpt-4o-mini" # OpenAI: "gpt-4o-mini", Groq: "llama-3.3-70b-versatile"
[llm.post_processing]
@@ -272,7 +278,7 @@ keywords = []
# ─────────────────────────────────────────────────────────────────────────────
[injection]
backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain (tries each until one succeeds)
backends = ["clipboard-paste", "ydotool", "wtype", "clipboard"] # Ordered fallback chain (tries each until one succeeds)
ydotool_timeout = "5s" # Timeout for ydotool commands
wtype_timeout = "5s" # Timeout for wtype commands
clipboard_timeout = "3s" # Timeout for clipboard operations
@@ -322,12 +328,15 @@ keywords = []
# - "groq-transcription": Groq Whisper API (very fast, models: whisper-large-v3, whisper-large-v3-turbo)
# - "mistral-transcription": Mistral Voxtral API (excellent for European languages, model: voxtral-mini-latest)
# - "elevenlabs": ElevenLabs Scribe API (99 languages, models: scribe_v1, scribe_v2, scribe_v2_realtime)
# - "llama-swap": Remote OpenAI-compatible LlamaSwap (any configured transcription model)
#
# LLM providers (for post-processing):
# - "openai": GPT models (gpt-4o-mini recommended for cost/quality balance)
# - "groq": Fast inference (llama-3.3-70b-versatile recommended)
# - "llama-swap": Any chat model configured in your LlamaSwap server
#
# Injection backends:
# - "clipboard-paste": Uses primary selection + ydotool Shift+Insert; fast, layout-independent, and preserves clipboard.
# - "ydotool": Uses ydotool (requires ydotoold daemon). Best for Chromium/Electron apps.
# - "wtype": Uses wtype for Wayland. May have issues with some Chromium apps.
# - "clipboard": Copies to clipboard only (most reliable, requires manual paste).
+3 -1
View File
@@ -25,7 +25,8 @@ type Config struct {
// ProviderConfig holds API key for a provider
type ProviderConfig struct {
APIKey string `toml:"api_key"`
APIKey string `toml:"api_key"`
BaseURL string `toml:"base_url"` // OpenAI-compatible base URL, without /v1
}
// LLMConfig configures the LLM post-processing phase
@@ -139,4 +140,5 @@ type LLMAdapterConfig struct {
RemoveFillerWords bool
CustomPrompt string
Keywords []string
BaseURL string
}
+63 -27
View File
@@ -2,6 +2,7 @@ package config
import (
"fmt"
"net/url"
"strings"
"github.com/leonardotrapani/hyprvoice/internal/provider"
@@ -34,6 +35,8 @@ func envVarForProvider(registryName string) string {
return "ELEVENLABS_API_KEY"
case "deepgram":
return "DEEPGRAM_API_KEY"
case "llama-swap":
return "LLAMA_SWAP_API_KEY"
default:
return ""
}
@@ -82,27 +85,40 @@ func (c *Config) Validate() error {
strings.Title(registryName), registryName, envVar)
}
}
if registryName == provider.ProviderLlamaSwap {
if err := validateLlamaSwapBaseURL(c.Providers[provider.ProviderLlamaSwap].BaseURL); err != nil {
return err
}
}
// validate model exists
if c.Transcription.Model == "" {
return fmt.Errorf("invalid transcription.model: empty")
}
// validate model exists in provider
_, err := provider.GetModel(registryName, c.Transcription.Model)
if err != nil {
models := provider.ModelsOfType(p, provider.Transcription)
modelIDs := make([]string, len(models))
for i, m := range models {
modelIDs[i] = m.ID
// LlamaSwap is an OpenAI-compatible router: its model IDs are defined by the
// remote server, so they cannot be validated against Hyprvoice's static registry.
if registryName == provider.ProviderLlamaSwap {
if c.Transcription.Streaming {
return fmt.Errorf("llama-swap transcription supports batch mode only (set transcription.streaming = false)")
}
} else {
// validate model exists in provider
_, err := provider.GetModel(registryName, c.Transcription.Model)
if err != nil {
models := provider.ModelsOfType(p, provider.Transcription)
modelIDs := make([]string, len(models))
for i, m := range models {
modelIDs[i] = m.ID
}
return fmt.Errorf("invalid model for %s: %s (available: %s)", c.Transcription.Provider, c.Transcription.Model, strings.Join(modelIDs, ", "))
}
return fmt.Errorf("invalid model for %s: %s (available: %s)", c.Transcription.Provider, c.Transcription.Model, strings.Join(modelIDs, ", "))
}
// validate language-model compatibility using effective language (transcription overrides general)
effectiveLanguage := c.resolveEffectiveLanguage()
if err := ValidateModelLanguageCompatibility(registryName, c.Transcription.Model, effectiveLanguage); err != nil {
return err
// validate language-model compatibility using effective language (transcription overrides general)
effectiveLanguage := c.resolveEffectiveLanguage()
if err := ValidateModelLanguageCompatibility(registryName, c.Transcription.Model, effectiveLanguage); err != nil {
return err
}
}
// LLM validation
@@ -121,20 +137,26 @@ func (c *Config) Validate() error {
return fmt.Errorf("invalid llm.provider: %s (available: %s)", c.LLM.Provider, strings.Join(providers, ", "))
}
// validate LLM model exists
llmModel, err := provider.GetModel(c.LLM.Provider, c.LLM.Model)
if err != nil {
models := provider.ModelsOfType(llmProvider, provider.LLM)
modelIDs := make([]string, len(models))
for i, m := range models {
modelIDs[i] = m.ID
if c.LLM.Provider == provider.ProviderLlamaSwap {
if err := validateLlamaSwapBaseURL(c.Providers[provider.ProviderLlamaSwap].BaseURL); err != nil {
return err
}
} else {
// validate LLM model exists
llmModel, err := provider.GetModel(c.LLM.Provider, c.LLM.Model)
if err != nil {
models := provider.ModelsOfType(llmProvider, provider.LLM)
modelIDs := make([]string, len(models))
for i, m := range models {
modelIDs[i] = m.ID
}
return fmt.Errorf("invalid llm.model: %s (available for %s: %s)", c.LLM.Model, c.LLM.Provider, strings.Join(modelIDs, ", "))
}
return fmt.Errorf("invalid llm.model: %s (available for %s: %s)", c.LLM.Model, c.LLM.Provider, strings.Join(modelIDs, ", "))
}
// verify model is actually an LLM
if llmModel.Type != provider.LLM {
return fmt.Errorf("invalid llm.model: %s is not an LLM model", c.LLM.Model)
// verify model is actually an LLM
if llmModel.Type != provider.LLM {
return fmt.Errorf("invalid llm.model: %s is not an LLM model", c.LLM.Model)
}
}
// validate LLM API key
@@ -151,10 +173,10 @@ func (c *Config) Validate() error {
if len(c.Injection.Backends) == 0 {
return fmt.Errorf("invalid injection.backends: empty (must have at least one backend)")
}
validBackends := map[string]bool{"ydotool": true, "wtype": true, "clipboard": true}
validBackends := map[string]bool{"clipboard-paste": true, "ydotool": true, "wtype": true, "clipboard": true}
for _, backend := range c.Injection.Backends {
if !validBackends[backend] {
return fmt.Errorf("invalid injection.backends: unknown backend %q (must be ydotool, wtype, or clipboard)", backend)
return fmt.Errorf("invalid injection.backends: unknown backend %q (must be clipboard-paste, ydotool, wtype, or clipboard)", backend)
}
}
if c.Injection.YdotoolTimeout <= 0 {
@@ -175,6 +197,20 @@ func (c *Config) Validate() error {
return nil
}
func validateLlamaSwapBaseURL(baseURL string) error {
if baseURL == "" {
return fmt.Errorf("llama-swap base_url required in providers.llama-swap.base_url")
}
u, err := url.Parse(baseURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("invalid llama-swap base_url: %q (expected http://host:port, without /v1)", baseURL)
}
if strings.TrimRight(u.Path, "/") == "/v1" {
return fmt.Errorf("invalid llama-swap base_url: omit the /v1 suffix")
}
return nil
}
// ValidateModelLanguageCompatibility validates that a model supports the given language.
// Returns error if the language is not supported, nil if supported or if langCode is empty (auto).
func ValidateModelLanguageCompatibility(registryProvider, modelID, langCode string) error {
+76
View File
@@ -0,0 +1,76 @@
package injection
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// clipboardPasteBackend copies text to the primary selection and pastes it
// with ydotool's layout-independent Shift+Insert key codes.
//
// This preserves the regular clipboard, is independent of the active keyboard
// layout, and delivers the whole string at once.
type clipboardPasteBackend struct {
clipboard *clipboardBackend
ydotool *ydotoolBackend
}
func NewClipboardPasteBackend() Backend {
return &clipboardPasteBackend{
clipboard: NewClipboardBackend().(*clipboardBackend),
ydotool: NewYdotoolBackend().(*ydotoolBackend),
}
}
func (c *clipboardPasteBackend) Name() string {
return "clipboard-paste"
}
func (c *clipboardPasteBackend) Available() error {
if err := c.clipboard.Available(); err != nil {
return err
}
if err := c.ydotool.Available(); err != nil {
return err
}
return nil
}
func (c *clipboardPasteBackend) Inject(ctx context.Context, text string, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if err := c.Available(); err != nil {
return err
}
// Shift+Insert pastes the primary selection, leaving the user's regular
// clipboard untouched.
primaryCopy := exec.CommandContext(ctx, "wl-copy", "--primary")
primaryCopy.Stdin = strings.NewReader(text)
if err := primaryCopy.Run(); err != nil {
return fmt.Errorf("copy transcription to primary selection: %w", err)
}
// wl-copy returns before every client has observed the new selection. Give
// the compositor a short chance to publish it; this is still perceived as
// an immediate paste and avoids inserting the previous clipboard contents.
select {
case <-time.After(150 * time.Millisecond):
case <-ctx.Done():
return ctx.Err()
}
// Linux input event codes: KEY_LEFTSHIFT=42, KEY_INSERT=110. These keys do
// not depend on the alphabetic layout, and Shift+Insert is widely supported.
cmd := exec.CommandContext(ctx, "ydotool", "key", "42:1", "110:1", "110:0", "42:0")
if socketPath := c.ydotool.getSocketPath(); socketPath != "" {
cmd.Env = append(os.Environ(), "YDOTOOL_SOCKET="+socketPath)
}
if err := cmd.Run(); err != nil {
return fmt.Errorf("paste primary selection with ydotool: %w", err)
}
return nil
}
+3 -1
View File
@@ -12,7 +12,7 @@ type Injector interface {
}
type Config struct {
Backends []string // Ordered list: "ydotool", "wtype", "clipboard"
Backends []string // Ordered list: "clipboard-paste", "ydotool", "wtype", "clipboard"
YdotoolTimeout time.Duration // Timeout for ydotool commands
WtypeTimeout time.Duration // Timeout for wtype commands
ClipboardTimeout time.Duration // Timeout for clipboard operations
@@ -34,6 +34,8 @@ func NewInjector(config Config) Injector {
backends = append(backends, NewWtypeBackend())
case "clipboard":
backends = append(backends, NewClipboardBackend())
case "clipboard-paste":
backends = append(backends, NewClipboardPasteBackend())
default:
log.Printf("Injection: unknown backend %q, skipping", name)
}
+11 -3
View File
@@ -66,9 +66,10 @@ func TestNewInjector_IgnoresUnknownBackends(t *testing.T) {
}
func TestInjector_Inject(t *testing.T) {
// Skip integration tests in CI environments
if os.Getenv("CI") == "true" {
t.Skip("Skipping integration test in CI environment")
// This test invokes real Wayland input tools and must run in a graphical
// session, not merely outside CI.
if os.Getenv("CI") == "true" || os.Getenv("WAYLAND_DISPLAY") == "" {
t.Skip("Skipping integration test outside a Wayland session")
}
tests := []struct {
@@ -202,6 +203,13 @@ func TestClipboardBackend(t *testing.T) {
t.Logf("clipboard is available")
}
func TestClipboardPasteBackend(t *testing.T) {
backend := NewClipboardPasteBackend()
if backend.Name() != "clipboard-paste" {
t.Errorf("Name() = %s, want clipboard-paste", backend.Name())
}
}
// TestInjector_ClipboardMode tests clipboard-only injection
func TestInjector_ClipboardMode(t *testing.T) {
config := Config{
+4
View File
@@ -85,9 +85,13 @@ func (y *ydotoolBackend) Inject(ctx context.Context, text string, timeout time.D
if err := y.Available(); err != nil {
return err
}
socketPath := y.getSocketPath()
// ydotool type -- "text"
cmd := exec.CommandContext(ctx, "ydotool", "type", "--", text)
if socketPath != "" {
cmd.Env = append(os.Environ(), "YDOTOOL_SOCKET="+socketPath)
}
if err := cmd.Run(); err != nil {
return fmt.Errorf("ydotool failed: %w", err)
}
+6
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log"
"strings"
"time"
"github.com/sashabaranov/go-openai"
@@ -17,6 +18,11 @@ type OpenAIAdapter struct {
// NewOpenAIAdapter creates a new OpenAI LLM adapter
func NewOpenAIAdapter(cfg Config) *OpenAIAdapter {
if cfg.BaseURL != "" {
clientConfig := openai.DefaultConfig(cfg.APIKey)
clientConfig.BaseURL = strings.TrimRight(cfg.BaseURL, "/") + "/v1"
return &OpenAIAdapter{client: openai.NewClientWithConfig(clientConfig), config: cfg}
}
return &OpenAIAdapter{
client: openai.NewClient(cfg.APIKey),
config: cfg,
+9
View File
@@ -21,6 +21,7 @@ type Config struct {
RemoveFillerWords bool
CustomPrompt string
Keywords []string
BaseURL string
}
// NewAdapter creates an LLM adapter based on the provider
@@ -36,6 +37,14 @@ func NewAdapter(cfg Config) (Adapter, error) {
return nil, fmt.Errorf("Groq API key required")
}
return NewGroqAdapter(cfg), nil
case "llama-swap":
if cfg.APIKey == "" {
return nil, fmt.Errorf("LlamaSwap API key required")
}
if cfg.BaseURL == "" {
return nil, fmt.Errorf("LlamaSwap base_url required")
}
return NewOpenAIAdapter(cfg), nil
default:
return nil, fmt.Errorf("unsupported LLM provider: %s", cfg.Provider)
}
+13
View File
@@ -3,6 +3,7 @@ package pipeline
import (
"context"
"log"
"strings"
"sync"
"sync/atomic"
@@ -316,6 +317,7 @@ func (p *pipeline) handleInjectAction(ctx context.Context, recorder recording.Re
RemoveFillerWords: llmCfg.RemoveFillerWords,
CustomPrompt: llmCfg.CustomPrompt,
Keywords: llmCfg.Keywords,
BaseURL: llmCfg.BaseURL,
})
if err != nil {
log.Printf("Pipeline: Failed to create LLM adapter: %v, using raw transcription", err)
@@ -331,6 +333,17 @@ func (p *pipeline) handleInjectAction(ctx context.Context, recorder recording.Re
p.setStatus(Injecting)
}
// Sanitize: replace line-terminating characters with spaces to prevent
// unintended Enter keypresses during injection, which can submit forms mid-sentence.
// Covers ASCII controls (\r, \n, \v, \f), Unicode NEL (U+0085),
// LINE SEPARATOR (U+2028), and PARAGRAPH SEPARATOR (U+2029).
textToInject = strings.Map(func(r rune) rune {
switch r {
case '\r', '\n', '\v', '\f', '\u0085', '\u2028', '\u2029':
return ' '
}
return r
}, textToInject)
injector := p.injectorFactory(p.config.ToInjectionConfig())
if err := injector.Inject(ctx, textToInject); err != nil {
+14
View File
@@ -0,0 +1,14 @@
package provider
// LlamaSwapProvider represents LlamaSwap's authenticated OpenAI-compatible
// proxy. The concrete model IDs are supplied in config because LlamaSwap can
// route to any model configured by its operator.
type LlamaSwapProvider struct{}
func (p *LlamaSwapProvider) Name() string { return ProviderLlamaSwap }
func (p *LlamaSwapProvider) RequiresAPIKey() bool { return true }
func (p *LlamaSwapProvider) ValidateAPIKey(key string) bool { return key != "" }
func (p *LlamaSwapProvider) APIKeyURL() string { return "" }
func (p *LlamaSwapProvider) IsLocal() bool { return false }
func (p *LlamaSwapProvider) Models() []Model { return nil }
func (p *LlamaSwapProvider) DefaultModel(ModelType) string { return "" }
+4
View File
@@ -8,6 +8,7 @@ const (
ProviderElevenLabs = "elevenlabs"
ProviderDeepgram = "deepgram"
ProviderWhisperCpp = "whisper-cpp"
ProviderLlamaSwap = "llama-swap"
)
// Config provider names (used in config file transcription.provider)
@@ -27,6 +28,7 @@ const (
EnvMistralKey = "MISTRAL_API_KEY"
EnvElevenLabsKey = "ELEVENLABS_API_KEY"
EnvDeepgramKey = "DEEPGRAM_API_KEY"
EnvLlamaSwapKey = "LLAMA_SWAP_API_KEY"
)
// Adapter type constants for transcription backends
@@ -66,6 +68,8 @@ func EnvVarForProvider(provider string) string {
return EnvElevenLabsKey
case ProviderDeepgram:
return EnvDeepgramKey
case ProviderLlamaSwap:
return EnvLlamaSwapKey
default:
return ""
}
+1
View File
@@ -31,6 +31,7 @@ func init() {
Register(&ElevenLabsProvider{})
Register(&WhisperCppProvider{})
Register(&DeepgramProvider{})
Register(&LlamaSwapProvider{})
}
// Register adds a provider to the registry
+1 -1
View File
@@ -35,7 +35,7 @@ func NewOpenAIAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang str
if endpoint != nil && endpoint.BaseURL != "" {
// use custom endpoint
clientConfig := openai.DefaultConfig(apiKey)
clientConfig.BaseURL = endpoint.BaseURL + "/v1"
clientConfig.BaseURL = strings.TrimRight(endpoint.BaseURL, "/") + "/v1"
client = openai.NewClientWithConfig(clientConfig)
} else {
// default to OpenAI
+19 -2
View File
@@ -31,8 +31,9 @@ type Config struct {
Language string
Model string
Keywords []string
Threads int // CPU threads for local transcription (0 = auto)
Streaming bool // use streaming mode if model supports it
Threads int // CPU threads for local transcription (0 = auto)
Streaming bool // use streaming mode if model supports it
BaseURL string // OpenAI-compatible base URL, without /v1
}
// NewTranscriber creates a new transcriber based on model metadata
@@ -55,6 +56,22 @@ func NewTranscriber(config Config) (Transcriber, error) {
return nil, fmt.Errorf("%s API key required", cases.Title(language.English).String(registryProvider))
}
// llama-swap proxies arbitrary OpenAI-compatible model IDs, so models are
// intentionally configured by the user rather than limited to this registry.
if registryProvider == provider.ProviderLlamaSwap {
if config.Model == "" {
return nil, fmt.Errorf("model is required for llama-swap")
}
if config.BaseURL == "" {
return nil, fmt.Errorf("llama-swap base_url required")
}
if config.Streaming {
return nil, fmt.Errorf("llama-swap transcription currently supports batch mode only (set streaming = false)")
}
endpoint := &provider.EndpointConfig{BaseURL: config.BaseURL}
return NewSimpleTranscriber(config, NewOpenAIAdapter(endpoint, config.APIKey, config.Model, config.Language, config.Keywords, registryProvider)), nil
}
// lookup model from provider
model, err := provider.GetModel(registryProvider, config.Model)
if err != nil {
+2 -2
View File
@@ -1,6 +1,6 @@
# Maintainer: Leonardo Trapani <leo@trapani.sh>
pkgname=hyprvoice-bin
pkgver=v1.0.1
pkgver=v1.0.2
pkgrel=1
pkgdesc="Voice-powered typing for Wayland/Hyprland"
arch=('x86_64')
@@ -26,7 +26,7 @@ source=(
"hyprvoice-${pkgver}::https://github.com/leonardotrapani/hyprvoice/releases/download/${pkgver}/hyprvoice-linux-x86_64"
"hyprvoice.service"
)
sha256sums=('d0d1dc952fe374917e7598b36a7c228a4a5222dd1a68f4c859a46e87a1ee1613'
sha256sums=('d055b01352b0cb4e50ac2e729e0e1baac259468559bb86a792b2f20f30bb6cc4'
'5631c957777882870e61934176d2142fab6c553bb650ffd8b3eb74b8dce955bd')
install=hyprvoice.install