Compare commits

...
11 Commits
Author SHA1 Message Date
thread 4426af63cb LLMIFY THIS MOFO
CI / Test (push) Successful in 44s
2026-09-01 01:49:40 -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
22 changed files with 485 additions and 57 deletions
+34
View File
@@ -83,6 +83,8 @@ systemctl --user enable --now hyprvoice.service
bind = SUPER, R, exec, hyprvoice toggle bind = SUPER, R, exec, hyprvoice toggle
``` ```
See [Hyprland Keybindings](#hyprland-keybindings) for push-to-talk and other patterns.
4. Test voice input: 4. Test voice input:
```bash ```bash
@@ -91,6 +93,38 @@ hyprvoice toggle
Run `hyprvoice configure` anytime for advanced settings. 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 ## Commands
### Core CLI ### Core CLI
+68 -2
View File
@@ -21,6 +21,7 @@ Configuration is stored in `~/.config/hyprvoice/config.toml` and changes are app
## Table of Contents ## Table of Contents
- [Hyprland Keybinding Patterns](#hyprland-keybinding-patterns)
- [Unified Provider System](#unified-provider-system) - [Unified Provider System](#unified-provider-system)
- [Transcription Providers](#transcription-providers) - [Transcription Providers](#transcription-providers)
- [Cloud Providers](#cloud-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) - [Example Configurations](#example-configurations)
- [Legacy Configs](#legacy-configs) - [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 ## Unified Provider System
Hyprvoice uses a unified provider system where API keys are configured once and shared between transcription and LLM features: 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] [providers.deepgram]
api_key = "..." # Or set DEEPGRAM_API_KEY env var 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:** **API key resolution order:**
1. `[providers.X]` section in config 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 ## Transcription Providers
@@ -69,6 +108,29 @@ Hyprvoice supports multiple transcription backends. See [docs/providers.md](./pr
### Cloud Providers ### 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 ### OpenAI Whisper API
Cloud-based transcription using OpenAI's Whisper API: Cloud-based transcription using OpenAI's Whisper API:
@@ -420,15 +482,19 @@ Configurable text injection with multiple backends:
```toml ```toml
[injection] [injection]
backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain backends = ["clipboard-paste", "ydotool", "wtype", "clipboard"] # Ordered fallback chain
ydotool_timeout = "5s" ydotool_timeout = "5s"
wtype_timeout = "5s" wtype_timeout = "5s"
clipboard_timeout = "3s" clipboard_timeout = "3s"
ctrl_shift_v_classes = ["ghostty"] # Window-class substrings that paste with Ctrl+Shift+V
``` ```
### Injection Backends ### Injection Backends
- **`ydotool`**: Uses ydotool (requires `ydotoold` daemon for ydotool v1.0.0+). Most compatible with Chromium/Electron apps. - **`ydotool`**: Uses ydotool (requires `ydotoold` daemon for ydotool v1.0.0+). Most compatible with Chromium/Electron apps.
- **`clipboard-paste`**: Temporarily copies text to the regular clipboard, sends Ctrl+V through wtype, then restores the prior text clipboard. This works in browsers and Electron apps and is layout-independent; recommended for Dvorak/Colemak users. Requires `wl-clipboard` and `wtype`.
`ctrl_shift_v_classes` selects applications that need Ctrl+Shift+V instead of Ctrl+V. Matching is case-insensitive against Hyprland's active-window data, and the default `"ghostty"` handles Ghostty. Add a terminal's class substring to this list when needed.
- **`wtype`**: Uses wtype for Wayland. May have issues with some Chromium-based apps (known upstream bug). - **`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. - **`clipboard`**: Copies text to clipboard only. Most reliable, but requires manual paste.
+14
View File
@@ -8,6 +8,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
"syscall" "syscall"
) )
@@ -99,6 +100,19 @@ func (pm *pidManager) isProcessAlive(pid int) bool {
return false 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 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) { func TestValidateModelLanguageCompatibility(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+11 -4
View File
@@ -32,6 +32,9 @@ func (c *Config) ToTranscriberConfig() transcriber.Config {
} }
config.APIKey = c.resolveAPIKeyForProvider(c.Transcription.Provider) config.APIKey = c.resolveAPIKeyForProvider(c.Transcription.Provider)
if c.Transcription.Provider == provider.ProviderLlamaSwap {
config.BaseURL = c.Providers[provider.ProviderLlamaSwap].BaseURL
}
return config return config
} }
@@ -74,6 +77,9 @@ func (c *Config) ToLLMConfig() LLMAdapterConfig {
if c.LLM.Provider != "" { if c.LLM.Provider != "" {
config.APIKey = c.resolveAPIKeyForLLMProvider(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 != "" { if c.LLM.CustomPrompt.Enabled && c.LLM.CustomPrompt.Prompt != "" {
config.CustomPrompt = c.LLM.CustomPrompt.Prompt config.CustomPrompt = c.LLM.CustomPrompt.Prompt
@@ -106,9 +112,10 @@ func (c *Config) IsLLMEnabled() bool {
func (c *Config) ToInjectionConfig() injection.Config { func (c *Config) ToInjectionConfig() injection.Config {
return injection.Config{ return injection.Config{
Backends: c.Injection.Backends, Backends: c.Injection.Backends,
YdotoolTimeout: c.Injection.YdotoolTimeout, YdotoolTimeout: c.Injection.YdotoolTimeout,
WtypeTimeout: c.Injection.WtypeTimeout, WtypeTimeout: c.Injection.WtypeTimeout,
ClipboardTimeout: c.Injection.ClipboardTimeout, ClipboardTimeout: c.Injection.ClipboardTimeout,
CtrlShiftVClasses: c.Injection.CtrlShiftVClasses,
} }
} }
+5 -4
View File
@@ -20,10 +20,11 @@ func DefaultConfig() *Config {
Threads: 0, Threads: 0,
}, },
Injection: InjectionConfig{ Injection: InjectionConfig{
Backends: []string{"ydotool", "wtype", "clipboard"}, Backends: []string{"ydotool", "wtype", "clipboard"},
YdotoolTimeout: 5 * time.Second, YdotoolTimeout: 5 * time.Second,
WtypeTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second,
ClipboardTimeout: 3 * time.Second, ClipboardTimeout: 3 * time.Second,
CtrlShiftVClasses: []string{"ghostty"},
}, },
Notifications: NotificationsConfig{ Notifications: NotificationsConfig{
Enabled: false, Enabled: false,
+21 -3
View File
@@ -47,6 +47,9 @@ func Save(cfg *Config) error {
for name, pc := range cfg.Providers { for name, pc := range cfg.Providers {
sb.WriteString(fmt.Sprintf("[providers.%s]\n", name)) sb.WriteString(fmt.Sprintf("[providers.%s]\n", name))
sb.WriteString(fmt.Sprintf(" api_key = %q\n", pc.APIKey)) 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") sb.WriteString("\n")
} }
} }
@@ -117,6 +120,14 @@ func Save(cfg *Config) error {
sb.WriteString(fmt.Sprintf(" ydotool_timeout = %q\n", cfg.Injection.YdotoolTimeout.String())) sb.WriteString(fmt.Sprintf(" ydotool_timeout = %q\n", cfg.Injection.YdotoolTimeout.String()))
sb.WriteString(fmt.Sprintf(" wtype_timeout = %q\n", cfg.Injection.WtypeTimeout.String())) sb.WriteString(fmt.Sprintf(" wtype_timeout = %q\n", cfg.Injection.WtypeTimeout.String()))
sb.WriteString(fmt.Sprintf(" clipboard_timeout = %q\n", cfg.Injection.ClipboardTimeout.String())) sb.WriteString(fmt.Sprintf(" clipboard_timeout = %q\n", cfg.Injection.ClipboardTimeout.String()))
sb.WriteString(" ctrl_shift_v_classes = [")
for i, class := range cfg.Injection.CtrlShiftVClasses {
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString(fmt.Sprintf("%q", class))
}
sb.WriteString("]\n")
sb.WriteString("\n") sb.WriteString("\n")
// Notifications // Notifications
@@ -221,6 +232,9 @@ keywords = []
# api_key = "" # ElevenLabs API key (or set ELEVENLABS_API_KEY env var) # api_key = "" # ElevenLabs API key (or set ELEVENLABS_API_KEY env var)
# [providers.deepgram] # [providers.deepgram]
# api_key = "" # Deepgram API key (or set DEEPGRAM_API_KEY env var) # 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 # Audio Recording
@@ -241,7 +255,7 @@ keywords = []
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
[transcription] [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" 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. 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) threads = 0 # CPU threads for local transcription (0 = auto: uses NumCPU-1)
@@ -253,7 +267,7 @@ keywords = []
[llm] [llm]
enabled = true # Enable LLM post-processing (highly recommended) 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" model = "gpt-4o-mini" # OpenAI: "gpt-4o-mini", Groq: "llama-3.3-70b-versatile"
[llm.post_processing] [llm.post_processing]
@@ -272,7 +286,7 @@ keywords = []
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
[injection] [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 ydotool_timeout = "5s" # Timeout for ydotool commands
wtype_timeout = "5s" # Timeout for wtype commands wtype_timeout = "5s" # Timeout for wtype commands
clipboard_timeout = "3s" # Timeout for clipboard operations clipboard_timeout = "3s" # Timeout for clipboard operations
@@ -322,12 +336,16 @@ keywords = []
# - "groq-transcription": Groq Whisper API (very fast, models: whisper-large-v3, whisper-large-v3-turbo) # - "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) # - "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) # - "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): # LLM providers (for post-processing):
# - "openai": GPT models (gpt-4o-mini recommended for cost/quality balance) # - "openai": GPT models (gpt-4o-mini recommended for cost/quality balance)
# - "groq": Fast inference (llama-3.3-70b-versatile recommended) # - "groq": Fast inference (llama-3.3-70b-versatile recommended)
# - "llama-swap": Any chat model configured in your LlamaSwap server
# #
# Injection backends: # Injection backends:
# - "clipboard-paste": Temporarily uses the regular clipboard + wtype Ctrl+V, then restores prior text clipboard.
# - ctrl_shift_v_classes: Window-class substrings that use Ctrl+Shift+V instead (default: ["ghostty"]).
# - "ydotool": Uses ydotool (requires ydotoold daemon). Best for Chromium/Electron apps. # - "ydotool": Uses ydotool (requires ydotoold daemon). Best for Chromium/Electron apps.
# - "wtype": Uses wtype for Wayland. May have issues with some Chromium apps. # - "wtype": Uses wtype for Wayland. May have issues with some Chromium apps.
# - "clipboard": Copies to clipboard only (most reliable, requires manual paste). # - "clipboard": Copies to clipboard only (most reliable, requires manual paste).
+8 -5
View File
@@ -25,7 +25,8 @@ type Config struct {
// ProviderConfig holds API key for a provider // ProviderConfig holds API key for a provider
type ProviderConfig struct { 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 // LLMConfig configures the LLM post-processing phase
@@ -70,10 +71,11 @@ type TranscriptionConfig struct {
} }
type InjectionConfig struct { type InjectionConfig struct {
Backends []string `toml:"backends"` Backends []string `toml:"backends"`
YdotoolTimeout time.Duration `toml:"ydotool_timeout"` YdotoolTimeout time.Duration `toml:"ydotool_timeout"`
WtypeTimeout time.Duration `toml:"wtype_timeout"` WtypeTimeout time.Duration `toml:"wtype_timeout"`
ClipboardTimeout time.Duration `toml:"clipboard_timeout"` ClipboardTimeout time.Duration `toml:"clipboard_timeout"`
CtrlShiftVClasses []string `toml:"ctrl_shift_v_classes"`
} }
type NotificationsConfig struct { type NotificationsConfig struct {
@@ -139,4 +141,5 @@ type LLMAdapterConfig struct {
RemoveFillerWords bool RemoveFillerWords bool
CustomPrompt string CustomPrompt string
Keywords []string Keywords []string
BaseURL string
} }
+63 -27
View File
@@ -2,6 +2,7 @@ package config
import ( import (
"fmt" "fmt"
"net/url"
"strings" "strings"
"github.com/leonardotrapani/hyprvoice/internal/provider" "github.com/leonardotrapani/hyprvoice/internal/provider"
@@ -34,6 +35,8 @@ func envVarForProvider(registryName string) string {
return "ELEVENLABS_API_KEY" return "ELEVENLABS_API_KEY"
case "deepgram": case "deepgram":
return "DEEPGRAM_API_KEY" return "DEEPGRAM_API_KEY"
case "llama-swap":
return "LLAMA_SWAP_API_KEY"
default: default:
return "" return ""
} }
@@ -82,27 +85,40 @@ func (c *Config) Validate() error {
strings.Title(registryName), registryName, envVar) strings.Title(registryName), registryName, envVar)
} }
} }
if registryName == provider.ProviderLlamaSwap {
if err := validateLlamaSwapBaseURL(c.Providers[provider.ProviderLlamaSwap].BaseURL); err != nil {
return err
}
}
// validate model exists // validate model exists
if c.Transcription.Model == "" { if c.Transcription.Model == "" {
return fmt.Errorf("invalid transcription.model: empty") return fmt.Errorf("invalid transcription.model: empty")
} }
// validate model exists in provider // LlamaSwap is an OpenAI-compatible router: its model IDs are defined by the
_, err := provider.GetModel(registryName, c.Transcription.Model) // remote server, so they cannot be validated against Hyprvoice's static registry.
if err != nil { if registryName == provider.ProviderLlamaSwap {
models := provider.ModelsOfType(p, provider.Transcription) if c.Transcription.Streaming {
modelIDs := make([]string, len(models)) return fmt.Errorf("llama-swap transcription supports batch mode only (set transcription.streaming = false)")
for i, m := range models { }
modelIDs[i] = m.ID } 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) // validate language-model compatibility using effective language (transcription overrides general)
effectiveLanguage := c.resolveEffectiveLanguage() effectiveLanguage := c.resolveEffectiveLanguage()
if err := ValidateModelLanguageCompatibility(registryName, c.Transcription.Model, effectiveLanguage); err != nil { if err := ValidateModelLanguageCompatibility(registryName, c.Transcription.Model, effectiveLanguage); err != nil {
return err return err
}
} }
// LLM validation // 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, ", ")) return fmt.Errorf("invalid llm.provider: %s (available: %s)", c.LLM.Provider, strings.Join(providers, ", "))
} }
// validate LLM model exists if c.LLM.Provider == provider.ProviderLlamaSwap {
llmModel, err := provider.GetModel(c.LLM.Provider, c.LLM.Model) if err := validateLlamaSwapBaseURL(c.Providers[provider.ProviderLlamaSwap].BaseURL); err != nil {
if err != nil { return err
models := provider.ModelsOfType(llmProvider, provider.LLM) }
modelIDs := make([]string, len(models)) } else {
for i, m := range models { // validate LLM model exists
modelIDs[i] = m.ID 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 // verify model is actually an LLM
if llmModel.Type != provider.LLM { if llmModel.Type != provider.LLM {
return fmt.Errorf("invalid llm.model: %s is not an LLM model", c.LLM.Model) return fmt.Errorf("invalid llm.model: %s is not an LLM model", c.LLM.Model)
}
} }
// validate LLM API key // validate LLM API key
@@ -151,10 +173,10 @@ func (c *Config) Validate() error {
if len(c.Injection.Backends) == 0 { if len(c.Injection.Backends) == 0 {
return fmt.Errorf("invalid injection.backends: empty (must have at least one backend)") 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 { for _, backend := range c.Injection.Backends {
if !validBackends[backend] { 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 { if c.Injection.YdotoolTimeout <= 0 {
@@ -175,6 +197,20 @@ func (c *Config) Validate() error {
return nil 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. // 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). // Returns error if the language is not supported, nil if supported or if langCode is empty (auto).
func ValidateModelLanguageCompatibility(registryProvider, modelID, langCode string) error { func ValidateModelLanguageCompatibility(registryProvider, modelID, langCode string) error {
+141
View File
@@ -0,0 +1,141 @@
package injection
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
)
// clipboardPasteBackend temporarily puts text on the regular clipboard, then
// uses wtype to issue Ctrl+V. This works in applications that do not support
// the primary selection, such as browsers and Electron apps. The previous
// text clipboard is restored after the paste has been delivered.
type clipboardPasteBackend struct {
clipboard *clipboardBackend
wtype *wtypeBackend
ctrlShiftVClasses []string
}
func NewClipboardPasteBackend(ctrlShiftVClasses []string) Backend {
return &clipboardPasteBackend{
clipboard: NewClipboardBackend().(*clipboardBackend),
wtype: NewWtypeBackend().(*wtypeBackend),
ctrlShiftVClasses: ctrlShiftVClasses,
}
}
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.wtype.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
}
// wl-paste only needs to keep the clipboard owner alive while it reads the
// selection, so this command returns with the exact existing text content.
// --no-newline prevents wl-paste from adding a terminal-friendly newline.
previousClipboard, restore, err := c.readClipboard(ctx)
if err != nil {
return err
}
copyText := exec.CommandContext(ctx, "wl-copy")
copyText.Stdin = strings.NewReader(text)
if err := copyText.Run(); err != nil {
return fmt.Errorf("copy transcription to clipboard: %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()
}
// wtype resolves the keysym itself, avoiding ydotool's physical-keycode
// layout problem on Dvorak and Colemak setups. Terminal emulators typically
// reserve Ctrl+V, so configured window classes use Ctrl+Shift+V instead.
args := []string{"-M", "ctrl"}
if c.shouldUseCtrlShiftV(ctx) {
args = append(args, "-M", "shift", "-k", "v", "-m", "shift")
} else {
args = append(args, "-k", "v")
}
args = append(args, "-m", "ctrl")
cmd := exec.CommandContext(ctx, "wtype", args...)
if err := cmd.Run(); err != nil {
return fmt.Errorf("paste clipboard with wtype: %w", err)
}
if !restore {
return nil
}
// Receiving a Wayland paste is asynchronous. Leave the transcription
// available briefly, then put the user's prior clipboard back.
select {
case <-time.After(300 * time.Millisecond):
case <-ctx.Done():
return ctx.Err()
}
restoreClipboard := exec.CommandContext(ctx, "wl-copy")
restoreClipboard.Stdin = strings.NewReader(previousClipboard)
if err := restoreClipboard.Run(); err != nil {
return fmt.Errorf("restore previous clipboard: %w", err)
}
return nil
}
func (c *clipboardPasteBackend) shouldUseCtrlShiftV(ctx context.Context) bool {
if len(c.ctrlShiftVClasses) == 0 {
return false
}
cmd := exec.CommandContext(ctx, "hyprctl", "activewindow", "-j")
output, err := cmd.Output()
if err != nil {
return false
}
var activeWindow struct {
Class string `json:"class"`
}
if err := json.Unmarshal(output, &activeWindow); err != nil {
return false
}
class := strings.ToLower(activeWindow.Class)
for _, match := range c.ctrlShiftVClasses {
if match = strings.TrimSpace(strings.ToLower(match)); match != "" && strings.Contains(class, match) {
return true
}
}
return false
}
func (c *clipboardPasteBackend) readClipboard(ctx context.Context) (text string, restore bool, err error) {
cmd := exec.CommandContext(ctx, "wl-paste", "--no-newline")
output, err := cmd.Output()
if err != nil {
// No clipboard owner is normal. There is simply nothing to restore.
if _, ok := err.(*exec.ExitError); ok {
return "", false, nil
}
return "", false, fmt.Errorf("read existing clipboard: %w", err)
}
return string(output), true, nil
}
+7 -4
View File
@@ -12,10 +12,11 @@ type Injector interface {
} }
type Config struct { 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 YdotoolTimeout time.Duration // Timeout for ydotool commands
WtypeTimeout time.Duration // Timeout for wtype commands WtypeTimeout time.Duration // Timeout for wtype commands
ClipboardTimeout time.Duration // Timeout for clipboard operations ClipboardTimeout time.Duration // Timeout for clipboard operations
CtrlShiftVClasses []string // Hyprland window-class substrings that paste with Ctrl+Shift+V
} }
type injector struct { type injector struct {
@@ -34,6 +35,8 @@ func NewInjector(config Config) Injector {
backends = append(backends, NewWtypeBackend()) backends = append(backends, NewWtypeBackend())
case "clipboard": case "clipboard":
backends = append(backends, NewClipboardBackend()) backends = append(backends, NewClipboardBackend())
case "clipboard-paste":
backends = append(backends, NewClipboardPasteBackend(config.CtrlShiftVClasses))
default: default:
log.Printf("Injection: unknown backend %q, skipping", name) 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) { func TestInjector_Inject(t *testing.T) {
// Skip integration tests in CI environments // This test invokes real Wayland input tools and must run in a graphical
if os.Getenv("CI") == "true" { // session, not merely outside CI.
t.Skip("Skipping integration test in CI environment") if os.Getenv("CI") == "true" || os.Getenv("WAYLAND_DISPLAY") == "" {
t.Skip("Skipping integration test outside a Wayland session")
} }
tests := []struct { tests := []struct {
@@ -202,6 +203,13 @@ func TestClipboardBackend(t *testing.T) {
t.Logf("clipboard is available") t.Logf("clipboard is available")
} }
func TestClipboardPasteBackend(t *testing.T) {
backend := NewClipboardPasteBackend([]string{"ghostty"})
if backend.Name() != "clipboard-paste" {
t.Errorf("Name() = %s, want clipboard-paste", backend.Name())
}
}
// TestInjector_ClipboardMode tests clipboard-only injection // TestInjector_ClipboardMode tests clipboard-only injection
func TestInjector_ClipboardMode(t *testing.T) { func TestInjector_ClipboardMode(t *testing.T) {
config := Config{ 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 { if err := y.Available(); err != nil {
return err return err
} }
socketPath := y.getSocketPath()
// ydotool type -- "text" // ydotool type -- "text"
cmd := exec.CommandContext(ctx, "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 { if err := cmd.Run(); err != nil {
return fmt.Errorf("ydotool failed: %w", err) return fmt.Errorf("ydotool failed: %w", err)
} }
+6
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"strings"
"time" "time"
"github.com/sashabaranov/go-openai" "github.com/sashabaranov/go-openai"
@@ -17,6 +18,11 @@ type OpenAIAdapter struct {
// NewOpenAIAdapter creates a new OpenAI LLM adapter // NewOpenAIAdapter creates a new OpenAI LLM adapter
func NewOpenAIAdapter(cfg Config) *OpenAIAdapter { 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{ return &OpenAIAdapter{
client: openai.NewClient(cfg.APIKey), client: openai.NewClient(cfg.APIKey),
config: cfg, config: cfg,
+9
View File
@@ -21,6 +21,7 @@ type Config struct {
RemoveFillerWords bool RemoveFillerWords bool
CustomPrompt string CustomPrompt string
Keywords []string Keywords []string
BaseURL string
} }
// NewAdapter creates an LLM adapter based on the provider // 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 nil, fmt.Errorf("Groq API key required")
} }
return NewGroqAdapter(cfg), nil 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: default:
return nil, fmt.Errorf("unsupported LLM provider: %s", cfg.Provider) return nil, fmt.Errorf("unsupported LLM provider: %s", cfg.Provider)
} }
+17
View File
@@ -3,6 +3,7 @@ package pipeline
import ( import (
"context" "context"
"log" "log"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -316,6 +317,7 @@ func (p *pipeline) handleInjectAction(ctx context.Context, recorder recording.Re
RemoveFillerWords: llmCfg.RemoveFillerWords, RemoveFillerWords: llmCfg.RemoveFillerWords,
CustomPrompt: llmCfg.CustomPrompt, CustomPrompt: llmCfg.CustomPrompt,
Keywords: llmCfg.Keywords, Keywords: llmCfg.Keywords,
BaseURL: llmCfg.BaseURL,
}) })
if err != nil { if err != nil {
log.Printf("Pipeline: Failed to create LLM adapter: %v, using raw transcription", err) log.Printf("Pipeline: Failed to create LLM adapter: %v, using raw transcription", err)
@@ -331,6 +333,21 @@ func (p *pipeline) handleInjectAction(ctx context.Context, recorder recording.Re
p.setStatus(Injecting) 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)
// Speech APIs occasionally include leading or trailing whitespace (for
// example Whisper commonly returns a leading space). It is transport
// formatting rather than dictated content, so never inject it.
textToInject = strings.TrimSpace(textToInject)
injector := p.injectorFactory(p.config.ToInjectionConfig()) injector := p.injectorFactory(p.config.ToInjectionConfig())
if err := injector.Inject(ctx, textToInject); err != nil { 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" ProviderElevenLabs = "elevenlabs"
ProviderDeepgram = "deepgram" ProviderDeepgram = "deepgram"
ProviderWhisperCpp = "whisper-cpp" ProviderWhisperCpp = "whisper-cpp"
ProviderLlamaSwap = "llama-swap"
) )
// Config provider names (used in config file transcription.provider) // Config provider names (used in config file transcription.provider)
@@ -27,6 +28,7 @@ const (
EnvMistralKey = "MISTRAL_API_KEY" EnvMistralKey = "MISTRAL_API_KEY"
EnvElevenLabsKey = "ELEVENLABS_API_KEY" EnvElevenLabsKey = "ELEVENLABS_API_KEY"
EnvDeepgramKey = "DEEPGRAM_API_KEY" EnvDeepgramKey = "DEEPGRAM_API_KEY"
EnvLlamaSwapKey = "LLAMA_SWAP_API_KEY"
) )
// Adapter type constants for transcription backends // Adapter type constants for transcription backends
@@ -66,6 +68,8 @@ func EnvVarForProvider(provider string) string {
return EnvElevenLabsKey return EnvElevenLabsKey
case ProviderDeepgram: case ProviderDeepgram:
return EnvDeepgramKey return EnvDeepgramKey
case ProviderLlamaSwap:
return EnvLlamaSwapKey
default: default:
return "" return ""
} }
+1
View File
@@ -31,6 +31,7 @@ func init() {
Register(&ElevenLabsProvider{}) Register(&ElevenLabsProvider{})
Register(&WhisperCppProvider{}) Register(&WhisperCppProvider{})
Register(&DeepgramProvider{}) Register(&DeepgramProvider{})
Register(&LlamaSwapProvider{})
} }
// Register adds a provider to the registry // 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 != "" { if endpoint != nil && endpoint.BaseURL != "" {
// use custom endpoint // use custom endpoint
clientConfig := openai.DefaultConfig(apiKey) clientConfig := openai.DefaultConfig(apiKey)
clientConfig.BaseURL = endpoint.BaseURL + "/v1" clientConfig.BaseURL = strings.TrimRight(endpoint.BaseURL, "/") + "/v1"
client = openai.NewClientWithConfig(clientConfig) client = openai.NewClientWithConfig(clientConfig)
} else { } else {
// default to OpenAI // default to OpenAI
+19 -2
View File
@@ -31,8 +31,9 @@ type Config struct {
Language string Language string
Model string Model string
Keywords []string Keywords []string
Threads int // CPU threads for local transcription (0 = auto) Threads int // CPU threads for local transcription (0 = auto)
Streaming bool // use streaming mode if model supports it 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 // 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)) 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 // lookup model from provider
model, err := provider.GetModel(registryProvider, config.Model) model, err := provider.GetModel(registryProvider, config.Model)
if err != nil { if err != nil {
+2 -2
View File
@@ -1,6 +1,6 @@
# Maintainer: Leonardo Trapani <leo@trapani.sh> # Maintainer: Leonardo Trapani <leo@trapani.sh>
pkgname=hyprvoice-bin pkgname=hyprvoice-bin
pkgver=v1.0.1 pkgver=v1.0.2
pkgrel=1 pkgrel=1
pkgdesc="Voice-powered typing for Wayland/Hyprland" pkgdesc="Voice-powered typing for Wayland/Hyprland"
arch=('x86_64') arch=('x86_64')
@@ -26,7 +26,7 @@ source=(
"hyprvoice-${pkgver}::https://github.com/leonardotrapani/hyprvoice/releases/download/${pkgver}/hyprvoice-linux-x86_64" "hyprvoice-${pkgver}::https://github.com/leonardotrapani/hyprvoice/releases/download/${pkgver}/hyprvoice-linux-x86_64"
"hyprvoice.service" "hyprvoice.service"
) )
sha256sums=('d0d1dc952fe374917e7598b36a7c228a4a5222dd1a68f4c859a46e87a1ee1613' sha256sums=('d055b01352b0cb4e50ac2e729e0e1baac259468559bb86a792b2f20f30bb6cc4'
'5631c957777882870e61934176d2142fab6c553bb650ffd8b3eb74b8dce955bd') '5631c957777882870e61934176d2142fab6c553bb650ffd8b3eb74b8dce955bd')
install=hyprvoice.install install=hyprvoice.install