feat: better configuration

This commit is contained in:
leonardotrapani
2026-01-31 22:37:58 +01:00
parent 077e08b887
commit f251df302d
21 changed files with 2972 additions and 2906 deletions
+17 -402
View File
@@ -94,17 +94,21 @@ sudo usermod -aG input $USER
After installing via AUR: After installing via AUR:
1. **Configure hyprvoice interactively:** 1. **Configure hyprvoice interactively:**
```bash ```bash
hyprvoice configure hyprvoice configure
``` ```
This wizard will guide you through setting up your transcription provider, API key, audio preferences, and other settings. This wizard will guide you through setting up your transcription provider, API key, audio preferences, and other settings.
2. **Enable and start the service:** 2. **Enable and start the service:**
```bash ```bash
systemctl --user enable --now hyprvoice.service systemctl --user enable --now hyprvoice.service
``` ```
3. **Add keybinding to your window manager:** 3. **Add keybinding to your window manager:**
```bash ```bash
# For Hyprland, add to ~/.config/hypr/hyprland.conf # For Hyprland, add to ~/.config/hypr/hyprland.conf
bind = SUPER, R, exec, hyprvoice toggle bind = SUPER, R, exec, hyprvoice toggle
@@ -203,415 +207,25 @@ hyprvoice status
## Configuration ## Configuration
Use the interactive configuration wizard: The recommended way to configure hyprvoice is through the interactive wizard:
```bash ```bash
hyprvoice configure hyprvoice configure
``` ```
This will guide you through setting up: The wizard guides you through all settings with a user-friendly interface:
- Provider API keys (OpenAI, Groq, Mistral, ElevenLabs) - **Providers** - API keys for OpenAI, Groq, Mistral, ElevenLabs
- Transcription provider and model - **Transcription** - Speech-to-text provider and model selection
- LLM post-processing options (enabled by default) - **LLM** - Post-processing to clean up transcriptions (enabled by default)
- Keywords for domain-specific terms - **Keywords** - Domain-specific terms for better accuracy
- Text injection method (clipboard/typing/fallback) - **Injection** - How text is typed (ydotool, wtype, clipboard)
- Notification settings - **Notifications** - Desktop notification preferences
- **Advanced Settings** - Recording parameters, timeouts
Configuration is stored in `~/.config/hyprvoice/config.toml` and can also be edited manually. Changes are applied immediately without restarting the daemon. Configuration is stored in `~/.config/hyprvoice/config.toml`. Changes are applied immediately without restarting the daemon.
### Unified Provider System For manual configuration and detailed options, see [docs/config.md](docs/config.md).
Hyprvoice uses a unified provider system where API keys are configured once and shared between transcription and LLM features:
```toml
# Configure API keys for providers you want to use
[providers.openai]
api_key = "sk-..." # Or set OPENAI_API_KEY env var
[providers.groq]
api_key = "gsk_..." # Or set GROQ_API_KEY env var
[providers.mistral]
api_key = "..." # Or set MISTRAL_API_KEY env var
[providers.elevenlabs]
api_key = "..." # Or set ELEVENLABS_API_KEY env var
```
**API key resolution order:**
1. `[providers.X]` section in config
2. Legacy `transcription.api_key` (backward compatible)
3. Environment variable (`OPENAI_API_KEY`, `GROQ_API_KEY`, etc.)
### Transcription Providers
Hyprvoice supports multiple transcription backends:
#### OpenAI Whisper API
Cloud-based transcription using OpenAI's Whisper API:
```toml
[transcription]
provider = "openai"
language = "" # Empty for auto-detect, or "en", "es", "fr", etc.
model = "whisper-1"
```
**Features:**
- High-quality transcription
- Supports 50+ languages
- Auto-detection or specify language for better accuracy
#### Groq Whisper API (Transcription)
Fast cloud-based transcription using Groq's Whisper API:
```toml
[transcription]
provider = "groq-transcription"
language = "" # Empty for auto-detect, or "en", "es", "fr", etc.
model = "whisper-large-v3" # Or "whisper-large-v3-turbo" for faster processing
```
**Features:**
- Ultra-fast transcription (significantly faster than OpenAI)
- Same Whisper model quality
- Supports 50+ languages
- Free tier available with generous limits
#### Groq Translation API
Fast translation of audio to English using Groq's Whisper API:
```toml
[transcription]
provider = "groq-translation"
language = "es" # Optional: hint source language for better accuracy
model = "whisper-large-v3-turbo"
```
**Features:**
- Translates any language audio → English text
- Ultra-fast processing
- Language field hints at source language (improves accuracy)
- Always outputs English regardless of input language
### LLM Post-Processing
LLM post-processing is **enabled by default** and significantly improves transcription quality. After transcription, the text is processed by an LLM to:
- Remove stutters and repeated words ("I I I want" → "I want")
- Add proper punctuation
- Fix grammar errors
- Remove filler words ("um", "uh", "like", "you know", etc.)
#### Basic Configuration
```toml
[llm]
enabled = true # Disable with false if you want raw transcriptions
provider = "openai" # "openai" or "groq"
model = "gpt-4o-mini" # OpenAI: "gpt-4o-mini", Groq: "llama-3.3-70b-versatile"
```
#### Post-Processing Options
All options are enabled by default. Disable specific ones as needed:
```toml
[llm.post_processing]
remove_stutters = true # "I I I want" → "I want"
add_punctuation = true # Adds periods, commas, etc.
fix_grammar = true # Fixes grammatical errors
remove_filler_words = true # Removes "um", "uh", "like", "you know"
```
#### Custom Prompts
Add custom instructions for specific use cases:
```toml
[llm.custom_prompt]
enabled = true
prompt = "Format as bullet points"
```
**Use cases for custom prompts:**
- "Format as bullet points" - for note-taking
- "Keep technical terms exactly as spoken" - for programming dictation
- "Use formal language" - for professional documents
- "Translate to Spanish" - for translation workflows
#### LLM Provider Recommendations
| Provider | Model | Best For |
| -------- | ----- | -------- |
| OpenAI | gpt-4o-mini | Best quality/cost balance (default) |
| Groq | llama-3.3-70b-versatile | Fastest processing, free tier |
Both providers use the same API key as transcription if you're using OpenAI or Groq for transcription.
### Keywords
Keywords help both transcription and LLM understand domain-specific terms, names, and technical vocabulary:
```toml
keywords = ["Hyprland", "Wayland", "PipeWire", "Claude", "TypeScript"]
```
**How keywords work:**
- **Transcription**: Passed as initial_prompt to Whisper, improving recognition of these terms
- **LLM**: Included in the system prompt to ensure correct spelling
**When to use keywords:**
- Names of people, companies, or products
- Technical terminology specific to your field
- Acronyms or abbreviations
- Words commonly misheard by speech-to-text
### Example Configurations
#### Fast Transcription Only (No LLM)
```toml
[providers.groq]
api_key = "gsk_..."
[transcription]
provider = "groq-transcription"
model = "whisper-large-v3-turbo"
[llm]
enabled = false
```
#### High Quality with OpenAI (Default)
```toml
[providers.openai]
api_key = "sk-..."
[transcription]
provider = "openai"
model = "whisper-1"
[llm]
enabled = true
provider = "openai"
model = "gpt-4o-mini"
```
#### Budget-Friendly with Groq
```toml
[providers.groq]
api_key = "gsk_..."
[transcription]
provider = "groq-transcription"
model = "whisper-large-v3-turbo"
[llm]
enabled = true
provider = "groq"
model = "llama-3.3-70b-versatile"
```
#### Mixed Providers (Groq Transcription + OpenAI LLM)
```toml
[providers.openai]
api_key = "sk-..."
[providers.groq]
api_key = "gsk_..."
[transcription]
provider = "groq-transcription"
model = "whisper-large-v3-turbo"
[llm]
enabled = true
provider = "openai"
model = "gpt-4o-mini"
```
### Migration from Old Config Format
If you're upgrading from an older version with `transcription.api_key`:
**Old format (still works):**
```toml
[transcription]
provider = "openai"
api_key = "sk-..." # Legacy location
model = "whisper-1"
```
**New format (recommended):**
```toml
[providers.openai]
api_key = "sk-..." # Unified location
[transcription]
provider = "openai"
model = "whisper-1"
[llm]
enabled = true
provider = "openai"
model = "gpt-4o-mini"
```
Run `hyprvoice configure` to interactively update your config to the new format.
#### whisper.cpp Local (Planned) -> Not yet implemented
Private, offline transcription using local models:
```toml
[transcription]
provider = "whisper_cpp"
model_path = "~/models/ggml-base.en.bin"
threads = 4
```
#### Recording Configuration
Audio capture settings:
```toml
[recording]
sample_rate = 16000 # Audio sample rate in Hz
channels = 1 # Number of audio channels (1 for mono)
format = "s16" # Audio format (s16 recommended)
buffer_size = 8192 # Internal buffer size in bytes
device = "" # PipeWire device (empty for default)
channel_buffer_size = 30 # Audio frame buffer size
timeout = "5m" # Maximum recording duration (prevents runaway recordings)
```
**Recording Timeout:**
- Prevents accidental long recordings that could consume resources
- Default: 5 minutes (`"5m"`)
- Format: Go duration strings like `"30s"`, `"2m"`, `"10m"`
- Recording automatically stops when timeout is reached
#### Text Injection
Configurable text injection with multiple backends:
```toml
[injection]
backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain
ydotool_timeout = "5s"
wtype_timeout = "5s"
clipboard_timeout = "3s"
```
**Injection Backends:**
- **`ydotool`**: Uses ydotool (requires `ydotoold` daemon for ydotool v1.0.0+). Most compatible with Chromium/Electron apps.
- **`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.
**Fallback Chain:**
Backends are tried in order. The first successful one wins. Example configurations:
```toml
# Clipboard only (safest, always works)
backends = ["clipboard"]
# wtype with clipboard fallback
backends = ["wtype", "clipboard"]
# Full fallback chain (default) - best compatibility
backends = ["ydotool", "wtype", "clipboard"]
# ydotool only (if you have it set up)
backends = ["ydotool"]
```
**ydotool Setup:**
ydotool requires the `ydotoold` daemon running (for ydotool v1.0.0+) and access to `/dev/uinput`:
```bash
# Start ydotool daemon (systemd)
systemctl --user enable --now ydotool
# Or add user to input group
sudo usermod -aG input $USER
# Then logout/login
# For Hyprland, add to config to set correct keyboard layout:
# device:ydotoold-virtual-device {
# kb_layout = us
# }
```
**Behavior:**
- Backends are tried in order until one succeeds
- Include `clipboard` in the chain if you want text copied to clipboard as fallback
#### Notifications
Desktop notification settings:
```toml
[notifications]
enabled = true # Enable/disable notifications
type = "desktop" # "desktop", "log", or "none"
```
**Notification Types:**
- **`desktop`**: Use notify-send for desktop notifications
- **`log`**: Log messages to console only
- **`none`**: Disable all notifications
Always keep `type = "desktop"` unless debugging.
##### Custom Notification Messages
You can customize notification text via the `[notifications.messages]` section.
```toml
[notifications.messages]
[notifications.messages.recording_started]
title = "Hyprvoice"
body = "Recording Started"
[notifications.messages.transcribing]
title = "Hyprvoice"
body = "Recording Ended... Transcribing"
[notifications.messages.llm_processing]
title = "Hyprvoice"
body = "Processing..."
[notifications.messages.config_reloaded]
title = "Hyprvoice"
body = "Config Reloaded"
[notifications.messages.operation_cancelled]
title = "Hyprvoice"
body = "Operation Cancelled"
[notifications.messages.recording_aborted]
body = "Recording Aborted"
[notifications.messages.injection_aborted]
body = "Injection Aborted"
```
### Configuration Hot-Reloading
The daemon automatically watches the config file for changes and applies them immediately:
- **Notification settings**: Applied instantly
- **Injection settings**: Applied to current and future operations
- **Recording/Transcription/LLM settings**: Applied to new recording sessions
- **Invalid configs**: Rejected with error notification, daemon continues with previous config
### Service Management ### Service Management
@@ -642,7 +256,7 @@ journalctl --user -u hyprvoice.service -f
## Development Status ## Development Status
| Component | Status | Notes | | Component | Status | Notes |
| ---------------------- | ------ | ----------------------------------------------------- | | ------------------------ | ------ | ----------------------------------------------------- |
| Core daemon & IPC | ✅ | Unix socket control plane | | Core daemon & IPC | ✅ | Unix socket control plane |
| Recording workflow | ✅ | Toggle recording via PipeWire | | Recording workflow | ✅ | Toggle recording via PipeWire |
| Audio capture | ✅ | Efficient PipeWire integration | | Audio capture | ✅ | Efficient PipeWire integration |
@@ -868,6 +482,7 @@ export PATH="$HOME/.local/bin:$PATH"
See [`packaging/RELEASE.md`](packaging/RELEASE.md) for complete release process including AUR deployment. See [`packaging/RELEASE.md`](packaging/RELEASE.md) for complete release process including AUR deployment.
Quick start for AUR: Quick start for AUR:
```bash ```bash
# After creating your first GitHub release # After creating your first GitHub release
cd packaging/ cd packaging/
+6
View File
@@ -344,6 +344,11 @@ func saveConfig(cfg *config.Config) error {
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.Transcribing.Title)) sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.Transcribing.Title))
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.Transcribing.Body)) sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.Transcribing.Body))
} }
if msgs.LLMProcessing.Title != "" || msgs.LLMProcessing.Body != "" {
sb.WriteString(" [notifications.messages.llm_processing]\n")
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.LLMProcessing.Title))
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.LLMProcessing.Body))
}
if msgs.ConfigReloaded.Title != "" || msgs.ConfigReloaded.Body != "" { if msgs.ConfigReloaded.Title != "" || msgs.ConfigReloaded.Body != "" {
sb.WriteString(" [notifications.messages.config_reloaded]\n") sb.WriteString(" [notifications.messages.config_reloaded]\n")
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.ConfigReloaded.Title)) sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.ConfigReloaded.Title))
@@ -374,6 +379,7 @@ func saveConfig(cfg *config.Config) error {
func hasCustomMessages(msgs config.MessagesConfig) bool { func hasCustomMessages(msgs config.MessagesConfig) bool {
return msgs.RecordingStarted.Title != "" || msgs.RecordingStarted.Body != "" || return msgs.RecordingStarted.Title != "" || msgs.RecordingStarted.Body != "" ||
msgs.Transcribing.Title != "" || msgs.Transcribing.Body != "" || msgs.Transcribing.Title != "" || msgs.Transcribing.Body != "" ||
msgs.LLMProcessing.Title != "" || msgs.LLMProcessing.Body != "" ||
msgs.ConfigReloaded.Title != "" || msgs.ConfigReloaded.Body != "" || msgs.ConfigReloaded.Title != "" || msgs.ConfigReloaded.Body != "" ||
msgs.OperationCancelled.Title != "" || msgs.OperationCancelled.Body != "" || msgs.OperationCancelled.Title != "" || msgs.OperationCancelled.Body != "" ||
msgs.RecordingAborted.Body != "" || msgs.RecordingAborted.Body != "" ||
+432
View File
@@ -0,0 +1,432 @@
# Configuration Reference
This document covers manual configuration of hyprvoice via the `config.toml` file. For most users, the interactive wizard is recommended:
```bash
hyprvoice configure
```
Configuration is stored in `~/.config/hyprvoice/config.toml` and changes are applied immediately without restarting the daemon.
## Table of Contents
- [Unified Provider System](#unified-provider-system)
- [Transcription Providers](#transcription-providers)
- [LLM Post-Processing](#llm-post-processing)
- [Keywords](#keywords)
- [Recording Configuration](#recording-configuration)
- [Text Injection](#text-injection)
- [Notifications](#notifications)
- [Example Configurations](#example-configurations)
- [Migration from Old Config Format](#migration-from-old-config-format)
## Unified Provider System
Hyprvoice uses a unified provider system where API keys are configured once and shared between transcription and LLM features:
```toml
# Configure API keys for providers you want to use
[providers.openai]
api_key = "sk-..." # Or set OPENAI_API_KEY env var
[providers.groq]
api_key = "gsk_..." # Or set GROQ_API_KEY env var
[providers.mistral]
api_key = "..." # Or set MISTRAL_API_KEY env var
[providers.elevenlabs]
api_key = "..." # Or set ELEVENLABS_API_KEY env var
```
**API key resolution order:**
1. `[providers.X]` section in config
2. Environment variable (`OPENAI_API_KEY`, `GROQ_API_KEY`, etc.)
## Transcription Providers
Hyprvoice supports multiple transcription backends:
### OpenAI Whisper API
Cloud-based transcription using OpenAI's Whisper API:
```toml
[transcription]
provider = "openai"
language = "" # Empty for auto-detect, or "en", "es", "fr", etc.
model = "whisper-1"
```
**Features:**
- High-quality transcription
- Supports 50+ languages
- Auto-detection or specify language for better accuracy
### Groq Whisper API (Transcription)
Fast cloud-based transcription using Groq's Whisper API:
```toml
[transcription]
provider = "groq-transcription"
language = "" # Empty for auto-detect, or "en", "es", "fr", etc.
model = "whisper-large-v3" # Or "whisper-large-v3-turbo" for faster processing
```
**Features:**
- Ultra-fast transcription (significantly faster than OpenAI)
- Same Whisper model quality
- Supports 50+ languages
- Free tier available with generous limits
### Groq Translation API
Fast translation of audio to English using Groq's Whisper API:
```toml
[transcription]
provider = "groq-translation"
language = "es" # Optional: hint source language for better accuracy
model = "whisper-large-v3"
```
**Features:**
- Translates any language audio → English text
- Ultra-fast processing
- Language field hints at source language (improves accuracy)
- Always outputs English regardless of input language
### Mistral Voxtral
Transcription using Mistral's Voxtral API, excellent for European languages:
```toml
[transcription]
provider = "mistral-transcription"
language = ""
model = "voxtral-mini-latest" # Or "voxtral-mini-2507"
```
### ElevenLabs Scribe
Transcription using ElevenLabs' Scribe API with 99 language support:
```toml
[transcription]
provider = "elevenlabs"
language = ""
model = "scribe_v1" # Or "scribe_v2" for real-time, lower latency
```
## LLM Post-Processing
LLM post-processing is **enabled by default** and significantly improves transcription quality. After transcription, the text is processed by an LLM to:
- Remove stutters and repeated words ("I I I want" → "I want")
- Add proper punctuation
- Fix grammar errors
- Remove filler words ("um", "uh", "like", "you know", etc.)
### Basic Configuration
```toml
[llm]
enabled = true # Disable with false if you want raw transcriptions
provider = "openai" # "openai" or "groq"
model = "gpt-4o-mini" # OpenAI: "gpt-4o-mini", Groq: "llama-3.3-70b-versatile"
```
### Post-Processing Options
All options are enabled by default. Disable specific ones as needed:
```toml
[llm.post_processing]
remove_stutters = true # "I I I want" → "I want"
add_punctuation = true # Adds periods, commas, etc.
fix_grammar = true # Fixes grammatical errors
remove_filler_words = true # Removes "um", "uh", "like", "you know"
```
### Custom Prompts
Add custom instructions for specific use cases:
```toml
[llm.custom_prompt]
enabled = true
prompt = "Format as bullet points"
```
**Use cases for custom prompts:**
- "Format as bullet points" - for note-taking
- "Keep technical terms exactly as spoken" - for programming dictation
- "Use formal language" - for professional documents
- "Translate to Spanish" - for translation workflows
### LLM Provider Recommendations
| Provider | Model | Best For |
| -------- | ----------------------- | ----------------------------------- |
| OpenAI | gpt-4o-mini | Best quality/cost balance (default) |
| Groq | llama-3.3-70b-versatile | Fastest processing, free tier |
## Keywords
Keywords help both transcription and LLM understand domain-specific terms, names, and technical vocabulary:
```toml
keywords = ["Hyprland", "Wayland", "PipeWire", "Claude", "TypeScript"]
```
**How keywords work:**
- **Transcription**: Passed as initial_prompt to Whisper, improving recognition of these terms
- **LLM**: Included in the system prompt to ensure correct spelling
**When to use keywords:**
- Names of people, companies, or products
- Technical terminology specific to your field
- Acronyms or abbreviations
- Words commonly misheard by speech-to-text
## Recording Configuration
Audio capture settings:
```toml
[recording]
sample_rate = 16000 # Audio sample rate in Hz (16000 recommended for speech)
channels = 1 # Number of audio channels (1 = mono, 2 = stereo)
format = "s16" # Audio format (s16 = 16-bit signed integers)
buffer_size = 8192 # Internal buffer size in bytes (larger = less CPU, more latency)
device = "" # PipeWire device name (empty = default microphone)
channel_buffer_size = 30 # Audio frame buffer size (frames to buffer)
timeout = "5m" # Maximum recording duration (e.g., "30s", "2m", "5m")
```
### Recording Timeout
- Prevents accidental long recordings that could consume resources
- Default: 5 minutes (`"5m"`)
- Format: Go duration strings like `"30s"`, `"2m"`, `"10m"`
- Recording automatically stops when timeout is reached
## Text Injection
Configurable text injection with multiple backends:
```toml
[injection]
backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain
ydotool_timeout = "5s"
wtype_timeout = "5s"
clipboard_timeout = "3s"
```
### Injection Backends
- **`ydotool`**: Uses ydotool (requires `ydotoold` daemon for ydotool v1.0.0+). Most compatible with Chromium/Electron apps.
- **`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.
### Fallback Chain
Backends are tried in order. The first successful one wins. Example configurations:
```toml
# Clipboard only (safest, always works)
backends = ["clipboard"]
# wtype with clipboard fallback
backends = ["wtype", "clipboard"]
# Full fallback chain (default) - best compatibility
backends = ["ydotool", "wtype", "clipboard"]
# ydotool only (if you have it set up)
backends = ["ydotool"]
```
### ydotool Setup
ydotool requires the `ydotoold` daemon running (for ydotool v1.0.0+) and access to `/dev/uinput`:
```bash
# Start ydotool daemon (systemd)
systemctl --user enable --now ydotool
# Or add user to input group
sudo usermod -aG input $USER
# Then logout/login
# For Hyprland, add to config to set correct keyboard layout:
# device:ydotoold-virtual-device {
# kb_layout = us
# }
```
## Notifications
Desktop notification settings:
```toml
[notifications]
enabled = true # Enable/disable notifications
type = "desktop" # "desktop", "log", or "none"
```
### Notification Types
- **`desktop`**: Use notify-send for desktop notifications
- **`log`**: Log messages to console only
- **`none`**: Disable all notifications
### Custom Notification Messages
You can customize notification text via the `[notifications.messages]` section:
```toml
[notifications.messages]
[notifications.messages.recording_started]
title = "Hyprvoice"
body = "Recording Started"
[notifications.messages.transcribing]
title = "Hyprvoice"
body = "Recording Ended... Transcribing"
[notifications.messages.llm_processing]
title = "Hyprvoice"
body = "Processing..."
[notifications.messages.config_reloaded]
title = "Hyprvoice"
body = "Config Reloaded"
[notifications.messages.operation_cancelled]
title = "Hyprvoice"
body = "Operation Cancelled"
[notifications.messages.recording_aborted]
body = "Recording Aborted"
[notifications.messages.injection_aborted]
body = "Injection Aborted"
```
**Emoji-only example** (for minimal pill-style notifications):
```toml
[notifications.messages.recording_started]
title = ""
body = "🎙️"
```
## Example Configurations
### Fast Transcription Only (No LLM)
```toml
[providers.groq]
api_key = "gsk_..."
[transcription]
provider = "groq-transcription"
model = "whisper-large-v3-turbo"
[llm]
enabled = false
```
### High Quality with OpenAI (Default)
```toml
[providers.openai]
api_key = "sk-..."
[transcription]
provider = "openai"
model = "whisper-1"
[llm]
enabled = true
provider = "openai"
model = "gpt-4o-mini"
```
### Budget-Friendly with Groq
```toml
[providers.groq]
api_key = "gsk_..."
[transcription]
provider = "groq-transcription"
model = "whisper-large-v3-turbo"
[llm]
enabled = true
provider = "groq"
model = "llama-3.3-70b-versatile"
```
### Mixed Providers (Groq Transcription + OpenAI LLM)
```toml
[providers.openai]
api_key = "sk-..."
[providers.groq]
api_key = "gsk_..."
[transcription]
provider = "groq-transcription"
model = "whisper-large-v3-turbo"
[llm]
enabled = true
provider = "openai"
model = "gpt-4o-mini"
```
## Migration from Old Config Format
If you're upgrading from an older version with `transcription.api_key`:
**Old format (still works):**
```toml
[transcription]
provider = "openai"
api_key = "sk-..." # Legacy location
model = "whisper-1"
```
**New format (recommended):**
```toml
[providers.openai]
api_key = "sk-..." # Unified location
[transcription]
provider = "openai"
model = "whisper-1"
[llm]
enabled = true
provider = "openai"
model = "gpt-4o-mini"
```
Run `hyprvoice configure` to interactively update your config to the new format.
## Configuration Hot-Reloading
The daemon automatically watches the config file for changes and applies them immediately:
- **Notification settings**: Applied instantly
- **Injection settings**: Applied to current and future operations
- **Recording/Transcription/LLM settings**: Applied to new recording sessions
- **Invalid configs**: Rejected with error notification, daemon continues with previous config
+3 -3
View File
@@ -4,7 +4,10 @@ go 1.24.5
require ( require (
github.com/BurntSushi/toml v1.5.0 github.com/BurntSushi/toml v1.5.0
github.com/charmbracelet/huh v0.8.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/fsnotify/fsnotify v1.9.0 github.com/fsnotify/fsnotify v1.9.0
github.com/muesli/termenv v0.16.0
github.com/sashabaranov/go-openai v1.41.1 github.com/sashabaranov/go-openai v1.41.1
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
) )
@@ -16,8 +19,6 @@ require (
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/huh v0.8.0 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
@@ -32,7 +33,6 @@ require (
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+18 -2
View File
@@ -1,9 +1,13 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
@@ -20,11 +24,23 @@ github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
@@ -61,10 +77,10 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
-772
View File
@@ -1,772 +0,0 @@
package config
import (
"fmt"
"log"
"os"
"path/filepath"
"reflect"
"time"
"github.com/BurntSushi/toml"
"github.com/leonardotrapani/hyprvoice/internal/injection"
"github.com/leonardotrapani/hyprvoice/internal/notify"
"github.com/leonardotrapani/hyprvoice/internal/recording"
"github.com/leonardotrapani/hyprvoice/internal/transcriber"
)
type Config struct {
Recording RecordingConfig `toml:"recording"`
Transcription TranscriptionConfig `toml:"transcription"`
Injection InjectionConfig `toml:"injection"`
Notifications NotificationsConfig `toml:"notifications"`
Providers map[string]ProviderConfig `toml:"providers"`
Keywords []string `toml:"keywords"`
LLM LLMConfig `toml:"llm"`
}
// ProviderConfig holds API key for a provider
type ProviderConfig struct {
APIKey string `toml:"api_key"`
}
// LLMConfig configures the LLM post-processing phase
type LLMConfig struct {
Enabled bool `toml:"enabled"`
Provider string `toml:"provider"`
Model string `toml:"model"`
PostProcessing LLMPostProcessingConfig `toml:"post_processing"`
CustomPrompt LLMCustomPromptConfig `toml:"custom_prompt"`
}
// LLMPostProcessingConfig controls text cleanup options
type LLMPostProcessingConfig struct {
RemoveStutters bool `toml:"remove_stutters"`
AddPunctuation bool `toml:"add_punctuation"`
FixGrammar bool `toml:"fix_grammar"`
RemoveFillerWords bool `toml:"remove_filler_words"`
}
// LLMCustomPromptConfig allows custom prompts
type LLMCustomPromptConfig struct {
Enabled bool `toml:"enabled"`
Prompt string `toml:"prompt"`
}
type RecordingConfig struct {
SampleRate int `toml:"sample_rate"`
Channels int `toml:"channels"`
Format string `toml:"format"`
BufferSize int `toml:"buffer_size"`
Device string `toml:"device"`
ChannelBufferSize int `toml:"channel_buffer_size"`
Timeout time.Duration `toml:"timeout"`
}
type TranscriptionConfig struct {
Provider string `toml:"provider"`
APIKey string `toml:"api_key"`
Language string `toml:"language"`
Model string `toml:"model"`
}
type InjectionConfig struct {
Backends []string `toml:"backends"`
YdotoolTimeout time.Duration `toml:"ydotool_timeout"`
WtypeTimeout time.Duration `toml:"wtype_timeout"`
ClipboardTimeout time.Duration `toml:"clipboard_timeout"`
}
type NotificationsConfig struct {
Enabled bool `toml:"enabled"`
Type string `toml:"type"` // "desktop", "log", "none"
Messages MessagesConfig `toml:"messages"`
}
type MessageConfig struct {
Title string `toml:"title"`
Body string `toml:"body"`
}
type MessagesConfig struct {
RecordingStarted MessageConfig `toml:"recording_started"`
Transcribing MessageConfig `toml:"transcribing"`
LLMProcessing MessageConfig `toml:"llm_processing"`
ConfigReloaded MessageConfig `toml:"config_reloaded"`
OperationCancelled MessageConfig `toml:"operation_cancelled"`
RecordingAborted MessageConfig `toml:"recording_aborted"`
InjectionAborted MessageConfig `toml:"injection_aborted"`
}
// Resolve merges user config with defaults from MessageDefs
func (m *MessagesConfig) Resolve() map[notify.MessageType]notify.Message {
result := make(map[notify.MessageType]notify.Message)
// Build toml tag → field index map
v := reflect.ValueOf(m).Elem()
t := v.Type()
tagToField := make(map[string]int)
for i := 0; i < t.NumField(); i++ {
tagToField[t.Field(i).Tag.Get("toml")] = i
}
for _, def := range notify.MessageDefs {
msg := notify.Message{
Title: def.DefaultTitle,
Body: def.DefaultBody,
IsError: def.IsError,
}
if idx, ok := tagToField[def.ConfigKey]; ok {
userMsg := v.Field(idx).Interface().(MessageConfig)
if userMsg.Title != "" {
msg.Title = userMsg.Title
}
if userMsg.Body != "" {
msg.Body = userMsg.Body
}
}
result[def.Type] = msg
}
return result
}
func (c *Config) ToRecordingConfig() recording.Config {
return recording.Config{
SampleRate: c.Recording.SampleRate,
Channels: c.Recording.Channels,
Format: c.Recording.Format,
BufferSize: c.Recording.BufferSize,
Device: c.Recording.Device,
ChannelBufferSize: c.Recording.ChannelBufferSize,
Timeout: c.Recording.Timeout,
}
}
func (c *Config) ToTranscriberConfig() transcriber.Config {
config := transcriber.Config{
Provider: c.Transcription.Provider,
Language: c.Transcription.Language,
Model: c.Transcription.Model,
Keywords: c.Keywords,
}
// Resolve API key: providers map -> legacy transcription.api_key -> environment variable
config.APIKey = c.resolveAPIKeyForProvider(c.Transcription.Provider)
return config
}
// resolveAPIKeyForProvider returns the API key for a provider from multiple sources
func (c *Config) resolveAPIKeyForProvider(provider string) string {
// Map transcription provider names to provider registry names
providerName := provider
envVar := ""
switch provider {
case "openai":
providerName = "openai"
envVar = "OPENAI_API_KEY"
case "groq-transcription", "groq-translation":
providerName = "groq"
envVar = "GROQ_API_KEY"
case "mistral-transcription":
providerName = "mistral"
envVar = "MISTRAL_API_KEY"
case "elevenlabs":
providerName = "elevenlabs"
envVar = "ELEVENLABS_API_KEY"
}
// 1. Check providers map
if c.Providers != nil {
if pc, ok := c.Providers[providerName]; ok && pc.APIKey != "" {
return pc.APIKey
}
}
// 2. Check legacy transcription.api_key (backward compatibility)
if c.Transcription.APIKey != "" {
return c.Transcription.APIKey
}
// 3. Check environment variable
if envVar != "" {
return os.Getenv(envVar)
}
return ""
}
// LLMAdapterConfig is the configuration passed to the LLM adapter
type LLMAdapterConfig struct {
Provider string
APIKey string
Model string
RemoveStutters bool
AddPunctuation bool
FixGrammar bool
RemoveFillerWords bool
CustomPrompt string
Keywords []string
}
// ToLLMConfig returns the LLM adapter configuration
func (c *Config) ToLLMConfig() LLMAdapterConfig {
config := LLMAdapterConfig{
Provider: c.LLM.Provider,
Model: c.LLM.Model,
RemoveStutters: c.LLM.PostProcessing.RemoveStutters,
AddPunctuation: c.LLM.PostProcessing.AddPunctuation,
FixGrammar: c.LLM.PostProcessing.FixGrammar,
RemoveFillerWords: c.LLM.PostProcessing.RemoveFillerWords,
Keywords: c.Keywords,
}
// Resolve API key for LLM provider
if c.LLM.Provider != "" {
config.APIKey = c.resolveAPIKeyForLLMProvider(c.LLM.Provider)
}
// Add custom prompt if enabled
if c.LLM.CustomPrompt.Enabled && c.LLM.CustomPrompt.Prompt != "" {
config.CustomPrompt = c.LLM.CustomPrompt.Prompt
}
return config
}
// resolveAPIKeyForLLMProvider returns the API key for an LLM provider
func (c *Config) resolveAPIKeyForLLMProvider(provider string) string {
envVar := ""
switch provider {
case "openai":
envVar = "OPENAI_API_KEY"
case "groq":
envVar = "GROQ_API_KEY"
}
// 1. Check providers map
if c.Providers != nil {
if pc, ok := c.Providers[provider]; ok && pc.APIKey != "" {
return pc.APIKey
}
}
// 2. Check environment variable
if envVar != "" {
return os.Getenv(envVar)
}
return ""
}
// IsLLMEnabled returns true if LLM post-processing is enabled and configured
func (c *Config) IsLLMEnabled() bool {
return c.LLM.Enabled && c.LLM.Provider != "" && c.LLM.Model != ""
}
func (c *Config) ToInjectionConfig() injection.Config {
return injection.Config{
Backends: c.Injection.Backends,
YdotoolTimeout: c.Injection.YdotoolTimeout,
WtypeTimeout: c.Injection.WtypeTimeout,
ClipboardTimeout: c.Injection.ClipboardTimeout,
}
}
func (c *Config) Validate() error {
// Recording
if c.Recording.SampleRate <= 0 {
return fmt.Errorf("invalid recording.sample_rate: %d", c.Recording.SampleRate)
}
if c.Recording.Channels <= 0 {
return fmt.Errorf("invalid recording.channels: %d", c.Recording.Channels)
}
if c.Recording.BufferSize <= 0 {
return fmt.Errorf("invalid recording.buffer_size: %d", c.Recording.BufferSize)
}
if c.Recording.ChannelBufferSize <= 0 {
return fmt.Errorf("invalid recording.channel_buffer_size: %d", c.Recording.ChannelBufferSize)
}
if c.Recording.Format == "" {
return fmt.Errorf("invalid recording.format: empty")
}
if c.Recording.Timeout <= 0 {
return fmt.Errorf("invalid recording.timeout: %v", c.Recording.Timeout)
}
// Transcription
if c.Transcription.Provider == "" {
return fmt.Errorf("invalid transcription.provider: empty")
}
// Validate provider-specific settings using unified API key resolution
apiKey := c.resolveAPIKeyForProvider(c.Transcription.Provider)
switch c.Transcription.Provider {
case "openai":
if apiKey == "" {
return fmt.Errorf("OpenAI API key required: not found in config (providers.openai.api_key, transcription.api_key) or environment variable (OPENAI_API_KEY)")
}
// Validate language code if provided (empty string means auto-detect)
if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) {
return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language)
}
case "groq-transcription":
if apiKey == "" {
return fmt.Errorf("Groq API key required: not found in config (providers.groq.api_key, transcription.api_key) or environment variable (GROQ_API_KEY)")
}
// Validate language code if provided (empty string means auto-detect)
if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) {
return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language)
}
// Validate Groq model
validGroqModels := map[string]bool{"whisper-large-v3": true, "whisper-large-v3-turbo": true}
if c.Transcription.Model != "" && !validGroqModels[c.Transcription.Model] {
return fmt.Errorf("invalid model for groq-transcription: %s (must be whisper-large-v3 or whisper-large-v3-turbo)", c.Transcription.Model)
}
case "groq-translation":
if apiKey == "" {
return fmt.Errorf("Groq API key required: not found in config (providers.groq.api_key, transcription.api_key) or environment variable (GROQ_API_KEY)")
}
// For translation, language field hints at source language (output is always English)
if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) {
return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language)
}
// Validate Groq translation model - only whisper-large-v3 is supported (no turbo)
if c.Transcription.Model != "" && c.Transcription.Model != "whisper-large-v3" {
return fmt.Errorf("invalid model for groq-translation: %s (must be whisper-large-v3, turbo version not supported for translation)", c.Transcription.Model)
}
case "mistral-transcription":
if apiKey == "" {
return fmt.Errorf("Mistral API key required: not found in config (providers.mistral.api_key, transcription.api_key) or environment variable (MISTRAL_API_KEY)")
}
// Validate language code if provided (empty string means auto-detect)
if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) {
return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language)
}
// Validate Mistral model
validMistralModels := map[string]bool{"voxtral-mini-latest": true, "voxtral-mini-2507": true}
if c.Transcription.Model != "" && !validMistralModels[c.Transcription.Model] {
return fmt.Errorf("invalid model for mistral-transcription: %s (must be voxtral-mini-latest or voxtral-mini-2507)", c.Transcription.Model)
}
case "elevenlabs":
if apiKey == "" {
return fmt.Errorf("ElevenLabs API key required: not found in config (providers.elevenlabs.api_key, transcription.api_key) or environment variable (ELEVENLABS_API_KEY)")
}
// Validate language code if provided (empty string means auto-detect)
if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) {
return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'pt', 'es')", c.Transcription.Language)
}
// Validate Eleven Labs model
validModels := map[string]bool{"scribe_v1": true, "scribe_v2": true}
if c.Transcription.Model != "" && !validModels[c.Transcription.Model] {
return fmt.Errorf("invalid model for elevenlabs: %s (must be scribe_v1 or scribe_v2)", c.Transcription.Model)
}
default:
return fmt.Errorf("unsupported transcription.provider: %s (must be openai, groq-transcription, groq-translation, mistral-transcription, or elevenlabs)", c.Transcription.Provider)
}
if c.Transcription.Model == "" {
return fmt.Errorf("invalid transcription.model: empty")
}
// LLM (only validate if enabled)
if c.LLM.Enabled {
if c.LLM.Provider == "" {
return fmt.Errorf("llm.provider required when llm.enabled = true")
}
if c.LLM.Model == "" {
return fmt.Errorf("llm.model required when llm.enabled = true")
}
// Validate LLM provider
validLLMProviders := map[string]bool{"openai": true, "groq": true}
if !validLLMProviders[c.LLM.Provider] {
return fmt.Errorf("invalid llm.provider: %s (must be openai or groq)", c.LLM.Provider)
}
// Check API key for LLM provider
llmAPIKey := c.resolveAPIKeyForLLMProvider(c.LLM.Provider)
if llmAPIKey == "" {
switch c.LLM.Provider {
case "openai":
return fmt.Errorf("OpenAI API key required for LLM: not found in config (providers.openai.api_key) or environment variable (OPENAI_API_KEY)")
case "groq":
return fmt.Errorf("Groq API key required for LLM: not found in config (providers.groq.api_key) or environment variable (GROQ_API_KEY)")
}
}
}
// Injection
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}
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)
}
}
if c.Injection.YdotoolTimeout <= 0 {
return fmt.Errorf("invalid injection.ydotool_timeout: %v", c.Injection.YdotoolTimeout)
}
if c.Injection.WtypeTimeout <= 0 {
return fmt.Errorf("invalid injection.wtype_timeout: %v", c.Injection.WtypeTimeout)
}
if c.Injection.ClipboardTimeout <= 0 {
return fmt.Errorf("invalid injection.clipboard_timeout: %v", c.Injection.ClipboardTimeout)
}
// Notifications
validTypes := map[string]bool{"desktop": true, "log": true, "none": true}
if !validTypes[c.Notifications.Type] {
return fmt.Errorf("invalid notifications.type: %s (must be desktop, log, or none)", c.Notifications.Type)
}
return nil
}
func isValidLanguageCode(code string) bool {
validCodes := map[string]bool{
"en": true, "es": true, "fr": true, "de": true, "it": true, "pt": true,
"ru": true, "ja": true, "ko": true, "zh": true, "ar": true, "hi": true,
"nl": true, "sv": true, "da": true, "no": true, "fi": true, "pl": true,
"tr": true, "he": true, "th": true, "vi": true, "id": true, "ms": true,
"uk": true, "cs": true, "hu": true, "ro": true, "bg": true, "hr": true,
"sk": true, "sl": true, "et": true, "lv": true, "lt": true, "mt": true,
"cy": true, "ga": true, "eu": true, "ca": true, "gl": true, "is": true,
"mk": true, "sq": true, "az": true, "be": true, "ka": true, "hy": true,
"kk": true, "ky": true, "tg": true, "uz": true, "mn": true, "ne": true,
"si": true, "km": true, "lo": true, "my": true, "fa": true, "ps": true,
"ur": true, "bn": true, "ta": true, "te": true, "ml": true, "kn": true,
"gu": true, "pa": true, "or": true, "as": true, "mr": true, "sa": true,
"sw": true, "yo": true, "ig": true, "ha": true, "zu": true, "xh": true,
"af": true, "am": true, "mg": true, "so": true, "sn": true, "rw": true,
}
return validCodes[code]
}
func GetConfigPath() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("failed to get user config directory: %w", err)
}
hyprvoiceDir := filepath.Join(configDir, "hyprvoice")
if err := os.MkdirAll(hyprvoiceDir, 0755); err != nil {
return "", fmt.Errorf("failed to create config directory: %w", err)
}
return filepath.Join(hyprvoiceDir, "config.toml"), nil
}
// legacyInjectionConfig for migration from old mode-based config
type legacyInjectionConfig struct {
Mode string `toml:"mode"`
}
// legacyTranscriptionConfig for migration from old api_key in transcription
type legacyTranscriptionConfig struct {
APIKey string `toml:"api_key"`
}
type legacyConfig struct {
Injection legacyInjectionConfig `toml:"injection"`
Transcription legacyTranscriptionConfig `toml:"transcription"`
}
func Load() (*Config, error) {
configPath, err := GetConfigPath()
if err != nil {
return nil, err
}
// If config file doesn't exist, create it with defaults
if _, err := os.Stat(configPath); os.IsNotExist(err) {
log.Printf("Config: no config file found at %s, creating with defaults", configPath)
if err := SaveDefaultConfig(); err != nil {
return nil, fmt.Errorf("failed to create default config: %w", err)
}
log.Printf("Config: default configuration created successfully")
return Load() // Recursively load the config, now file will exist
}
log.Printf("Config: loading configuration from %s", configPath)
var config Config
if _, err := toml.DecodeFile(configPath, &config); err != nil {
return nil, fmt.Errorf("failed to parse config file %s: %w", configPath, err)
}
// Parse legacy config for migrations
var legacy legacyConfig
toml.DecodeFile(configPath, &legacy)
// Migrate legacy mode-based config to backends
if len(config.Injection.Backends) == 0 {
config.migrateInjectionMode(legacy.Injection.Mode)
}
// Migrate legacy transcription.api_key to providers map
if legacy.Transcription.APIKey != "" && config.Providers == nil {
config.migrateTranscriptionAPIKey(legacy.Transcription.APIKey)
}
// Initialize providers map if nil
if config.Providers == nil {
config.Providers = make(map[string]ProviderConfig)
}
// Set LLM defaults if not configured
config.applyLLMDefaults()
log.Printf("Config: configuration loaded successfully")
return &config, nil
}
// migrateTranscriptionAPIKey migrates old transcription.api_key to providers map
func (c *Config) migrateTranscriptionAPIKey(apiKey string) {
if c.Providers == nil {
c.Providers = make(map[string]ProviderConfig)
}
// Determine which provider this key is for based on transcription.provider
providerName := c.Transcription.Provider
switch providerName {
case "openai":
c.Providers["openai"] = ProviderConfig{APIKey: apiKey}
case "groq-transcription", "groq-translation":
c.Providers["groq"] = ProviderConfig{APIKey: apiKey}
case "mistral-transcription":
c.Providers["mistral"] = ProviderConfig{APIKey: apiKey}
case "elevenlabs":
c.Providers["elevenlabs"] = ProviderConfig{APIKey: apiKey}
default:
// Unknown provider, try to guess based on key prefix
if len(apiKey) > 3 && apiKey[:3] == "sk-" {
c.Providers["openai"] = ProviderConfig{APIKey: apiKey}
} else if len(apiKey) > 4 && apiKey[:4] == "gsk_" {
c.Providers["groq"] = ProviderConfig{APIKey: apiKey}
}
}
log.Printf("Config: migrated transcription.api_key to providers map. Run 'hyprvoice configure' to update config format.")
}
// applyLLMDefaults sets default values for LLM config
func (c *Config) applyLLMDefaults() {
// Default post-processing options to true if LLM is enabled and not explicitly set
// We detect "not set" by checking if all booleans are false (zero value)
// Since the default behavior should be all true, we only apply if everything is false
pp := &c.LLM.PostProcessing
if !pp.RemoveStutters && !pp.AddPunctuation && !pp.FixGrammar && !pp.RemoveFillerWords {
// Nothing was set, apply defaults
pp.RemoveStutters = true
pp.AddPunctuation = true
pp.FixGrammar = true
pp.RemoveFillerWords = true
}
}
// migrateInjectionMode converts old mode field to new backends array
func (c *Config) migrateInjectionMode(mode string) {
switch mode {
case "clipboard":
c.Injection.Backends = []string{"clipboard"}
log.Printf("Config: migrated injection.mode='clipboard' to backends=['clipboard']")
case "type":
c.Injection.Backends = []string{"wtype"}
log.Printf("Config: migrated injection.mode='type' to backends=['wtype']")
case "fallback":
c.Injection.Backends = []string{"wtype", "clipboard"}
log.Printf("Config: migrated injection.mode='fallback' to backends=['wtype', 'clipboard']")
default:
// Default for new installs or unknown modes
c.Injection.Backends = []string{"ydotool", "wtype", "clipboard"}
if mode != "" {
log.Printf("Config: unknown injection.mode='%s', using default backends", mode)
}
}
// Set default ydotool timeout if not set
if c.Injection.YdotoolTimeout == 0 {
c.Injection.YdotoolTimeout = 5 * time.Second
}
log.Printf("Config: legacy 'mode' config detected - please update your config.toml to use 'backends' instead")
}
func SaveDefaultConfig() error {
configPath, err := GetConfigPath()
if err != nil {
return err
}
file, err := os.Create(configPath)
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
}
defer file.Close()
configContent := `# Hyprvoice Configuration
# This file is automatically generated with defaults.
# Edit values as needed - changes are applied immediately without daemon restart.
#
# MIGRATION NOTE: If upgrading from an older version, your transcription.api_key
# will be automatically migrated to the new [providers.X] format. Run 'hyprvoice configure'
# to update your config file structure.
# Keywords help both transcription and LLM understand domain-specific terms
# Add names, technical terms, or brand names that might be misheard
keywords = []
# ─────────────────────────────────────────────────────────────────────────────
# Provider API Keys
# Configure API keys for each provider you want to use.
# Keys can also be set via environment variables: OPENAI_API_KEY, GROQ_API_KEY, etc.
# ─────────────────────────────────────────────────────────────────────────────
[providers.openai]
api_key = "" # OpenAI API key (or set OPENAI_API_KEY env var)
[providers.groq]
api_key = "" # Groq API key (or set GROQ_API_KEY env var)
# Uncomment to configure additional providers:
# [providers.mistral]
# api_key = "" # Mistral API key (or set MISTRAL_API_KEY env var)
# [providers.elevenlabs]
# api_key = "" # ElevenLabs API key (or set ELEVENLABS_API_KEY env var)
# ─────────────────────────────────────────────────────────────────────────────
# Audio Recording
# ─────────────────────────────────────────────────────────────────────────────
[recording]
sample_rate = 16000 # Audio sample rate in Hz (16000 recommended for speech)
channels = 1 # Number of audio channels (1 = mono, 2 = stereo)
format = "s16" # Audio format (s16 = 16-bit signed integers)
buffer_size = 8192 # Internal buffer size in bytes (larger = less CPU, more latency)
device = "" # PipeWire audio device (empty = use default microphone)
channel_buffer_size = 30 # Audio frame buffer size (frames to buffer)
timeout = "5m" # Maximum recording duration (e.g., "30s", "2m", "5m")
# ─────────────────────────────────────────────────────────────────────────────
# Speech Transcription
# Converts audio to text using speech-to-text APIs
# ─────────────────────────────────────────────────────────────────────────────
[transcription]
provider = "openai" # "openai", "groq-transcription", "groq-translation", "mistral-transcription", "elevenlabs"
language = "" # Language code (empty = auto-detect, "en", "it", "es", "fr", etc.)
model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1"
# ─────────────────────────────────────────────────────────────────────────────
# LLM Post-Processing (Recommended)
# Cleans up transcribed text: removes stutters, adds punctuation, fixes grammar
# ─────────────────────────────────────────────────────────────────────────────
[llm]
enabled = true # Enable LLM post-processing (highly recommended)
provider = "openai" # "openai" or "groq" (must have API key configured above)
model = "gpt-4o-mini" # OpenAI: "gpt-4o-mini", Groq: "llama-3.3-70b-versatile"
[llm.post_processing]
remove_stutters = true # Remove "um", "uh", repeated words
add_punctuation = true # Add proper punctuation
fix_grammar = true # Fix grammatical errors
remove_filler_words = true # Remove "like", "you know", "basically"
[llm.custom_prompt]
enabled = false # Enable custom instructions for LLM
prompt = "" # Additional instructions (e.g., "Format as bullet points")
# ─────────────────────────────────────────────────────────────────────────────
# Text Injection
# How transcribed text is inserted into applications
# ─────────────────────────────────────────────────────────────────────────────
[injection]
backends = ["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
# ─────────────────────────────────────────────────────────────────────────────
# Desktop Notifications
# ─────────────────────────────────────────────────────────────────────────────
[notifications]
enabled = true # Enable desktop notifications
type = "desktop" # "desktop", "log", or "none"
# Custom notification messages (optional - defaults shown below)
# Uncomment and modify to customize notification text
# [notifications.messages]
# [notifications.messages.recording_started]
# title = "Hyprvoice"
# body = "Recording Started"
# [notifications.messages.transcribing]
# title = "Hyprvoice"
# body = "Recording Ended... Transcribing"
# [notifications.messages.llm_processing]
# title = "Hyprvoice"
# body = "Processing..."
# [notifications.messages.config_reloaded]
# title = "Hyprvoice"
# body = "Config Reloaded"
# [notifications.messages.operation_cancelled]
# title = "Hyprvoice"
# body = "Operation Cancelled"
# [notifications.messages.recording_aborted]
# body = "Recording Aborted"
# [notifications.messages.injection_aborted]
# body = "Injection Aborted"
#
# Emoji-only example (for minimal pill-style notifications):
# [notifications.messages.recording_started]
# title = ""
# body = "..."
# ─────────────────────────────────────────────────────────────────────────────
# Reference: Provider Details
# ─────────────────────────────────────────────────────────────────────────────
#
# Transcription providers:
# - "openai": OpenAI Whisper API (cloud-based, excellent accuracy)
# - "groq-transcription": Groq Whisper API (very fast, models: whisper-large-v3, whisper-large-v3-turbo)
# - "groq-translation": Groq translation to English (always outputs English text, model: whisper-large-v3)
# - "mistral-transcription": Mistral Voxtral API (excellent for European languages, model: voxtral-mini-latest)
# - "elevenlabs": ElevenLabs Scribe API (99 languages, models: scribe_v1, scribe_v2)
#
# 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)
#
# Injection backends:
# - "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).
#
# Language codes: "" (auto-detect), "en", "it", "es", "fr", "de", "pt", etc.
`
if _, err := file.WriteString(configContent); err != nil {
return fmt.Errorf("failed to write config content: %w", err)
}
return nil
}
+130
View File
@@ -0,0 +1,130 @@
package config
import (
"os"
"github.com/leonardotrapani/hyprvoice/internal/injection"
"github.com/leonardotrapani/hyprvoice/internal/recording"
"github.com/leonardotrapani/hyprvoice/internal/transcriber"
)
func (c *Config) ToRecordingConfig() recording.Config {
return recording.Config{
SampleRate: c.Recording.SampleRate,
Channels: c.Recording.Channels,
Format: c.Recording.Format,
BufferSize: c.Recording.BufferSize,
Device: c.Recording.Device,
ChannelBufferSize: c.Recording.ChannelBufferSize,
Timeout: c.Recording.Timeout,
}
}
func (c *Config) ToTranscriberConfig() transcriber.Config {
config := transcriber.Config{
Provider: c.Transcription.Provider,
Language: c.Transcription.Language,
Model: c.Transcription.Model,
Keywords: c.Keywords,
}
config.APIKey = c.resolveAPIKeyForProvider(c.Transcription.Provider)
return config
}
// resolveAPIKeyForProvider returns the API key for a provider from multiple sources
func (c *Config) resolveAPIKeyForProvider(provider string) string {
providerName := provider
envVar := ""
switch provider {
case "openai":
providerName = "openai"
envVar = "OPENAI_API_KEY"
case "groq-transcription", "groq-translation":
providerName = "groq"
envVar = "GROQ_API_KEY"
case "mistral-transcription":
providerName = "mistral"
envVar = "MISTRAL_API_KEY"
case "elevenlabs":
providerName = "elevenlabs"
envVar = "ELEVENLABS_API_KEY"
}
if c.Providers != nil {
if pc, ok := c.Providers[providerName]; ok && pc.APIKey != "" {
return pc.APIKey
}
}
if c.Transcription.APIKey != "" {
return c.Transcription.APIKey
}
if envVar != "" {
return os.Getenv(envVar)
}
return ""
}
// ToLLMConfig returns the LLM adapter configuration
func (c *Config) ToLLMConfig() LLMAdapterConfig {
config := LLMAdapterConfig{
Provider: c.LLM.Provider,
Model: c.LLM.Model,
RemoveStutters: c.LLM.PostProcessing.RemoveStutters,
AddPunctuation: c.LLM.PostProcessing.AddPunctuation,
FixGrammar: c.LLM.PostProcessing.FixGrammar,
RemoveFillerWords: c.LLM.PostProcessing.RemoveFillerWords,
Keywords: c.Keywords,
}
if c.LLM.Provider != "" {
config.APIKey = c.resolveAPIKeyForLLMProvider(c.LLM.Provider)
}
if c.LLM.CustomPrompt.Enabled && c.LLM.CustomPrompt.Prompt != "" {
config.CustomPrompt = c.LLM.CustomPrompt.Prompt
}
return config
}
// resolveAPIKeyForLLMProvider returns the API key for an LLM provider
func (c *Config) resolveAPIKeyForLLMProvider(provider string) string {
envVar := ""
switch provider {
case "openai":
envVar = "OPENAI_API_KEY"
case "groq":
envVar = "GROQ_API_KEY"
}
if c.Providers != nil {
if pc, ok := c.Providers[provider]; ok && pc.APIKey != "" {
return pc.APIKey
}
}
if envVar != "" {
return os.Getenv(envVar)
}
return ""
}
// IsLLMEnabled returns true if LLM post-processing is enabled and configured
func (c *Config) IsLLMEnabled() bool {
return c.LLM.Enabled && c.LLM.Provider != "" && c.LLM.Model != ""
}
func (c *Config) ToInjectionConfig() injection.Config {
return injection.Config{
Backends: c.Injection.Backends,
YdotoolTimeout: c.Injection.YdotoolTimeout,
WtypeTimeout: c.Injection.WtypeTimeout,
ClipboardTimeout: c.Injection.ClipboardTimeout,
}
}
+146
View File
@@ -0,0 +1,146 @@
package config
import (
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/BurntSushi/toml"
)
func GetConfigPath() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("failed to get user config directory: %w", err)
}
hyprvoiceDir := filepath.Join(configDir, "hyprvoice")
if err := os.MkdirAll(hyprvoiceDir, 0755); err != nil {
return "", fmt.Errorf("failed to create config directory: %w", err)
}
return filepath.Join(hyprvoiceDir, "config.toml"), nil
}
// legacyInjectionConfig for migration from old mode-based config
type legacyInjectionConfig struct {
Mode string `toml:"mode"`
}
// legacyTranscriptionConfig for migration from old api_key in transcription
type legacyTranscriptionConfig struct {
APIKey string `toml:"api_key"`
}
type legacyConfig struct {
Injection legacyInjectionConfig `toml:"injection"`
Transcription legacyTranscriptionConfig `toml:"transcription"`
}
func Load() (*Config, error) {
configPath, err := GetConfigPath()
if err != nil {
return nil, err
}
if _, err := os.Stat(configPath); os.IsNotExist(err) {
log.Printf("Config: no config file found at %s, creating with defaults", configPath)
if err := SaveDefaultConfig(); err != nil {
return nil, fmt.Errorf("failed to create default config: %w", err)
}
log.Printf("Config: default configuration created successfully")
return Load()
}
log.Printf("Config: loading configuration from %s", configPath)
var config Config
if _, err := toml.DecodeFile(configPath, &config); err != nil {
return nil, fmt.Errorf("failed to parse config file %s: %w", configPath, err)
}
var legacy legacyConfig
toml.DecodeFile(configPath, &legacy)
if len(config.Injection.Backends) == 0 {
config.migrateInjectionMode(legacy.Injection.Mode)
}
if legacy.Transcription.APIKey != "" && config.Providers == nil {
config.migrateTranscriptionAPIKey(legacy.Transcription.APIKey)
}
if config.Providers == nil {
config.Providers = make(map[string]ProviderConfig)
}
config.applyLLMDefaults()
log.Printf("Config: configuration loaded successfully")
return &config, nil
}
// migrateTranscriptionAPIKey migrates old transcription.api_key to providers map
func (c *Config) migrateTranscriptionAPIKey(apiKey string) {
if c.Providers == nil {
c.Providers = make(map[string]ProviderConfig)
}
providerName := c.Transcription.Provider
switch providerName {
case "openai":
c.Providers["openai"] = ProviderConfig{APIKey: apiKey}
case "groq-transcription", "groq-translation":
c.Providers["groq"] = ProviderConfig{APIKey: apiKey}
case "mistral-transcription":
c.Providers["mistral"] = ProviderConfig{APIKey: apiKey}
case "elevenlabs":
c.Providers["elevenlabs"] = ProviderConfig{APIKey: apiKey}
default:
if len(apiKey) > 3 && apiKey[:3] == "sk-" {
c.Providers["openai"] = ProviderConfig{APIKey: apiKey}
} else if len(apiKey) > 4 && apiKey[:4] == "gsk_" {
c.Providers["groq"] = ProviderConfig{APIKey: apiKey}
}
}
log.Printf("Config: migrated transcription.api_key to providers map. Run 'hyprvoice configure' to update config format.")
}
// applyLLMDefaults sets default values for LLM config
func (c *Config) applyLLMDefaults() {
pp := &c.LLM.PostProcessing
if !pp.RemoveStutters && !pp.AddPunctuation && !pp.FixGrammar && !pp.RemoveFillerWords {
pp.RemoveStutters = true
pp.AddPunctuation = true
pp.FixGrammar = true
pp.RemoveFillerWords = true
}
}
// migrateInjectionMode converts old mode field to new backends array
func (c *Config) migrateInjectionMode(mode string) {
switch mode {
case "clipboard":
c.Injection.Backends = []string{"clipboard"}
log.Printf("Config: migrated injection.mode='clipboard' to backends=['clipboard']")
case "type":
c.Injection.Backends = []string{"wtype"}
log.Printf("Config: migrated injection.mode='type' to backends=['wtype']")
case "fallback":
c.Injection.Backends = []string{"wtype", "clipboard"}
log.Printf("Config: migrated injection.mode='fallback' to backends=['wtype', 'clipboard']")
default:
c.Injection.Backends = []string{"ydotool", "wtype", "clipboard"}
if mode != "" {
log.Printf("Config: unknown injection.mode='%s', using default backends", mode)
}
}
if c.Injection.YdotoolTimeout == 0 {
c.Injection.YdotoolTimeout = 5 * time.Second
}
log.Printf("Config: legacy 'mode' config detected - please update your config.toml to use 'backends' instead")
}
+168
View File
@@ -0,0 +1,168 @@
package config
import (
"fmt"
"os"
)
func SaveDefaultConfig() error {
configPath, err := GetConfigPath()
if err != nil {
return err
}
file, err := os.Create(configPath)
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
}
defer file.Close()
configContent := `# Hyprvoice Configuration
# This file is automatically generated with defaults.
# Edit values as needed - changes are applied immediately without daemon restart.
#
# MIGRATION NOTE: If upgrading from an older version, your transcription.api_key
# will be automatically migrated to the new [providers.X] format. Run 'hyprvoice configure'
# to update your config file structure.
# Keywords help both transcription and LLM understand domain-specific terms
# Add names, technical terms, or brand names that might be misheard
keywords = []
# ─────────────────────────────────────────────────────────────────────────────
# Provider API Keys
# Configure API keys for each provider you want to use.
# Keys can also be set via environment variables: OPENAI_API_KEY, GROQ_API_KEY, etc.
# ─────────────────────────────────────────────────────────────────────────────
[providers.openai]
api_key = "" # OpenAI API key (or set OPENAI_API_KEY env var)
[providers.groq]
api_key = "" # Groq API key (or set GROQ_API_KEY env var)
# Uncomment to configure additional providers:
# [providers.mistral]
# api_key = "" # Mistral API key (or set MISTRAL_API_KEY env var)
# [providers.elevenlabs]
# api_key = "" # ElevenLabs API key (or set ELEVENLABS_API_KEY env var)
# ─────────────────────────────────────────────────────────────────────────────
# Audio Recording
# ─────────────────────────────────────────────────────────────────────────────
[recording]
sample_rate = 16000 # Audio sample rate in Hz (16000 recommended for speech)
channels = 1 # Number of audio channels (1 = mono, 2 = stereo)
format = "s16" # Audio format (s16 = 16-bit signed integers)
buffer_size = 8192 # Internal buffer size in bytes (larger = less CPU, more latency)
device = "" # PipeWire audio device (empty = use default microphone)
channel_buffer_size = 30 # Audio frame buffer size (frames to buffer)
timeout = "5m" # Maximum recording duration (e.g., "30s", "2m", "5m")
# ─────────────────────────────────────────────────────────────────────────────
# Speech Transcription
# Converts audio to text using speech-to-text APIs
# ─────────────────────────────────────────────────────────────────────────────
[transcription]
provider = "openai" # "openai", "groq-transcription", "groq-translation", "mistral-transcription", "elevenlabs"
language = "" # Language code (empty = auto-detect, "en", "it", "es", "fr", etc.)
model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1"
# ─────────────────────────────────────────────────────────────────────────────
# LLM Post-Processing (Recommended)
# Cleans up transcribed text: removes stutters, adds punctuation, fixes grammar
# ─────────────────────────────────────────────────────────────────────────────
[llm]
enabled = true # Enable LLM post-processing (highly recommended)
provider = "openai" # "openai" or "groq" (must have API key configured above)
model = "gpt-4o-mini" # OpenAI: "gpt-4o-mini", Groq: "llama-3.3-70b-versatile"
[llm.post_processing]
remove_stutters = true # Remove "um", "uh", repeated words
add_punctuation = true # Add proper punctuation
fix_grammar = true # Fix grammatical errors
remove_filler_words = true # Remove "like", "you know", "basically"
[llm.custom_prompt]
enabled = false # Enable custom instructions for LLM
prompt = "" # Additional instructions (e.g., "Format as bullet points")
# ─────────────────────────────────────────────────────────────────────────────
# Text Injection
# How transcribed text is inserted into applications
# ─────────────────────────────────────────────────────────────────────────────
[injection]
backends = ["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
# ─────────────────────────────────────────────────────────────────────────────
# Desktop Notifications
# ─────────────────────────────────────────────────────────────────────────────
[notifications]
enabled = true # Enable desktop notifications
type = "desktop" # "desktop", "log", or "none"
# Custom notification messages (optional - defaults shown below)
# Uncomment and modify to customize notification text
# [notifications.messages]
# [notifications.messages.recording_started]
# title = "Hyprvoice"
# body = "Recording Started"
# [notifications.messages.transcribing]
# title = "Hyprvoice"
# body = "Recording Ended... Transcribing"
# [notifications.messages.llm_processing]
# title = "Hyprvoice"
# body = "Processing..."
# [notifications.messages.config_reloaded]
# title = "Hyprvoice"
# body = "Config Reloaded"
# [notifications.messages.operation_cancelled]
# title = "Hyprvoice"
# body = "Operation Cancelled"
# [notifications.messages.recording_aborted]
# body = "Recording Aborted"
# [notifications.messages.injection_aborted]
# body = "Injection Aborted"
#
# Emoji-only example (for minimal pill-style notifications):
# [notifications.messages.recording_started]
# title = ""
# body = "..."
# ─────────────────────────────────────────────────────────────────────────────
# Reference: Provider Details
# ─────────────────────────────────────────────────────────────────────────────
#
# Transcription providers:
# - "openai": OpenAI Whisper API (cloud-based, excellent accuracy)
# - "groq-transcription": Groq Whisper API (very fast, models: whisper-large-v3, whisper-large-v3-turbo)
# - "groq-translation": Groq translation to English (always outputs English text, model: whisper-large-v3)
# - "mistral-transcription": Mistral Voxtral API (excellent for European languages, model: voxtral-mini-latest)
# - "elevenlabs": ElevenLabs Scribe API (99 languages, models: scribe_v1, scribe_v2)
#
# 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)
#
# Injection backends:
# - "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).
#
# Language codes: "" (auto-detect), "en", "it", "es", "fr", "de", "pt", etc.
`
if _, err := file.WriteString(configContent); err != nil {
return fmt.Errorf("failed to write config content: %w", err)
}
return nil
}
+135
View File
@@ -0,0 +1,135 @@
package config
import (
"reflect"
"time"
"github.com/leonardotrapani/hyprvoice/internal/notify"
)
type Config struct {
Recording RecordingConfig `toml:"recording"`
Transcription TranscriptionConfig `toml:"transcription"`
Injection InjectionConfig `toml:"injection"`
Notifications NotificationsConfig `toml:"notifications"`
Providers map[string]ProviderConfig `toml:"providers"`
Keywords []string `toml:"keywords"`
LLM LLMConfig `toml:"llm"`
}
// ProviderConfig holds API key for a provider
type ProviderConfig struct {
APIKey string `toml:"api_key"`
}
// LLMConfig configures the LLM post-processing phase
type LLMConfig struct {
Enabled bool `toml:"enabled"`
Provider string `toml:"provider"`
Model string `toml:"model"`
PostProcessing LLMPostProcessingConfig `toml:"post_processing"`
CustomPrompt LLMCustomPromptConfig `toml:"custom_prompt"`
}
// LLMPostProcessingConfig controls text cleanup options
type LLMPostProcessingConfig struct {
RemoveStutters bool `toml:"remove_stutters"`
AddPunctuation bool `toml:"add_punctuation"`
FixGrammar bool `toml:"fix_grammar"`
RemoveFillerWords bool `toml:"remove_filler_words"`
}
// LLMCustomPromptConfig allows custom prompts
type LLMCustomPromptConfig struct {
Enabled bool `toml:"enabled"`
Prompt string `toml:"prompt"`
}
type RecordingConfig struct {
SampleRate int `toml:"sample_rate"`
Channels int `toml:"channels"`
Format string `toml:"format"`
BufferSize int `toml:"buffer_size"`
Device string `toml:"device"`
ChannelBufferSize int `toml:"channel_buffer_size"`
Timeout time.Duration `toml:"timeout"`
}
type TranscriptionConfig struct {
Provider string `toml:"provider"`
APIKey string `toml:"api_key"`
Language string `toml:"language"`
Model string `toml:"model"`
}
type InjectionConfig struct {
Backends []string `toml:"backends"`
YdotoolTimeout time.Duration `toml:"ydotool_timeout"`
WtypeTimeout time.Duration `toml:"wtype_timeout"`
ClipboardTimeout time.Duration `toml:"clipboard_timeout"`
}
type NotificationsConfig struct {
Enabled bool `toml:"enabled"`
Type string `toml:"type"` // "desktop", "log", "none"
Messages MessagesConfig `toml:"messages"`
}
type MessageConfig struct {
Title string `toml:"title"`
Body string `toml:"body"`
}
type MessagesConfig struct {
RecordingStarted MessageConfig `toml:"recording_started"`
Transcribing MessageConfig `toml:"transcribing"`
LLMProcessing MessageConfig `toml:"llm_processing"`
ConfigReloaded MessageConfig `toml:"config_reloaded"`
OperationCancelled MessageConfig `toml:"operation_cancelled"`
RecordingAborted MessageConfig `toml:"recording_aborted"`
InjectionAborted MessageConfig `toml:"injection_aborted"`
}
// Resolve merges user config with defaults from MessageDefs
func (m *MessagesConfig) Resolve() map[notify.MessageType]notify.Message {
result := make(map[notify.MessageType]notify.Message)
v := reflect.ValueOf(m).Elem()
t := v.Type()
tagToField := make(map[string]int)
for i := 0; i < t.NumField(); i++ {
tagToField[t.Field(i).Tag.Get("toml")] = i
}
for _, def := range notify.MessageDefs {
msg := notify.Message{
Title: def.DefaultTitle,
Body: def.DefaultBody,
IsError: def.IsError,
}
if idx, ok := tagToField[def.ConfigKey]; ok {
userMsg := v.Field(idx).Interface().(MessageConfig)
if userMsg.Title != "" {
msg.Title = userMsg.Title
}
if userMsg.Body != "" {
msg.Body = userMsg.Body
}
}
result[def.Type] = msg
}
return result
}
// LLMAdapterConfig is the configuration passed to the LLM adapter
type LLMAdapterConfig struct {
Provider string
APIKey string
Model string
RemoveStutters bool
AddPunctuation bool
FixGrammar bool
RemoveFillerWords bool
CustomPrompt string
Keywords []string
}
+173
View File
@@ -0,0 +1,173 @@
package config
import "fmt"
func (c *Config) Validate() error {
if c.Recording.SampleRate <= 0 {
return fmt.Errorf("invalid recording.sample_rate: %d", c.Recording.SampleRate)
}
if c.Recording.Channels <= 0 {
return fmt.Errorf("invalid recording.channels: %d", c.Recording.Channels)
}
if c.Recording.BufferSize <= 0 {
return fmt.Errorf("invalid recording.buffer_size: %d", c.Recording.BufferSize)
}
if c.Recording.ChannelBufferSize <= 0 {
return fmt.Errorf("invalid recording.channel_buffer_size: %d", c.Recording.ChannelBufferSize)
}
if c.Recording.Format == "" {
return fmt.Errorf("invalid recording.format: empty")
}
if c.Recording.Timeout <= 0 {
return fmt.Errorf("invalid recording.timeout: %v", c.Recording.Timeout)
}
if c.Transcription.Provider == "" {
return fmt.Errorf("invalid transcription.provider: empty")
}
apiKey := c.resolveAPIKeyForProvider(c.Transcription.Provider)
switch c.Transcription.Provider {
case "openai":
if apiKey == "" {
return fmt.Errorf("OpenAI API key required: not found in config (providers.openai.api_key, transcription.api_key) or environment variable (OPENAI_API_KEY)")
}
if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) {
return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language)
}
case "groq-transcription":
if apiKey == "" {
return fmt.Errorf("Groq API key required: not found in config (providers.groq.api_key, transcription.api_key) or environment variable (GROQ_API_KEY)")
}
if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) {
return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language)
}
validGroqModels := map[string]bool{"whisper-large-v3": true, "whisper-large-v3-turbo": true}
if c.Transcription.Model != "" && !validGroqModels[c.Transcription.Model] {
return fmt.Errorf("invalid model for groq-transcription: %s (must be whisper-large-v3 or whisper-large-v3-turbo)", c.Transcription.Model)
}
case "groq-translation":
if apiKey == "" {
return fmt.Errorf("Groq API key required: not found in config (providers.groq.api_key, transcription.api_key) or environment variable (GROQ_API_KEY)")
}
if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) {
return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language)
}
if c.Transcription.Model != "" && c.Transcription.Model != "whisper-large-v3" {
return fmt.Errorf("invalid model for groq-translation: %s (must be whisper-large-v3, turbo version not supported for translation)", c.Transcription.Model)
}
case "mistral-transcription":
if apiKey == "" {
return fmt.Errorf("Mistral API key required: not found in config (providers.mistral.api_key, transcription.api_key) or environment variable (MISTRAL_API_KEY)")
}
if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) {
return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language)
}
validMistralModels := map[string]bool{"voxtral-mini-latest": true, "voxtral-mini-2507": true}
if c.Transcription.Model != "" && !validMistralModels[c.Transcription.Model] {
return fmt.Errorf("invalid model for mistral-transcription: %s (must be voxtral-mini-latest or voxtral-mini-2507)", c.Transcription.Model)
}
case "elevenlabs":
if apiKey == "" {
return fmt.Errorf("ElevenLabs API key required: not found in config (providers.elevenlabs.api_key, transcription.api_key) or environment variable (ELEVENLABS_API_KEY)")
}
if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) {
return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'pt', 'es')", c.Transcription.Language)
}
validModels := map[string]bool{"scribe_v1": true, "scribe_v2": true}
if c.Transcription.Model != "" && !validModels[c.Transcription.Model] {
return fmt.Errorf("invalid model for elevenlabs: %s (must be scribe_v1 or scribe_v2)", c.Transcription.Model)
}
default:
return fmt.Errorf("unsupported transcription.provider: %s (must be openai, groq-transcription, groq-translation, mistral-transcription, or elevenlabs)", c.Transcription.Provider)
}
if c.Transcription.Model == "" {
return fmt.Errorf("invalid transcription.model: empty")
}
if c.LLM.Enabled {
if c.LLM.Provider == "" {
return fmt.Errorf("llm.provider required when llm.enabled = true")
}
if c.LLM.Model == "" {
return fmt.Errorf("llm.model required when llm.enabled = true")
}
validLLMProviders := map[string]bool{"openai": true, "groq": true}
if !validLLMProviders[c.LLM.Provider] {
return fmt.Errorf("invalid llm.provider: %s (must be openai or groq)", c.LLM.Provider)
}
llmAPIKey := c.resolveAPIKeyForLLMProvider(c.LLM.Provider)
if llmAPIKey == "" {
switch c.LLM.Provider {
case "openai":
return fmt.Errorf("OpenAI API key required for LLM: not found in config (providers.openai.api_key) or environment variable (OPENAI_API_KEY)")
case "groq":
return fmt.Errorf("Groq API key required for LLM: not found in config (providers.groq.api_key) or environment variable (GROQ_API_KEY)")
}
}
}
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}
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)
}
}
if c.Injection.YdotoolTimeout <= 0 {
return fmt.Errorf("invalid injection.ydotool_timeout: %v", c.Injection.YdotoolTimeout)
}
if c.Injection.WtypeTimeout <= 0 {
return fmt.Errorf("invalid injection.wtype_timeout: %v", c.Injection.WtypeTimeout)
}
if c.Injection.ClipboardTimeout <= 0 {
return fmt.Errorf("invalid injection.clipboard_timeout: %v", c.Injection.ClipboardTimeout)
}
validTypes := map[string]bool{"desktop": true, "log": true, "none": true}
if !validTypes[c.Notifications.Type] {
return fmt.Errorf("invalid notifications.type: %s (must be desktop, log, or none)", c.Notifications.Type)
}
return nil
}
func isValidLanguageCode(code string) bool {
validCodes := map[string]bool{
"en": true, "es": true, "fr": true, "de": true, "it": true, "pt": true,
"ru": true, "ja": true, "ko": true, "zh": true, "ar": true, "hi": true,
"nl": true, "sv": true, "da": true, "no": true, "fi": true, "pl": true,
"tr": true, "he": true, "th": true, "vi": true, "id": true, "ms": true,
"uk": true, "cs": true, "hu": true, "ro": true, "bg": true, "hr": true,
"sk": true, "sl": true, "et": true, "lv": true, "lt": true, "mt": true,
"cy": true, "ga": true, "eu": true, "ca": true, "gl": true, "is": true,
"mk": true, "sq": true, "az": true, "be": true, "ka": true, "hy": true,
"kk": true, "ky": true, "tg": true, "uz": true, "mn": true, "ne": true,
"si": true, "km": true, "lo": true, "my": true, "fa": true, "ps": true,
"ur": true, "bn": true, "ta": true, "te": true, "ml": true, "kn": true,
"gu": true, "pa": true, "or": true, "as": true, "mr": true, "sa": true,
"sw": true, "yo": true, "ig": true, "ha": true, "zu": true, "xh": true,
"af": true, "am": true, "mg": true, "so": true, "sn": true, "rw": true,
}
return validCodes[code]
}
+32 -1175
View File
File diff suppressed because it is too large Load Diff
+225
View File
@@ -0,0 +1,225 @@
package tui
import (
"fmt"
"strconv"
"time"
"github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/config"
)
// AdvancedSection represents a section in the advanced settings menu
type AdvancedSection string
const (
AdvancedRecording AdvancedSection = "recording"
AdvancedInjectionTimeout AdvancedSection = "injection_timeout"
AdvancedBack AdvancedSection = "back"
)
// editAdvanced handles the advanced settings submenu
func editAdvanced(cfg *config.Config) error {
for {
options := []huh.Option[AdvancedSection]{
huh.NewOption(formatAdvancedRecordingLabel(cfg), AdvancedRecording),
huh.NewOption(formatAdvancedInjectionTimeoutLabel(cfg), AdvancedInjectionTimeout),
huh.NewOption("Back to Main Menu", AdvancedBack),
}
var selected AdvancedSection
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[AdvancedSection]().
Title("Advanced Settings").
Description("Configure low-level options").
Options(options...).
Value(&selected),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return err
}
switch selected {
case AdvancedBack:
return nil
case AdvancedRecording:
if err := editRecording(cfg); err != nil {
continue
}
case AdvancedInjectionTimeout:
if err := editInjectionTimeouts(cfg); err != nil {
continue
}
}
}
}
func formatAdvancedRecordingLabel(cfg *config.Config) string {
return fmt.Sprintf("Recording Settings (rate=%d, timeout=%s)", cfg.Recording.SampleRate, cfg.Recording.Timeout)
}
func formatAdvancedInjectionTimeoutLabel(cfg *config.Config) string {
return fmt.Sprintf("Injection Timeouts (ydotool=%s, wtype=%s, clipboard=%s)",
cfg.Injection.YdotoolTimeout, cfg.Injection.WtypeTimeout, cfg.Injection.ClipboardTimeout)
}
// editRecording handles the recording settings
func editRecording(cfg *config.Config) error {
sampleRate := strconv.Itoa(cfg.Recording.SampleRate)
channels := strconv.Itoa(cfg.Recording.Channels)
format := cfg.Recording.Format
bufferSize := strconv.Itoa(cfg.Recording.BufferSize)
device := cfg.Recording.Device
channelBufferSize := strconv.Itoa(cfg.Recording.ChannelBufferSize)
timeout := cfg.Recording.Timeout.String()
channelOptions := []huh.Option[string]{
huh.NewOption("1 (Mono) - Recommended", "1"),
huh.NewOption("2 (Stereo)", "2"),
}
formatOptions := []huh.Option[string]{
huh.NewOption("s16 (16-bit signed) - Recommended", "s16"),
huh.NewOption("f32 (32-bit float)", "f32"),
}
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Sample Rate (Hz)").
Description("Audio sample rate. 16000 is optimal for speech recognition.").
Placeholder("16000").
Value(&sampleRate).
Validate(func(s string) error {
if _, err := strconv.Atoi(s); err != nil {
return fmt.Errorf("must be a number")
}
return nil
}),
huh.NewSelect[string]().
Title("Channels").
Description("Number of audio channels").
Options(channelOptions...).
Value(&channels),
huh.NewSelect[string]().
Title("Audio Format").
Description("Sample format").
Options(formatOptions...).
Value(&format),
),
huh.NewGroup(
huh.NewInput().
Title("Buffer Size (bytes)").
Description("Internal buffer size. Larger = less CPU, more latency.").
Placeholder("8192").
Value(&bufferSize).
Validate(func(s string) error {
if _, err := strconv.Atoi(s); err != nil {
return fmt.Errorf("must be a number")
}
return nil
}),
huh.NewInput().
Title("Channel Buffer Size").
Description("Number of audio frames to buffer.").
Placeholder("30").
Value(&channelBufferSize).
Validate(func(s string) error {
if _, err := strconv.Atoi(s); err != nil {
return fmt.Errorf("must be a number")
}
return nil
}),
),
huh.NewGroup(
huh.NewInput().
Title("Device").
Description("PipeWire device name. Empty = default microphone.").
Placeholder("(default)").
Value(&device),
huh.NewInput().
Title("Recording Timeout").
Description("Max recording duration (e.g., '30s', '2m', '5m'). Prevents runaway recordings.").
Placeholder("5m").
Value(&timeout).
Validate(func(s string) error {
if _, err := time.ParseDuration(s); err != nil {
return fmt.Errorf("invalid duration format (use '30s', '2m', etc.)")
}
return nil
}),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return err
}
cfg.Recording.SampleRate, _ = strconv.Atoi(sampleRate)
cfg.Recording.Channels, _ = strconv.Atoi(channels)
cfg.Recording.Format = format
cfg.Recording.BufferSize, _ = strconv.Atoi(bufferSize)
cfg.Recording.Device = device
cfg.Recording.ChannelBufferSize, _ = strconv.Atoi(channelBufferSize)
cfg.Recording.Timeout, _ = time.ParseDuration(timeout)
return nil
}
// editInjectionTimeouts handles the injection timeout settings
func editInjectionTimeouts(cfg *config.Config) error {
ydotoolTimeout := cfg.Injection.YdotoolTimeout.String()
wtypeTimeout := cfg.Injection.WtypeTimeout.String()
clipboardTimeout := cfg.Injection.ClipboardTimeout.String()
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("ydotool Timeout").
Description("Timeout for ydotool commands (e.g., '5s', '10s')").
Placeholder("5s").
Value(&ydotoolTimeout).
Validate(func(s string) error {
if _, err := time.ParseDuration(s); err != nil {
return fmt.Errorf("invalid duration format")
}
return nil
}),
huh.NewInput().
Title("wtype Timeout").
Description("Timeout for wtype commands (e.g., '5s', '10s')").
Placeholder("5s").
Value(&wtypeTimeout).
Validate(func(s string) error {
if _, err := time.ParseDuration(s); err != nil {
return fmt.Errorf("invalid duration format")
}
return nil
}),
huh.NewInput().
Title("Clipboard Timeout").
Description("Timeout for clipboard operations (e.g., '3s', '5s')").
Placeholder("3s").
Value(&clipboardTimeout).
Validate(func(s string) error {
if _, err := time.ParseDuration(s); err != nil {
return fmt.Errorf("invalid duration format")
}
return nil
}),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return err
}
cfg.Injection.YdotoolTimeout, _ = time.ParseDuration(ydotoolTimeout)
cfg.Injection.WtypeTimeout, _ = time.ParseDuration(wtypeTimeout)
cfg.Injection.ClipboardTimeout, _ = time.ParseDuration(clipboardTimeout)
return nil
}
+109
View File
@@ -0,0 +1,109 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/config"
)
// formatProvidersLabel formats the providers menu option
func formatProvidersLabel(cfg *config.Config) string {
return "Providers"
}
// formatTranscriptionLabel formats the transcription menu option
func formatTranscriptionLabel(cfg *config.Config) string {
return "Transcription"
}
// formatLLMLabel formats the LLM menu option
func formatLLMLabel(cfg *config.Config) string {
return "LLM"
}
// formatKeywordsLabel formats the keywords menu option
func formatKeywordsLabel(cfg *config.Config) string {
return "Keywords"
}
// formatInjectionLabel formats the injection menu option
func formatInjectionLabel(cfg *config.Config) string {
return "Injection"
}
// formatNotificationsLabel formats the notifications menu option
func formatNotificationsLabel(cfg *config.Config) string {
return "Notifications"
}
func showSummary(cfg *config.Config) (bool, error) {
fmt.Println()
fmt.Println(StyleHeader.Render("Configuration Summary"))
fmt.Println()
var providers []string
for name := range cfg.Providers {
providers = append(providers, name)
}
fmt.Printf(" %s %s\n", StyleLabel.Render("Providers:"), strings.Join(providers, ", "))
fmt.Printf(" %s %s (%s)\n", StyleLabel.Render("Transcription:"), cfg.Transcription.Provider, cfg.Transcription.Model)
if cfg.Transcription.Language != "" {
fmt.Printf(" %s %s\n", StyleLabel.Render("Language:"), cfg.Transcription.Language)
}
if cfg.LLM.Enabled {
fmt.Printf(" %s %s (%s)\n", StyleLabel.Render("LLM:"), cfg.LLM.Provider, cfg.LLM.Model)
var ppOpts []string
if cfg.LLM.PostProcessing.RemoveStutters {
ppOpts = append(ppOpts, "remove stutters")
}
if cfg.LLM.PostProcessing.AddPunctuation {
ppOpts = append(ppOpts, "add punctuation")
}
if cfg.LLM.PostProcessing.FixGrammar {
ppOpts = append(ppOpts, "fix grammar")
}
if cfg.LLM.PostProcessing.RemoveFillerWords {
ppOpts = append(ppOpts, "remove fillers")
}
if len(ppOpts) > 0 {
fmt.Printf(" %s %s\n", StyleLabel.Render("Post-processing:"), strings.Join(ppOpts, ", "))
}
} else {
fmt.Printf(" %s disabled\n", StyleLabel.Render("LLM:"))
}
if len(cfg.Keywords) > 0 {
fmt.Printf(" %s %s\n", StyleLabel.Render("Keywords:"), strings.Join(cfg.Keywords, ", "))
}
fmt.Printf(" %s %s\n", StyleLabel.Render("Backends:"), strings.Join(cfg.Injection.Backends, " -> "))
if cfg.Notifications.Enabled {
fmt.Printf(" %s enabled\n", StyleLabel.Render("Notifications:"))
} else {
fmt.Printf(" %s disabled\n", StyleLabel.Render("Notifications:"))
}
fmt.Println()
var confirmed bool
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Save this configuration?").
Affirmative("Save").
Negative("Cancel").
Value(&confirmed),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return false, err
}
return confirmed, nil
}
+444
View File
@@ -0,0 +1,444 @@
package tui
import (
"fmt"
"github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
// editLLM handles the LLM section edit with smart provider detection
func editLLM(cfg *config.Config, configuredProviders []string) ([]string, error) {
var llmProviders []string
for _, name := range configuredProviders {
p := provider.GetProvider(name)
if p != nil && p.SupportsLLM() {
llmProviders = append(llmProviders, name)
}
}
postProcessing := cfg.LLM.PostProcessing
if !postProcessing.RemoveStutters && !postProcessing.AddPunctuation &&
!postProcessing.FixGrammar && !postProcessing.RemoveFillerWords {
postProcessing = config.LLMPostProcessingConfig{
RemoveStutters: true,
AddPunctuation: true,
FixGrammar: true,
RemoveFillerWords: true,
}
}
customPrompt := cfg.LLM.CustomPrompt
enableLLM := cfg.LLM.Enabled
enableDesc := "LLM improves transcription by fixing grammar, removing stutters, and cleaning up text"
if cfg.LLM.Enabled {
enableDesc = fmt.Sprintf("Currently: enabled (%s/%s). %s", cfg.LLM.Provider, cfg.LLM.Model, enableDesc)
} else {
enableDesc = "Currently: disabled. " + enableDesc
}
enableForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Enable LLM Post-Processing? (Recommended)").
Description(enableDesc).
Affirmative("Yes (Recommended)").
Negative("No").
Value(&enableLLM),
),
).WithTheme(getTheme())
if err := enableForm.Run(); err != nil {
return configuredProviders, err
}
if !enableLLM {
cfg.LLM.Enabled = false
return configuredProviders, nil
}
var llmOptions []huh.Option[string]
for _, name := range llmProviders {
switch name {
case "openai":
llmOptions = append(llmOptions, huh.NewOption("OpenAI GPT", "openai"))
case "groq":
llmOptions = append(llmOptions, huh.NewOption("Groq Llama (fast)", "groq"))
}
}
unconfiguredLLM := getUnconfiguredLLMOptions(configuredProviders)
if len(unconfiguredLLM) > 0 {
llmOptions = append(llmOptions, unconfiguredLLM...)
}
if len(llmOptions) == 0 {
fmt.Println(StyleError.Render("No LLM providers available. Please configure OpenAI or Groq first."))
cfg.LLM.Enabled = false
return configuredProviders, nil
}
selectedProvider := cfg.LLM.Provider
if selectedProvider == "" && len(llmOptions) > 0 {
selectedProvider = llmOptions[0].Value
}
llmProviderDesc := "Choose which service to use for text post-processing"
if cfg.LLM.Provider != "" {
llmProviderDesc = fmt.Sprintf("Currently: %s/%s", cfg.LLM.Provider, cfg.LLM.Model)
}
providerForm := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("LLM Provider").
Description(llmProviderDesc).
Options(llmOptions...).
Value(&selectedProvider),
),
).WithTheme(getTheme())
if err := providerForm.Run(); err != nil {
return configuredProviders, err
}
configuredProviders = ensureProviderConfigured(cfg, selectedProvider, configuredProviders)
cfg.LLM.Provider = selectedProvider
modelOptions := getLLMModelOptions(selectedProvider)
selectedModel := cfg.LLM.Model
if selectedModel == "" && len(modelOptions) > 0 {
selectedModel = modelOptions[0].Value
}
llmModelDesc := ""
if cfg.LLM.Model != "" {
llmModelDesc = fmt.Sprintf("Currently: %s", cfg.LLM.Model)
}
modelForm := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("LLM Model").
Description(llmModelDesc).
Options(modelOptions...).
Value(&selectedModel),
),
).WithTheme(getTheme())
if err := modelForm.Run(); err != nil {
return configuredProviders, err
}
cfg.LLM.Model = selectedModel
var ppErr error
postProcessing, ppErr = selectPostProcessingOptions(postProcessing)
if ppErr != nil {
return configuredProviders, ppErr
}
cfg.LLM.PostProcessing = postProcessing
enableCustomPrompt := customPrompt.Enabled
customPromptText := customPrompt.Prompt
customPromptDesc := "Add extra instructions for the LLM"
if customPrompt.Enabled && customPrompt.Prompt != "" {
preview := customPrompt.Prompt
if len(preview) > 40 {
preview = preview[:40] + "..."
}
customPromptDesc = fmt.Sprintf("Currently: \"%s\"", preview)
} else {
customPromptDesc = "Currently: none. " + customPromptDesc
}
customForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Add custom prompt?").
Description(customPromptDesc).
Value(&enableCustomPrompt),
),
).WithTheme(getTheme())
if err := customForm.Run(); err != nil {
return configuredProviders, err
}
if enableCustomPrompt {
promptForm := huh.NewForm(
huh.NewGroup(
huh.NewText().
Title("Custom Prompt").
Description("Additional instructions (e.g., 'Format as bullet points')").
Value(&customPromptText).
CharLimit(500),
),
).WithTheme(getTheme())
if err := promptForm.Run(); err != nil {
return configuredProviders, err
}
cfg.LLM.CustomPrompt.Enabled = true
cfg.LLM.CustomPrompt.Prompt = customPromptText
} else {
cfg.LLM.CustomPrompt.Enabled = false
}
cfg.LLM.Enabled = true
return configuredProviders, nil
}
// getUnconfiguredLLMOptions returns options for LLM providers not yet configured
func getUnconfiguredLLMOptions(configuredProviders []string) []huh.Option[string] {
configured := make(map[string]bool)
for _, p := range configuredProviders {
configured[p] = true
}
var options []huh.Option[string]
if !configured["openai"] {
options = append(options, huh.NewOption("OpenAI GPT (not configured)", "openai"))
}
if !configured["groq"] {
options = append(options, huh.NewOption("Groq Llama (not configured)", "groq"))
}
return options
}
func getLLMModelOptions(provider string) []huh.Option[string] {
switch provider {
case "openai":
return []huh.Option[string]{
huh.NewOption("gpt-4o-mini (recommended)", "gpt-4o-mini"),
huh.NewOption("gpt-4o", "gpt-4o"),
huh.NewOption("gpt-4-turbo", "gpt-4-turbo"),
huh.NewOption("gpt-3.5-turbo", "gpt-3.5-turbo"),
}
case "groq":
return []huh.Option[string]{
huh.NewOption("llama-3.3-70b-versatile (recommended)", "llama-3.3-70b-versatile"),
huh.NewOption("llama-3.1-8b-instant (faster)", "llama-3.1-8b-instant"),
huh.NewOption("mixtral-8x7b-32768", "mixtral-8x7b-32768"),
}
default:
return []huh.Option[string]{}
}
}
// selectPostProcessingOptions shows a multi-select for LLM post-processing toggles
func selectPostProcessingOptions(current config.LLMPostProcessingConfig) (config.LLMPostProcessingConfig, error) {
type ppOption string
const (
optRemoveStutters ppOption = "stutters"
optAddPunctuation ppOption = "punctuation"
optFixGrammar ppOption = "grammar"
optRemoveFillerWords ppOption = "fillers"
)
options := []huh.Option[ppOption]{
huh.NewOption("Remove stutters (repeated words)", optRemoveStutters),
huh.NewOption("Add punctuation", optAddPunctuation),
huh.NewOption("Fix grammar", optFixGrammar),
huh.NewOption("Remove filler words (um, uh, like)", optRemoveFillerWords),
}
var selected []ppOption
if current.RemoveStutters {
selected = append(selected, optRemoveStutters)
}
if current.AddPunctuation {
selected = append(selected, optAddPunctuation)
}
if current.FixGrammar {
selected = append(selected, optFixGrammar)
}
if current.RemoveFillerWords {
selected = append(selected, optRemoveFillerWords)
}
form := huh.NewForm(
huh.NewGroup(
huh.NewMultiSelect[ppOption]().
Title("Post-Processing Options").
Description("Select which improvements to apply").
Options(options...).
Value(&selected),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return current, err
}
result := config.LLMPostProcessingConfig{}
for _, opt := range selected {
switch opt {
case optRemoveStutters:
result.RemoveStutters = true
case optAddPunctuation:
result.AddPunctuation = true
case optFixGrammar:
result.FixGrammar = true
case optRemoveFillerWords:
result.RemoveFillerWords = true
}
}
return result, nil
}
func configureLLM(configuredProviders []string, cfg *config.Config) (bool, string, string, config.LLMPostProcessingConfig, config.LLMCustomPromptConfig, error) {
var llmProviders []string
for _, name := range configuredProviders {
p := provider.GetProvider(name)
if p != nil && p.SupportsLLM() {
llmProviders = append(llmProviders, name)
}
}
postProcessing := config.LLMPostProcessingConfig{
RemoveStutters: true,
AddPunctuation: true,
FixGrammar: true,
RemoveFillerWords: true,
}
customPrompt := config.LLMCustomPromptConfig{
Enabled: false,
Prompt: "",
}
if len(llmProviders) == 0 {
return false, "", "", postProcessing, customPrompt, nil
}
var enableLLM bool = true
enableForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Enable LLM Post-Processing? (Recommended)").
Description("LLM improves transcription by fixing grammar, removing stutters, and cleaning up text").
Affirmative("Yes (Recommended)").
Negative("No").
Value(&enableLLM),
),
).WithTheme(getTheme())
if err := enableForm.Run(); err != nil {
return false, "", "", postProcessing, customPrompt, err
}
if !enableLLM {
return false, "", "", postProcessing, customPrompt, nil
}
var llmOptions []huh.Option[string]
for _, name := range llmProviders {
p := provider.GetProvider(name)
if p != nil {
switch name {
case "openai":
llmOptions = append(llmOptions, huh.NewOption("OpenAI GPT", "openai"))
case "groq":
llmOptions = append(llmOptions, huh.NewOption("Groq Llama (fast)", "groq"))
}
}
}
var selectedProvider string
if cfg.LLM.Provider != "" {
selectedProvider = cfg.LLM.Provider
} else if len(llmOptions) > 0 {
selectedProvider = llmOptions[0].Value
}
providerForm := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("LLM Provider").
Description("Choose which service to use for text post-processing").
Options(llmOptions...).
Value(&selectedProvider),
),
).WithTheme(getTheme())
if err := providerForm.Run(); err != nil {
return false, "", "", postProcessing, customPrompt, err
}
modelOptions := getLLMModelOptions(selectedProvider)
var selectedModel string
if cfg.LLM.Model != "" {
selectedModel = cfg.LLM.Model
} else if len(modelOptions) > 0 {
selectedModel = modelOptions[0].Value
}
modelForm := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("LLM Model").
Options(modelOptions...).
Value(&selectedModel),
),
).WithTheme(getTheme())
if err := modelForm.Run(); err != nil {
return false, "", "", postProcessing, customPrompt, err
}
if cfg.LLM.PostProcessing.RemoveStutters || cfg.LLM.PostProcessing.AddPunctuation ||
cfg.LLM.PostProcessing.FixGrammar || cfg.LLM.PostProcessing.RemoveFillerWords {
postProcessing = cfg.LLM.PostProcessing
}
var ppErr error
postProcessing, ppErr = selectPostProcessingOptions(postProcessing)
if ppErr != nil {
return false, "", "", postProcessing, customPrompt, ppErr
}
var enableCustomPrompt bool
var customPromptText string
if cfg.LLM.CustomPrompt.Enabled {
enableCustomPrompt = true
customPromptText = cfg.LLM.CustomPrompt.Prompt
}
customForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Add custom prompt?").
Description("Add extra instructions for the LLM").
Value(&enableCustomPrompt),
),
).WithTheme(getTheme())
if err := customForm.Run(); err != nil {
return false, "", "", postProcessing, customPrompt, err
}
if enableCustomPrompt {
promptForm := huh.NewForm(
huh.NewGroup(
huh.NewText().
Title("Custom Prompt").
Description("Additional instructions (e.g., 'Format as bullet points')").
Value(&customPromptText).
CharLimit(500),
),
).WithTheme(getTheme())
if err := promptForm.Run(); err != nil {
return false, "", "", postProcessing, customPrompt, err
}
customPrompt.Enabled = true
customPrompt.Prompt = customPromptText
}
return true, selectedProvider, selectedModel, postProcessing, customPrompt, nil
}
+251
View File
@@ -0,0 +1,251 @@
package tui
import (
"fmt"
"github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/notify"
)
// editNotifications handles the notifications section edit with type and custom messages
func editNotifications(cfg *config.Config) error {
enabled := cfg.Notifications.Enabled
desc := "Show notifications for recording status changes"
if cfg.Notifications.Enabled {
desc = fmt.Sprintf("Currently: enabled (%s). %s", cfg.Notifications.Type, desc)
} else {
desc = "Currently: disabled. " + desc
}
enableForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Enable desktop notifications?").
Description(desc).
Value(&enabled),
),
).WithTheme(getTheme())
if err := enableForm.Run(); err != nil {
return err
}
cfg.Notifications.Enabled = enabled
if !enabled {
return nil
}
notifType := cfg.Notifications.Type
if notifType == "" {
notifType = "desktop"
}
typeOptions := []huh.Option[string]{
huh.NewOption("Desktop notifications (notify-send)", "desktop"),
huh.NewOption("Log to console only", "log"),
huh.NewOption("None (silent)", "none"),
}
typeForm := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Notification Type").
Description("How should notifications be displayed?").
Options(typeOptions...).
Value(&notifType),
),
).WithTheme(getTheme())
if err := typeForm.Run(); err != nil {
return err
}
cfg.Notifications.Type = notifType
var configureMessages bool
msgForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Configure custom notification messages?").
Description("Customize the text shown in notifications").
Affirmative("Yes").
Negative("No, use defaults").
Value(&configureMessages),
),
).WithTheme(getTheme())
if err := msgForm.Run(); err != nil {
return err
}
if configureMessages {
if err := editNotificationMessages(cfg); err != nil {
return err
}
}
return nil
}
// editNotificationMessages allows editing individual notification messages
func editNotificationMessages(cfg *config.Config) error {
for {
var options []huh.Option[string]
for _, def := range notify.MessageDefs {
currentBody := def.DefaultBody
switch def.ConfigKey {
case "recording_started":
if cfg.Notifications.Messages.RecordingStarted.Body != "" {
currentBody = cfg.Notifications.Messages.RecordingStarted.Body
}
case "transcribing":
if cfg.Notifications.Messages.Transcribing.Body != "" {
currentBody = cfg.Notifications.Messages.Transcribing.Body
}
case "llm_processing":
if cfg.Notifications.Messages.LLMProcessing.Body != "" {
currentBody = cfg.Notifications.Messages.LLMProcessing.Body
}
case "config_reloaded":
if cfg.Notifications.Messages.ConfigReloaded.Body != "" {
currentBody = cfg.Notifications.Messages.ConfigReloaded.Body
}
case "operation_cancelled":
if cfg.Notifications.Messages.OperationCancelled.Body != "" {
currentBody = cfg.Notifications.Messages.OperationCancelled.Body
}
case "recording_aborted":
if cfg.Notifications.Messages.RecordingAborted.Body != "" {
currentBody = cfg.Notifications.Messages.RecordingAborted.Body
}
case "injection_aborted":
if cfg.Notifications.Messages.InjectionAborted.Body != "" {
currentBody = cfg.Notifications.Messages.InjectionAborted.Body
}
}
displayBody := currentBody
if len(displayBody) > 30 {
displayBody = displayBody[:30] + "..."
}
label := fmt.Sprintf("%s: \"%s\"", def.ConfigKey, displayBody)
options = append(options, huh.NewOption(label, def.ConfigKey))
}
options = append(options, huh.NewOption("Back", "back"))
var selected string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Notification Messages").
Description("Select a message to edit").
Options(options...).
Value(&selected),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return err
}
if selected == "back" {
return nil
}
if err := editSingleMessage(cfg, selected); err != nil {
continue
}
}
}
// editSingleMessage edits a single notification message
func editSingleMessage(cfg *config.Config, configKey string) error {
var def notify.MessageDef
for _, d := range notify.MessageDefs {
if d.ConfigKey == configKey {
def = d
break
}
}
var currentTitle, currentBody string
switch configKey {
case "recording_started":
currentTitle = cfg.Notifications.Messages.RecordingStarted.Title
currentBody = cfg.Notifications.Messages.RecordingStarted.Body
case "transcribing":
currentTitle = cfg.Notifications.Messages.Transcribing.Title
currentBody = cfg.Notifications.Messages.Transcribing.Body
case "llm_processing":
currentTitle = cfg.Notifications.Messages.LLMProcessing.Title
currentBody = cfg.Notifications.Messages.LLMProcessing.Body
case "config_reloaded":
currentTitle = cfg.Notifications.Messages.ConfigReloaded.Title
currentBody = cfg.Notifications.Messages.ConfigReloaded.Body
case "operation_cancelled":
currentTitle = cfg.Notifications.Messages.OperationCancelled.Title
currentBody = cfg.Notifications.Messages.OperationCancelled.Body
case "recording_aborted":
currentTitle = cfg.Notifications.Messages.RecordingAborted.Title
currentBody = cfg.Notifications.Messages.RecordingAborted.Body
case "injection_aborted":
currentTitle = cfg.Notifications.Messages.InjectionAborted.Title
currentBody = cfg.Notifications.Messages.InjectionAborted.Body
}
if currentTitle == "" {
currentTitle = def.DefaultTitle
}
if currentBody == "" {
currentBody = def.DefaultBody
}
title := currentTitle
body := currentBody
var fields []huh.Field
if !def.IsError {
fields = append(fields, huh.NewInput().
Title("Title").
Description(fmt.Sprintf("Default: %s", def.DefaultTitle)).
Placeholder(def.DefaultTitle).
Value(&title))
}
fields = append(fields, huh.NewInput().
Title("Body").
Description(fmt.Sprintf("Default: %s", def.DefaultBody)).
Placeholder(def.DefaultBody).
Value(&body))
form := huh.NewForm(
huh.NewGroup(fields...),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return err
}
msgConfig := config.MessageConfig{Title: title, Body: body}
switch configKey {
case "recording_started":
cfg.Notifications.Messages.RecordingStarted = msgConfig
case "transcribing":
cfg.Notifications.Messages.Transcribing = msgConfig
case "llm_processing":
cfg.Notifications.Messages.LLMProcessing = msgConfig
case "config_reloaded":
cfg.Notifications.Messages.ConfigReloaded = msgConfig
case "operation_cancelled":
cfg.Notifications.Messages.OperationCancelled = msgConfig
case "recording_aborted":
cfg.Notifications.Messages.RecordingAborted = msgConfig
case "injection_aborted":
cfg.Notifications.Messages.InjectionAborted = msgConfig
}
return nil
}
+203
View File
@@ -0,0 +1,203 @@
package tui
import (
"fmt"
"github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
// getProviderDisplayName returns the display name for a provider
func getProviderDisplayName(providerName string) string {
if name, ok := providerDisplayNames[providerName]; ok {
return name
}
return providerName
}
// maskAPIKey returns a masked version of an API key for display
func maskAPIKey(key string) string {
if len(key) <= 8 {
return "***"
}
return key[:7] + "..." + key[len(key)-4:]
}
// getConfiguredProviders returns list of providers with API keys
func getConfiguredProviders(cfg *config.Config) []string {
var providers []string
for name, pc := range cfg.Providers {
if pc.APIKey != "" {
providers = append(providers, name)
}
}
return providers
}
// editProviders handles the providers section edit with submenu
func editProviders(cfg *config.Config) error {
for {
var options []huh.Option[string]
for _, name := range AllProviders {
options = append(options, huh.NewOption(formatProviderOption(cfg, name), name))
}
options = append(options, huh.NewOption("Back", "back"))
var selected string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Provider Settings").
Description("Select a provider to configure API key").
Options(options...).
Value(&selected),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return err
}
if selected == "back" {
return nil
}
apiKey, err := configureSingleProvider(cfg, selected)
if err != nil {
continue
}
if apiKey != "" {
if cfg.Providers == nil {
cfg.Providers = make(map[string]config.ProviderConfig)
}
cfg.Providers[selected] = config.ProviderConfig{APIKey: apiKey}
}
}
}
// formatProviderOption formats a provider menu option with status
func formatProviderOption(cfg *config.Config, name string) string {
var status string
if pc, exists := cfg.Providers[name]; exists && pc.APIKey != "" {
status = "(configured)"
} else {
status = "(not configured)"
}
switch name {
case "openai":
return fmt.Sprintf("OpenAI - Whisper + GPT %s", status)
case "groq":
return fmt.Sprintf("Groq - Whisper + Llama %s", status)
case "mistral":
return fmt.Sprintf("Mistral - Voxtral %s", status)
case "elevenlabs":
return fmt.Sprintf("ElevenLabs - Scribe %s", status)
default:
return fmt.Sprintf("%s %s", name, status)
}
}
// configureSingleProvider handles the complete flow for configuring a single provider's API key.
// Shows confirm dialog if key exists, then prompts for new key if needed.
// Returns the new API key (empty if user kept current) and any error.
func configureSingleProvider(cfg *config.Config, providerName string) (string, error) {
var existingKey string
if pc, exists := cfg.Providers[providerName]; exists && pc.APIKey != "" {
existingKey = pc.APIKey
}
if existingKey != "" {
displayName := getProviderDisplayName(providerName)
masked := maskAPIKey(existingKey)
var update bool
confirmForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(fmt.Sprintf("%s API Key", displayName)).
Description(fmt.Sprintf("Current: %s", masked)).
Affirmative("Update key").
Negative("Keep current").
Value(&update),
),
).WithTheme(getTheme())
if err := confirmForm.Run(); err != nil {
return "", err
}
if !update {
return "", nil
}
}
return inputAPIKey(providerName)
}
func inputAPIKey(providerName string) (string, error) {
p := provider.GetProvider(providerName)
displayName := getProviderDisplayName(providerName)
if p != nil {
if name, ok := providerDisplayNames[p.Name()]; ok {
displayName = name
}
}
var apiKey string
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title(fmt.Sprintf("%s API Key", displayName)).
Description(fmt.Sprintf("Enter your %s API key", displayName)).
EchoMode(huh.EchoModePassword).
Value(&apiKey).
Validate(func(s string) error {
if s == "" {
return fmt.Errorf("API key is required")
}
if p != nil && !p.ValidateAPIKey(s) {
return fmt.Errorf("invalid API key format for %s", displayName)
}
return nil
}),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return "", err
}
return apiKey, nil
}
// ensureProviderConfigured prompts for API key if provider not configured
func ensureProviderConfigured(cfg *config.Config, selectedProvider string, configuredProviders []string) []string {
providerName := selectedProvider
switch selectedProvider {
case "groq-transcription", "groq-translation":
providerName = "groq"
case "mistral-transcription":
providerName = "mistral"
}
for _, p := range configuredProviders {
if p == providerName {
return configuredProviders
}
}
apiKey, err := configureSingleProvider(cfg, providerName)
if err != nil || apiKey == "" {
return configuredProviders
}
if cfg.Providers == nil {
cfg.Providers = make(map[string]config.ProviderConfig)
}
cfg.Providers[providerName] = config.ProviderConfig{APIKey: apiKey}
return append(configuredProviders, providerName)
}
+249
View File
@@ -0,0 +1,249 @@
package tui
import (
"fmt"
"github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
// editTranscription handles the transcription section edit with smart provider detection
func editTranscription(cfg *config.Config, configuredProviders []string) ([]string, error) {
var transcriptionOptions []huh.Option[string]
for _, name := range configuredProviders {
p := provider.GetProvider(name)
if p != nil && p.SupportsTranscription() {
switch name {
case "openai":
transcriptionOptions = append(transcriptionOptions,
huh.NewOption("OpenAI Whisper", "openai"))
case "groq":
transcriptionOptions = append(transcriptionOptions,
huh.NewOption("Groq Whisper (transcription)", "groq-transcription"),
huh.NewOption("Groq Whisper (translate to English)", "groq-translation"))
case "mistral":
transcriptionOptions = append(transcriptionOptions,
huh.NewOption("Mistral Voxtral", "mistral-transcription"))
case "elevenlabs":
transcriptionOptions = append(transcriptionOptions,
huh.NewOption("ElevenLabs Scribe", "elevenlabs"))
}
}
}
unconfiguredOptions := getUnconfiguredTranscriptionOptions(configuredProviders)
if len(unconfiguredOptions) > 0 {
transcriptionOptions = append(transcriptionOptions, unconfiguredOptions...)
}
if len(transcriptionOptions) == 0 {
return configuredProviders, fmt.Errorf("no transcription providers available")
}
selectedProvider := cfg.Transcription.Provider
if selectedProvider == "" && len(transcriptionOptions) > 0 {
selectedProvider = transcriptionOptions[0].Value
}
providerDesc := "Choose which service to use for speech-to-text"
if cfg.Transcription.Provider != "" {
providerDesc = fmt.Sprintf("Currently: %s/%s", cfg.Transcription.Provider, cfg.Transcription.Model)
}
providerForm := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Transcription Provider").
Description(providerDesc).
Options(transcriptionOptions...).
Value(&selectedProvider),
),
).WithTheme(getTheme())
if err := providerForm.Run(); err != nil {
return configuredProviders, err
}
configuredProviders = ensureProviderConfigured(cfg, selectedProvider, configuredProviders)
cfg.Transcription.Provider = selectedProvider
modelOptions := getTranscriptionModelOptions(selectedProvider)
selectedModel := cfg.Transcription.Model
if selectedModel == "" && len(modelOptions) > 0 {
selectedModel = modelOptions[0].Value
}
modelDesc := ""
if cfg.Transcription.Model != "" {
modelDesc = fmt.Sprintf("Currently: %s", cfg.Transcription.Model)
}
language := cfg.Transcription.Language
langDesc := "ISO-639-1 code (e.g., 'en', 'es', 'fr') or empty for auto-detect"
if cfg.Transcription.Language != "" {
langDesc = fmt.Sprintf("Currently: %s. %s", cfg.Transcription.Language, langDesc)
}
modelForm := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Transcription Model").
Description(modelDesc).
Options(modelOptions...).
Value(&selectedModel),
huh.NewInput().
Title("Language").
Description(langDesc).
Placeholder("auto-detect").
Value(&language),
),
).WithTheme(getTheme())
if err := modelForm.Run(); err != nil {
return configuredProviders, err
}
cfg.Transcription.Model = selectedModel
cfg.Transcription.Language = language
return configuredProviders, nil
}
// getUnconfiguredTranscriptionOptions returns options for providers not yet configured
func getUnconfiguredTranscriptionOptions(configuredProviders []string) []huh.Option[string] {
configured := make(map[string]bool)
for _, p := range configuredProviders {
configured[p] = true
}
var options []huh.Option[string]
if !configured["openai"] {
options = append(options, huh.NewOption("OpenAI Whisper (not configured)", "openai"))
}
if !configured["groq"] {
options = append(options,
huh.NewOption("Groq Whisper transcription (not configured)", "groq-transcription"),
huh.NewOption("Groq Whisper translation (not configured)", "groq-translation"))
}
if !configured["mistral"] {
options = append(options, huh.NewOption("Mistral Voxtral (not configured)", "mistral-transcription"))
}
if !configured["elevenlabs"] {
options = append(options, huh.NewOption("ElevenLabs Scribe (not configured)", "elevenlabs"))
}
return options
}
func getTranscriptionModelOptions(provider string) []huh.Option[string] {
switch provider {
case "openai":
return []huh.Option[string]{
huh.NewOption("whisper-1", "whisper-1"),
}
case "groq-transcription":
return []huh.Option[string]{
huh.NewOption("whisper-large-v3-turbo (faster)", "whisper-large-v3-turbo"),
huh.NewOption("whisper-large-v3 (standard)", "whisper-large-v3"),
}
case "groq-translation":
return []huh.Option[string]{
huh.NewOption("whisper-large-v3 (only option)", "whisper-large-v3"),
}
case "mistral-transcription":
return []huh.Option[string]{
huh.NewOption("voxtral-mini-latest (recommended)", "voxtral-mini-latest"),
huh.NewOption("voxtral-mini-2507", "voxtral-mini-2507"),
}
case "elevenlabs":
return []huh.Option[string]{
huh.NewOption("scribe_v1 (99 languages, best accuracy)", "scribe_v1"),
huh.NewOption("scribe_v2 (real-time, lower latency)", "scribe_v2"),
}
default:
return []huh.Option[string]{}
}
}
func configureTranscription(configuredProviders []string, cfg *config.Config) (string, string, string, error) {
var transcriptionOptions []huh.Option[string]
for _, name := range configuredProviders {
p := provider.GetProvider(name)
if p != nil && p.SupportsTranscription() {
switch name {
case "openai":
transcriptionOptions = append(transcriptionOptions,
huh.NewOption("OpenAI Whisper", "openai"))
case "groq":
transcriptionOptions = append(transcriptionOptions,
huh.NewOption("Groq Whisper (transcription)", "groq-transcription"),
huh.NewOption("Groq Whisper (translate to English)", "groq-translation"))
case "mistral":
transcriptionOptions = append(transcriptionOptions,
huh.NewOption("Mistral Voxtral", "mistral-transcription"))
case "elevenlabs":
transcriptionOptions = append(transcriptionOptions,
huh.NewOption("ElevenLabs Scribe", "elevenlabs"))
}
}
}
if len(transcriptionOptions) == 0 {
return "", "", "", fmt.Errorf("no transcription-capable providers configured")
}
var selectedProvider string
if cfg.Transcription.Provider != "" {
selectedProvider = cfg.Transcription.Provider
} else if len(transcriptionOptions) > 0 {
selectedProvider = transcriptionOptions[0].Value
}
providerForm := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Transcription Provider").
Description("Choose which service to use for speech-to-text").
Options(transcriptionOptions...).
Value(&selectedProvider),
),
).WithTheme(getTheme())
if err := providerForm.Run(); err != nil {
return "", "", "", err
}
modelOptions := getTranscriptionModelOptions(selectedProvider)
var selectedModel string
if cfg.Transcription.Model != "" {
selectedModel = cfg.Transcription.Model
} else if len(modelOptions) > 0 {
selectedModel = modelOptions[0].Value
}
var language string
if cfg.Transcription.Language != "" {
language = cfg.Transcription.Language
}
modelForm := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Transcription Model").
Options(modelOptions...).
Value(&selectedModel),
huh.NewInput().
Title("Language").
Description("ISO-639-1 code (e.g., 'en', 'es', 'fr') or empty for auto-detect").
Placeholder("auto-detect").
Value(&language),
),
).WithTheme(getTheme())
if err := modelForm.Run(); err != nil {
return "", "", "", err
}
return selectedProvider, selectedModel, language, nil
}
+213
View File
@@ -0,0 +1,213 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/config"
)
// runFreshInstall runs the full configuration wizard for fresh installs
func runFreshInstall(cfg *config.Config) (*ConfigureResult, error) {
fmt.Println(Logo())
fmt.Println()
fmt.Println(StyleMuted.Render("Voice-powered typing for Wayland/Hyprland"))
fmt.Println()
selectedProviders, err := selectProviders()
if err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
if len(selectedProviders) == 0 {
return &ConfigureResult{Cancelled: true}, fmt.Errorf("no providers selected")
}
if cfg.Providers == nil {
cfg.Providers = make(map[string]config.ProviderConfig)
}
for _, providerName := range selectedProviders {
apiKey, err := inputAPIKey(providerName)
if err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
cfg.Providers[providerName] = config.ProviderConfig{APIKey: apiKey}
}
transcriptionProvider, transcriptionModel, language, err := configureTranscription(selectedProviders, cfg)
if err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
cfg.Transcription.Provider = transcriptionProvider
cfg.Transcription.Model = transcriptionModel
cfg.Transcription.Language = language
llmEnabled, llmProvider, llmModel, postProcessing, customPrompt, err := configureLLM(selectedProviders, cfg)
if err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
cfg.LLM.Enabled = llmEnabled
cfg.LLM.Provider = llmProvider
cfg.LLM.Model = llmModel
cfg.LLM.PostProcessing = postProcessing
cfg.LLM.CustomPrompt = customPrompt
keywords, err := inputKeywords(cfg.Keywords)
if err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
cfg.Keywords = keywords
backends, err := selectBackends(cfg.Injection.Backends)
if err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
cfg.Injection.Backends = backends
notificationsEnabled, err := configureNotifications(cfg.Notifications.Enabled)
if err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
cfg.Notifications.Enabled = notificationsEnabled
confirmed, err := showSummary(cfg)
if err != nil || !confirmed {
return &ConfigureResult{Cancelled: true}, nil
}
return &ConfigureResult{Config: cfg, Cancelled: false}, nil
}
func selectProviders() ([]string, error) {
options := []huh.Option[string]{
huh.NewOption("OpenAI - Whisper transcription + GPT for LLM", "openai"),
huh.NewOption("Groq - Fast Whisper transcription + Llama for LLM", "groq"),
huh.NewOption("Mistral - Voxtral transcription (European languages)", "mistral"),
huh.NewOption("ElevenLabs - Scribe transcription (99 languages)", "elevenlabs"),
}
var selected []string
form := huh.NewForm(
huh.NewGroup(
huh.NewMultiSelect[string]().
Title("Which providers do you want to configure?").
Description("Select all providers you have API keys for").
Options(options...).
Value(&selected),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return nil, err
}
valid := make([]string, 0)
for _, s := range selected {
for _, p := range AllProviders {
if s == p {
valid = append(valid, s)
break
}
}
}
return valid, nil
}
func inputKeywords(existingKeywords []string) ([]string, error) {
var keywordsInput string
if len(existingKeywords) > 0 {
keywordsInput = strings.Join(existingKeywords, ", ")
}
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Keywords").
Description("Comma-separated words to help with spelling (names, technical terms, etc.)").
Placeholder("e.g., Kubernetes, PostgreSQL, John Smith").
Value(&keywordsInput),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return nil, err
}
if keywordsInput == "" {
return nil, nil
}
parts := strings.Split(keywordsInput, ",")
keywords := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
keywords = append(keywords, p)
}
}
return keywords, nil
}
func selectBackends(existingBackends []string) ([]string, error) {
options := []huh.Option[string]{
huh.NewOption("ydotool - Best for Chromium/Electron (needs ydotoold)", "ydotool"),
huh.NewOption("wtype - Native Wayland typing", "wtype"),
huh.NewOption("clipboard - Copy to clipboard only", "clipboard"),
}
var selected []string
if len(existingBackends) > 0 {
selected = existingBackends
} else {
selected = []string{"ydotool", "wtype", "clipboard"}
}
form := huh.NewForm(
huh.NewGroup(
huh.NewMultiSelect[string]().
Title("Text Injection Backends").
Description("Backends are tried in order until one succeeds (fallback chain)").
Options(options...).
Value(&selected),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return nil, err
}
if len(selected) == 0 {
return nil, fmt.Errorf("at least one backend required")
}
return selected, nil
}
func configureNotifications(existingEnabled bool) (bool, error) {
enabled := existingEnabled
desc := "Show notifications for recording status changes"
if existingEnabled {
desc = "Currently: enabled. " + desc
} else {
desc = "Currently: disabled. " + desc
}
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Enable desktop notifications?").
Description(desc).
Value(&enabled),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return false, err
}
return enabled, nil
}
-268
View File
@@ -1,268 +0,0 @@
# Ralph Progress Log
Started: Sat Jan 31 08:30:51 PM CET 2026
---
## Task 1: Create Provider interface and registry - COMPLETE
Created internal/provider package with:
- Provider interface with all required methods
- ProviderConfig struct for API key storage
- 4 provider implementations: OpenAI, Groq, Mistral, ElevenLabs
- Registry with GetProvider(), ListProviders(), ListProvidersWithLLM(), ListProvidersWithTranscription()
- Comprehensive tests (all passing)
Key decisions:
- OpenAI and Groq support both transcription + LLM
- Mistral and ElevenLabs are transcription-only
- ValidateAPIKey checks prefix for OpenAI (sk-) and Groq (gsk_), accepts any non-empty for others
## Task 2: Refactor config to unified provider structure - COMPLETE
Added to internal/config/config.go:
- `Providers map[string]ProviderConfig` for centralized API key storage
- `Keywords []string` at config root level
- `LLMConfig` with Enabled, Provider, Model, PostProcessing, CustomPrompt
- `LLMPostProcessingConfig` with RemoveStutters, AddPunctuation, FixGrammar, RemoveFillerWords (all default true)
- `LLMCustomPromptConfig` with Enabled, Prompt
- `LLMAdapterConfig` struct for passing to LLM adapters
- `ToLLMConfig()` method
- `IsLLMEnabled()` helper
- `resolveAPIKeyForProvider()` - unified API key resolution: providers map -> legacy transcription.api_key -> env var
- `resolveAPIKeyForLLMProvider()` - same for LLM
- `migrateTranscriptionAPIKey()` - auto-migrates old config format
- `applyLLMDefaults()` - sets post-processing options to true if all are zero
Migration:
- Old configs with `transcription.api_key` auto-migrate to `providers` map on Load()
- Logs warning: "Run 'hyprvoice configure' to update config format"
- Both old and new config formats work (backward compatible)
Validation:
- LLM validation only runs when `llm.enabled = true`
- Checks provider is openai or groq
- Checks API key is available for LLM provider
Key decisions:
- API key resolution order: providers.X.api_key -> transcription.api_key -> ENV_VAR
- LLM provider names are "openai" and "groq" (not "groq-transcription")
- PostProcessing defaults to all true only if ALL options are false (zero values)
- Keywords at root level (global), used by both transcription and LLM
## Task 3: Create LLM adapter interface and implementations - COMPLETE
Created internal/llm package with:
- `Adapter` interface: `Process(ctx, text) (string, error)`
- `Config` struct mirroring config.LLMAdapterConfig
- `prompt.go` with `BuildSystemPrompt(opts, keywords)` and `BuildUserPrompt(text, customPrompt)`
- `OpenAIAdapter` using go-openai chat completions API
- `GroqAdapter` using Groq's OpenAI-compatible API (baseURL override)
- `NewAdapter(config)` factory function
Key decisions:
- Low temperature (0.3) for consistent text cleanup
- Default models: gpt-4o-mini (OpenAI), llama-3.3-70b-versatile (Groq)
- System prompt builds dynamically based on enabled options
- Keywords included in system prompt for correct spelling hints
- Custom prompt prepended to user prompt if enabled
## Task 4: Integrate LLM phase into pipeline - COMPLETE
Updated internal/pipeline/pipeline.go:
- Added `Processing` status for LLM post-processing phase
- After transcription, checks `config.IsLLMEnabled()` before LLM processing
- Creates LLM adapter using config.ToLLMConfig()
- Processes text with adapter, uses result for injection
- Graceful fallback: logs warning and uses raw transcription text on any error
Key decisions:
- LLM processing happens between transcription and injection
- Adapter creation and processing errors are logged but don't fail the pipeline
- Sets status to Processing during LLM phase, then back to Injecting
## Task 5: Pass keywords to transcription adapters - COMPLETE
Added Keywords support to transcription adapters:
- Added `Keywords []string` to transcriber.Config struct
- Updated `ToTranscriberConfig()` to pass keywords from config
- OpenAI adapter uses keywords in `Prompt` field (initial_prompt parameter)
- Groq transcription adapter uses keywords in `Prompt` field
- Groq translation adapter uses keywords in `Prompt` field
- Mistral and ElevenLabs adapters ignore keywords (APIs don't support initial_prompt)
Key decisions:
- Keywords joined with ", " to form a single string for the Prompt field
- Whisper uses this as "initial_prompt" to help with spelling/terminology
- Only added to adapters that clearly support it (OpenAI/Groq via go-openai lib)
## Task 6: Add TUI dependencies and base components - COMPLETE
Added Charmbracelet TUI stack:
- bubbletea v1.3.10, lipgloss v1.1.0, huh v0.8.0
- Created internal/tui package
Files created:
- `internal/tui/theme.go` - color palette (purple primary, cyan secondary, status colors)
- `internal/tui/styles.go` - lipgloss styles (header, label, success, error, muted, highlight, selected, box styles)
- `Logo()` function for ASCII branding
Key decisions:
- Purple (#7C3AED) as primary accent, matches hyprvoice "voice" theme
- Dark slate backgrounds for terminal aesthetics
- Box styles with rounded borders for form containers
## Task 7: Create TUI configure - fresh install flow - COMPLETE
Created internal/tui/configure.go with full TUI wizard:
- `Run(existingConfig)` entry point returning ConfigureResult
- `runFreshInstall()` - linear flow through all configuration steps
- `selectProviders()` - multi-select for OpenAI, Groq, Mistral, ElevenLabs
- `inputAPIKey()` - password-masked input with validation per provider
- `configureTranscription()` - provider dropdown (only configured+capable), model dropdown, language input
- `configureLLM()` - enable confirm (defaults YES, labeled "Recommended"), provider, model, post-processing toggles, custom prompt
- `inputKeywords()` - comma-separated input
- `selectBackends()` - multi-select with descriptions
- `configureNotifications()` - enable toggle
- `showSummary()` - displays all config, confirm button
- `getTheme()` - applies hyprvoice color scheme to huh forms
Key decisions:
- Linear flow for fresh installs, all steps required
- Transcription providers mapped: groq -> groq-transcription + groq-translation options
- LLM enabled by default, "Yes (Recommended)" as affirmative text
- Post-processing options all default to true
- Uses huh library forms with custom theme matching styles.go colors
## Task 8: Create TUI configure - edit existing flow - COMPLETE
Added edit flow for existing configs in internal/tui/configure.go:
- `hasUserChanges()` detects if config has been modified (providers configured or legacy api_key set)
- `runEditExisting()` - section-based edit flow instead of full wizard
- `selectSections()` - multi-select for Providers, Transcription, LLM, Keywords, Injection, Notifications, Full Setup
- `editProviders()` - add/update API keys for selected providers
- `editTranscription()` - configure speech-to-text with smart provider detection
- `editLLM()` - configure post-processing with smart provider detection
- `getUnconfiguredTranscriptionOptions()` / `getUnconfiguredLLMOptions()` - show options for providers without keys
- `ensureProviderConfigured()` - prompts for API key when user selects unconfigured provider
Key decisions:
- "Full Setup" option runs the fresh install flow
- Configured providers show "(configured)" label in providers section
- Unconfigured providers show "(needs API key)" in transcription/LLM sections
- When user picks unconfigured provider, immediately prompts for API key
- Unedited sections preserved - only touched sections are modified
- Config struct passed by reference, changes accumulate
## Task 9: Replace old configure with TUI - COMPLETE
Replaced old interactive config in cmd/hyprvoice/main.go:
- `configureCmd` now calls `tui.Run()` instead of `runInteractiveConfig()`
- Removed all old functions: `runInteractiveConfig`, `maskAPIKey`, `formatBackends`, old `saveConfig`
- New `saveConfig()` writes proper TOML with new structure:
- `keywords = [...]` at top (before any tables)
- `[providers.X]` sections with `api_key`
- `[llm]` with `[llm.post_processing]` and `[llm.custom_prompt]` subsections
- No more `transcription.api_key` in saved configs
- Added `showNextSteps()` helper for post-save instructions
- Added `runConfigure()` that wraps TUI flow with validation and save
Key decisions:
- Keywords written before any TOML table definitions (TOML requirement)
- Config saved only if user confirms in TUI summary
- Validation runs before save, errors displayed cleanly
- Next steps shown after successful save
## Task 10: Update default config template - COMPLETE
Updated SaveDefaultConfig() in internal/config/config.go:
- Added `keywords = []` at top level (before any TOML tables)
- Added `[providers.openai]` and `[providers.groq]` sections with api_key
- Added `[llm]` section with enabled = true, provider = "openai", model = "gpt-4o-mini"
- Added `[llm.post_processing]` with all 4 options = true
- Added `[llm.custom_prompt]` with enabled = false
- Added MIGRATION NOTE in header about old format upgrade
- Reorganized with clear section headers (box-drawing chars)
- Removed `transcription.api_key` from default (uses providers map now)
- Simplified and consolidated reference docs at bottom
Key decisions:
- LLM enabled by default with OpenAI gpt-4o-mini (best cost/quality)
- Providers section at top for visibility
- Keywords before any table definitions (TOML syntax requirement)
- Concise comments, full reference at bottom
## Task 11: Add LLM processing notification - COMPLETE
Added notification when LLM post-processing starts:
- Added `MsgLLMProcessing` to `notify/message.go` (default: "Hyprvoice", "Processing...")
- Added `LLMProcessing` field to `MessagesConfig` in config.go (toml: `llm_processing`)
- Added notification channel to pipeline (`GetNotifyCh()` method)
- Pipeline sends `MsgLLMProcessing` when entering Processing status
- Daemon monitors `notifyCh` via `monitorPipelineNotifications` goroutine
- Updated default config template with `llm_processing` message example
- Fixed tests: MockPipeline implements `GetNotifyCh`, notify test expects 7 MessageDefs
Key decisions:
- Notification channel approach (vs direct notifier access) keeps pipeline decoupled
- Notification sent at same time status changes to Processing
- Configurable like all other notifications via `[notifications.messages.llm_processing]`
## Task 12: Update README documentation - COMPLETE
Updated README.md with comprehensive LLM post-processing documentation:
- Added LLM feature to Features list at top
- Added "Unified Provider System" section with API key configuration examples
- Added "LLM Post-Processing" section with full configuration guide
- Added post-processing options documentation (remove_stutters, add_punctuation, etc.)
- Added custom prompt documentation with use cases
- Added "Keywords" section explaining how they help transcription + LLM
- Added 4 example configurations: fast transcription only, high quality, budget-friendly, mixed providers
- Added "Migration from Old Config Format" section with before/after examples
- Updated Development Status table: added Mistral, ElevenLabs, LLM post-processing, TUI setup
- Updated architecture diagrams to show processing state
- Updated state machine description: idle → recording → transcribing → processing → injecting
- Updated project structure to include new packages (config, llm, provider, tui)
- Added llm_processing to custom notification messages example
Key decisions:
- Put Unified Provider System before Transcription Providers (sets context)
- LLM section after transcription providers (logical flow)
- Example configs ordered by use case (fast → quality → budget → mixed)
- Migration section shows both old and new format side by side
## Task 13: End-to-end testing - COMPLETE
Verified all functionality through unit tests and code review:
**Automated verification (all pass):**
- Old config backward compatibility: TestConfig_MigrateTranscriptionAPIKey
- New config format: TestConfig_NewStyleConfig
- LLM config and validation: TestConfig_LLMConfig, TestConfig_LLMValidation
- LLM defaults applied: TestConfig_LLMDefaults, TestConfig_LLMDefaultsPreserveExplicit
- Keywords in config: TestConfig_LLMConfig (keywords passed to ToLLMConfig)
- Keywords in transcription: adapter_openai.go:48, adapter_groq_transcription.go:51 use keywords in Prompt
- Post-processing options: TestConfig_LLMConfig verifies all 4 options
- Custom prompt: TestConfig_LLMConfig verifies custom prompt config
- LLM disable: TestConfig_LLMValidation "LLM disabled skips validation"
- Config hot-reload: config/manager.go watches file changes, debounces, reloads
- Provider system: provider_test.go covers all providers
**Build and test results:**
- `go build ./...` - passes
- `go test ./...` - all tests pass (100+ tests across 11 packages)
**TUI implementation verified by code review:**
- Fresh install flow: runFreshInstall() walks through all steps
- Edit existing flow: runEditExisting() with section picker
- Smart provider detection: ensureProviderConfigured() prompts for API key when needed
- Configured providers show "(configured)" label
- Unconfigured show "(needs API key)" label
- Full setup option available in edit flow
**Items requiring manual verification with real API keys:**
- LLM actually improves text quality (needs live API call)
- TUI is intuitive (requires interactive terminal session)
Key decisions:
- TUI testing can't be automated without heavy mocking (charmbracelet forms are interactive)
- LLM quality testing needs real API keys for actual API calls
- All code paths are covered by unit tests, only integration layer needs manual verification
-266
View File
@@ -1,266 +0,0 @@
{
"project": "hyprvoice LLM Post-Processing",
"description": "Add LLM post-processing phase with unified provider system, TUI configure command, and configurable cleanup options",
"issue": "https://github.com/LeonardoTrapani/hyprvoice/issues/4",
"tasks": [
{
"title": "Create Provider interface and registry",
"steps": [
"Create new package internal/provider",
"Define Provider interface: Name(), RequiresAPIKey(), ValidateAPIKey(key), SupportsTranscription(), SupportsLLM(), DefaultTranscriptionModel(), DefaultLLMModel(), TranscriptionModels(), LLMModels()",
"Create ProviderConfig struct with APIKey field",
"Implement OpenAIProvider: transcription (whisper-1) + LLM (gpt-4o-mini)",
"Implement GroqProvider: transcription (whisper-large-v3, turbo) + LLM (llama-3.3-70b-versatile)",
"Implement MistralProvider: transcription only (voxtral-mini-latest)",
"Implement ElevenLabsProvider: transcription only (scribe_v1, scribe_v2)",
"Create GetProvider(name) and ListProviders() functions",
"Create ListProvidersWithLLM() and ListProvidersWithTranscription() helpers"
],
"verify": [
"All providers implement the interface",
"GetProvider returns correct provider for each name",
"Capability methods return correct values",
"Typecheck passes"
],
"passes": true
},
{
"title": "Refactor config to unified provider structure",
"steps": [
"Add Providers map[string]ProviderConfig to Config struct",
"Add global Keywords []string field to Config",
"Remove api_key from TranscriptionConfig, keep provider/model/language",
"Add LLMConfig with Enabled (default true), Provider, Model",
"Add LLMPostProcessingConfig: RemoveStutters, AddPunctuation, FixGrammar, RemoveFillerWords (all default true)",
"Add LLMCustomPromptConfig: Enabled, Prompt",
"Add migration in Load() to detect old format (transcription.api_key) and convert to providers map",
"Migration logs: 'Config migrated. Run hyprvoice configure to update format.'",
"Update ToTranscriberConfig() to resolve API key from Providers",
"Add ToLLMConfig() method",
"Environment variables still work as fallback"
],
"verify": [
"Old config with transcription.api_key still loads (backward compatible)",
"New config with [providers.openai] works",
"Environment variable fallback works",
"Migration logs warning",
"Typecheck passes",
"go test ./internal/config/... passes"
],
"passes": true
},
{
"title": "Create LLM adapter interface and implementations",
"steps": [
"Create internal/llm package",
"Define LLMAdapter interface: Process(ctx, text) (string, error)",
"Define Config struct with all options",
"Create prompt.go with BuildSystemPrompt(opts, keywords) and BuildUserPrompt(text, customPrompt)",
"Implement OpenAIAdapter using chat completions API",
"Implement GroqAdapter using Groq API (OpenAI-compatible)",
"Create NewAdapter(config) factory function"
],
"verify": [
"Both adapters implement interface",
"Prompt builder generates correct prompts",
"Factory returns correct adapter",
"Typecheck passes"
],
"passes": true
},
{
"title": "Integrate LLM phase into pipeline",
"steps": [
"Update internal/pipeline/pipeline.go",
"After transcription, check if LLM enabled",
"If enabled, create adapter and process text",
"Use processed text for injection",
"On failure, fall back to raw text with warning",
"Add LLM processing notification"
],
"verify": [
"Pipeline unchanged when LLM disabled",
"LLM processes text when enabled",
"Graceful fallback on LLM error",
"Typecheck passes"
],
"passes": true
},
{
"title": "Pass keywords to transcription adapters",
"steps": [
"Add Keywords []string to transcriber.Config",
"Update ToTranscriberConfig() to pass keywords",
"OpenAI transcriber uses keywords in initial_prompt",
"Groq transcriber uses keywords in prompt parameter",
"Other transcribers ignore if unsupported"
],
"verify": [
"Keywords passed to transcription",
"OpenAI includes in request",
"Non-supporting transcribers still work",
"Typecheck passes"
],
"passes": true
},
{
"title": "Add TUI dependencies and base components",
"steps": [
"Run: go get github.com/charmbracelet/bubbletea github.com/charmbracelet/lipgloss github.com/charmbracelet/huh",
"Create internal/tui package",
"Create styles.go with lipgloss styles: header, label, success, error, muted, highlight, selected",
"Create theme.go with color scheme matching hyprvoice branding"
],
"verify": [
"Dependencies in go.mod",
"Styles render in terminal",
"Typecheck passes"
],
"passes": true
},
{
"title": "Create TUI configure - fresh install flow",
"steps": [
"Create internal/tui/configure.go with Run() function",
"Welcome screen with hyprvoice ASCII/branding",
"Provider selection: multi-select which to configure (OpenAI, Groq, Mistral, ElevenLabs)",
"For each selected: API key input with password mask",
"Transcription: provider dropdown (only configured + supports transcription), model dropdown",
"LLM: 'Enable post-processing? (Recommended)' - defaults YES",
"If LLM yes: provider (only configured + supports LLM), model, post-processing toggles (all default true), custom prompt",
"Keywords: comma-separated input",
"Injection: backend multi-select with descriptions",
"Notifications: enable toggle",
"Summary screen with confirm"
],
"verify": [
"Fresh install walks through all steps",
"Only shows providers user selected for API keys",
"Transcription only shows configured + capable providers",
"LLM defaults to enabled, Yes is recommended",
"Typecheck passes"
],
"passes": true
},
{
"title": "Create TUI configure - edit existing flow",
"steps": [
"Detect if config exists and has user changes",
"Show section picker: 'What to configure?' multi-select",
"Sections: Providers, Transcription, LLM, Keywords, Injection, Notifications, Full Setup",
"For each section, show only that form",
"Smart provider detection: if user picks unconfigured provider, prompt for API key",
"If provider already configured, show 'Using existing key' (no re-prompt unless in Providers section)",
"Merge with existing config, preserve unedited sections"
],
"verify": [
"Existing config shows section picker",
"Single section only edits that section",
"Unconfigured provider triggers key prompt",
"Configured providers don't re-prompt",
"Unedited sections preserved",
"Typecheck passes"
],
"passes": true
},
{
"title": "Replace old configure with TUI",
"steps": [
"Update cmd/hyprvoice/main.go configureCmd to call tui.Run()",
"Remove runInteractiveConfig and related helpers (maskAPIKey, formatBackends, etc.)",
"Update saveConfig to write new TOML structure with [providers.X]",
"Ensure validation before save",
"Show next steps after successful save"
],
"verify": [
"hyprvoice configure launches TUI",
"Old code removed",
"Saved config valid TOML with new structure",
"Typecheck passes"
],
"passes": true
},
{
"title": "Update default config template",
"steps": [
"Update SaveDefaultConfig() in config.go",
"Add [providers.openai] and [providers.groq] sections",
"Add keywords = [] global",
"Add [llm] with enabled = true, provider, model",
"Add [llm.post_processing] all true",
"Add [llm.custom_prompt] enabled = false",
"Clear comments explaining structure",
"Add migration note about old format"
],
"verify": [
"Default config valid TOML",
"LLM enabled by default",
"Post-processing all true by default",
"Comments clear",
"Typecheck passes"
],
"passes": true
},
{
"title": "Add LLM processing notification",
"steps": [
"Add MsgLLMProcessing to notify types",
"Default: title='Hyprvoice', body='Processing...'",
"Add to MessagesConfig",
"Trigger when LLM starts",
"Make configurable"
],
"verify": [
"Type defined",
"Notification appears",
"Configurable in config",
"Typecheck passes"
],
"passes": true
},
{
"title": "Update README documentation",
"steps": [
"Add '## LLM Post-Processing' section",
"Document unified provider structure with examples",
"Document post-processing options",
"Document custom prompt with use cases",
"Document keywords (helps transcription + LLM)",
"Example configs for common setups",
"Document migration from old format",
"Note LLM enabled by default"
],
"verify": [
"README clear",
"Examples valid TOML",
"Migration documented",
"Keywords explained"
],
"passes": true
},
{
"title": "End-to-end testing",
"steps": [
"Test old config loads (backward compatible)",
"Test new config works",
"Test LLM enabled by default improves output",
"Test LLM can be disabled",
"Test each post-processing option",
"Test custom prompt",
"Test keywords in transcription",
"Test TUI fresh install flow",
"Test TUI edit existing flow",
"Test smart provider detection",
"Test config hot-reload"
],
"verify": [
"Old configs work unchanged",
"New configs work",
"LLM improves text quality",
"TUI flows intuitive and state-aware",
"No regressions"
],
"passes": true
}
]
}