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
```
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
+68 -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,15 +482,19 @@ 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"
ctrl_shift_v_classes = ["ghostty"] # Window-class substrings that paste with Ctrl+Shift+V
```
### Injection Backends
- **`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).
- **`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
+7
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
@@ -110,5 +116,6 @@ func (c *Config) ToInjectionConfig() injection.Config {
YdotoolTimeout: c.Injection.YdotoolTimeout,
WtypeTimeout: c.Injection.WtypeTimeout,
ClipboardTimeout: c.Injection.ClipboardTimeout,
CtrlShiftVClasses: c.Injection.CtrlShiftVClasses,
}
}
+1
View File
@@ -24,6 +24,7 @@ func DefaultConfig() *Config {
YdotoolTimeout: 5 * time.Second,
WtypeTimeout: 5 * time.Second,
ClipboardTimeout: 3 * time.Second,
CtrlShiftVClasses: []string{"ghostty"},
},
Notifications: NotificationsConfig{
Enabled: false,
+21 -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")
}
}
@@ -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(" wtype_timeout = %q\n", cfg.Injection.WtypeTimeout.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")
// Notifications
@@ -221,6 +232,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 +255,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 +267,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 +286,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 +336,16 @@ 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": 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.
# - "wtype": Uses wtype for Wayland. May have issues with some Chromium apps.
# - "clipboard": Copies to clipboard only (most reliable, requires manual paste).
+3
View File
@@ -26,6 +26,7 @@ type Config struct {
// ProviderConfig holds API key for a provider
type ProviderConfig struct {
APIKey string `toml:"api_key"`
BaseURL string `toml:"base_url"` // OpenAI-compatible base URL, without /v1
}
// LLMConfig configures the LLM post-processing phase
@@ -74,6 +75,7 @@ type InjectionConfig struct {
YdotoolTimeout time.Duration `toml:"ydotool_timeout"`
WtypeTimeout time.Duration `toml:"wtype_timeout"`
ClipboardTimeout time.Duration `toml:"clipboard_timeout"`
CtrlShiftVClasses []string `toml:"ctrl_shift_v_classes"`
}
type NotificationsConfig struct {
@@ -139,4 +141,5 @@ type LLMAdapterConfig struct {
RemoveFillerWords bool
CustomPrompt string
Keywords []string
BaseURL string
}
+38 -2
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,12 +85,24 @@ 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")
}
// 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 {
@@ -104,6 +119,7 @@ func (c *Config) Validate() error {
if err := ValidateModelLanguageCompatibility(registryName, c.Transcription.Model, effectiveLanguage); err != nil {
return err
}
}
// LLM validation
if c.LLM.Enabled {
@@ -121,6 +137,11 @@ func (c *Config) Validate() error {
return fmt.Errorf("invalid llm.provider: %s (available: %s)", c.LLM.Provider, strings.Join(providers, ", "))
}
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 {
@@ -136,6 +157,7 @@ func (c *Config) Validate() error {
if llmModel.Type != provider.LLM {
return fmt.Errorf("invalid llm.model: %s is not an LLM model", c.LLM.Model)
}
}
// validate LLM API key
if llmProvider.RequiresAPIKey() {
@@ -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 {
+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
}
+4 -1
View File
@@ -12,10 +12,11 @@ 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
CtrlShiftVClasses []string // Hyprland window-class substrings that paste with Ctrl+Shift+V
}
type injector struct {
@@ -34,6 +35,8 @@ func NewInjector(config Config) Injector {
backends = append(backends, NewWtypeBackend())
case "clipboard":
backends = append(backends, NewClipboardBackend())
case "clipboard-paste":
backends = append(backends, NewClipboardPasteBackend(config.CtrlShiftVClasses))
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([]string{"ghostty"})
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)
}
+17
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,21 @@ 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)
// 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())
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
+17
View File
@@ -33,6 +33,7 @@ type Config struct {
Keywords []string
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