From 0025bf97b6ca6cbd0cb040c93d085b2ced9b02f1 Mon Sep 17 00:00:00 2001 From: leonardotrapani Date: Sun, 1 Feb 2026 17:53:48 +0100 Subject: [PATCH] feat: fanalize straeming adapters --- AGENTS.md | 14 ++ README.md | 83 +++---- docs/config.md | 160 ++++--------- docs/structure.md | 59 +++++ internal/config/config_test.go | 221 ++---------------- internal/config/convert.go | 8 +- internal/config/load.go | 9 - internal/config/save.go | 17 +- internal/config/types.go | 2 +- internal/config/validate.go | 3 - internal/transcriber/adapter_deepgram.go | 75 +++++- .../adapter_elevenlabs_streaming.go | 64 ++++- .../transcriber/adapter_openai_realtime.go | 75 +++++- internal/transcriber/streaming.go | 5 + internal/transcriber/streaming_transcriber.go | 51 +++- internal/transcriber/transcriber_test.go | 8 + internal/tui/configure.go | 7 - internal/tui/configure_helpers.go | 12 +- internal/tui/configure_language.go | 85 ------- internal/tui/configure_transcription.go | 137 ++++------- internal/tui/configure_transcription_test.go | 10 +- internal/tui/configure_wizard.go | 7 +- internal/tui/languages.go | 21 +- 23 files changed, 489 insertions(+), 644 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/structure.md delete mode 100644 internal/tui/configure_language.go diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9c8cb20 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,14 @@ +# AGENTS.md + +This repo is a Go CLI + daemon for voice-powered typing on Wayland/Hyprland. + +## Build and run +- go mod download +- go build -o hyprvoice ./cmd/hyprvoice +- go run ./cmd/hyprvoice + +## Where to look +- docs/structure.md: architecture and code map +- docs/config.md: config reference and paths +- docs/providers.md: provider and model details +- packaging/RELEASE.md: release and AUR workflow diff --git a/README.md b/README.md index 34ae952..1fb4829 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Press a toggle key, speak, and get instant text input. Built natively for Waylan ## Features - **Toggle workflow**: Press once to start recording, press again to stop and inject text +- **Interactive configuration**: User-friendly TUI wizard - no manual config file editing required - **LLM post-processing**: Automatically cleans up transcriptions - removes stutters, fixes grammar, adds punctuation (enabled by default) - **Wayland native**: Purpose-built for Wayland compositors - no legacy X11 dependencies or hacky workarounds - **Real-time feedback**: Desktop notifications for recording states and transcription status @@ -129,6 +130,28 @@ hyprvoice toggle hyprvoice toggle # Stop and transcribe ``` +## Configuration + +The recommended way to configure hyprvoice is through the interactive wizard: + +```bash +hyprvoice configure +``` + +The wizard guides you through all settings with a user-friendly interface: + +- **Providers** - API keys for OpenAI, Groq, Mistral, ElevenLabs, Deepgram +- **Transcription** - Speech-to-text provider, model, and language selection (cloud or local) +- **LLM** - Post-processing to clean up transcriptions (enabled by default) +- **Keywords** - Domain-specific terms for better accuracy +- **Injection** - How text is typed (ydotool, wtype, clipboard) +- **Notifications** - Desktop notification preferences +- **Advanced Settings** - Recording parameters, timeouts + +Configuration is stored in `~/.config/hyprvoice/config.toml`. Changes are applied immediately without restarting the daemon. + +For manual configuration and detailed options, see [docs/config.md](docs/config.md). + ## Quick Reference ### Common Commands @@ -227,29 +250,6 @@ hyprvoice toggle hyprvoice status ``` -## Configuration - -The recommended way to configure hyprvoice is through the interactive wizard: - -```bash -hyprvoice configure -``` - -The wizard guides you through all settings with a user-friendly interface: - -- **Providers** - API keys for OpenAI, Groq, Mistral, ElevenLabs, Deepgram -- **Language** - Global language setting for all transcription (57 languages + auto-detect) -- **Transcription** - Speech-to-text provider and model selection (cloud or local) -- **LLM** - Post-processing to clean up transcriptions (enabled by default) -- **Keywords** - Domain-specific terms for better accuracy -- **Injection** - How text is typed (ydotool, wtype, clipboard) -- **Notifications** - Desktop notification preferences -- **Advanced Settings** - Recording parameters, timeouts - -Configuration is stored in `~/.config/hyprvoice/config.toml`. Changes are applied immediately without restarting the daemon. - -For manual configuration and detailed options, see [docs/config.md](docs/config.md). - ## Local Transcription For complete offline privacy, use whisper.cpp for local transcription - no API keys, no cloud, no data leaves your machine. @@ -297,17 +297,7 @@ For complete offline privacy, use whisper.cpp for local transcription - no API k **Recommendation**: Start with `base.en` for English or `base` for multilingual. Models ending in `.en` are English-only but slightly faster. -### Configuration - -```toml -[general] -language = "" # empty for auto-detect, or "en", "es", etc. - -[transcription] -provider = "whisper-cpp" -model = "base.en" # or "base" for multilingual -threads = 0 # 0 = auto (NumCPU - 1) -``` +Run `hyprvoice configure` to set up local transcription, or see [docs/config.md](docs/config.md) for manual configuration. ## Streaming Transcription @@ -321,30 +311,9 @@ For real-time transcription results as you speak, use streaming providers. Text | Deepgram | nova-3, nova-2 | ~100ms | 40+ langs | | OpenAI | gpt-4o-realtime-preview | ~200ms | 57 langs | -### Configuration +Streaming models show partial results while recording. Final text is accumulated and injected when you toggle off. -```toml -[general] -language = "" # empty for auto-detect - -# ElevenLabs streaming -[providers.elevenlabs] -api_key = "..." - -[transcription] -provider = "elevenlabs" -model = "scribe_v2-streaming" - -# Deepgram streaming -[providers.deepgram] -api_key = "..." - -[transcription] -provider = "deepgram" -model = "nova-3" -``` - -**Note**: Streaming models show partial results while recording. Final text is accumulated and injected when you toggle off. +Run `hyprvoice configure` to set up streaming, or see [docs/config.md](docs/config.md) for manual configuration. ### Service Management diff --git a/docs/config.md b/docs/config.md index 0d5cc8d..06a00e6 100644 --- a/docs/config.md +++ b/docs/config.md @@ -10,13 +10,12 @@ Configuration is stored in `~/.config/hyprvoice/config.toml` and changes are app ## Table of Contents -- [General Settings](#general-settings) - [Unified Provider System](#unified-provider-system) - [Transcription Providers](#transcription-providers) - [Cloud Providers](#cloud-providers) - [Local Transcription (whisper-cpp)](#local-transcription-whisper-cpp) - [Streaming Transcription](#streaming-transcription) -- [Language Configuration](#language-configuration) + - [Language Configuration](#language-configuration) - [Model Management](#model-management) - [LLM Post-Processing](#llm-post-processing) - [Keywords](#keywords) @@ -26,41 +25,6 @@ Configuration is stored in `~/.config/hyprvoice/config.toml` and changes are app - [Example Configurations](#example-configurations) - [Migration from Old Config Format](#migration-from-old-config-format) -## General Settings - -The `[general]` section contains application-wide settings: - -```toml -[general] -language = "" # ISO 639-1 code (e.g., "en", "es", "de"). Empty for auto-detect. -``` - -### Language - -The global language setting applies to all transcription providers: - -```toml -[general] -language = "" # Auto-detect (recommended) -# language = "en" # English -# language = "es" # Spanish -# language = "de" # German -``` - -**Override behavior:** You can override the global language for a specific transcription provider: - -```toml -[general] -language = "en" # Default to English - -[transcription] -# language = "es" # Uncomment to override for this provider only -``` - -When `transcription.language` is set, it takes precedence over `general.language`. This allows you to set a default language but override it for specific use cases. - -See [Language Configuration](#language-configuration) for the full list of supported languages and model compatibility. - ## Unified Provider System Hyprvoice uses a unified provider system where API keys are configured once and shared between transcription and LLM features: @@ -99,12 +63,10 @@ Hyprvoice supports multiple transcription backends. See [docs/providers.md](./pr Cloud-based transcription using OpenAI's Whisper API: ```toml -[general] -language = "" # Empty for auto-detect, or "en", "es", "fr", etc. - [transcription] provider = "openai" model = "whisper-1" +language = "" # Empty for auto-detect, or "en", "es", "fr", etc. ``` **Features:** @@ -118,12 +80,10 @@ model = "whisper-1" Fast cloud-based transcription using Groq's Whisper API: ```toml -[general] -language = "" # Empty for auto-detect, or "en", "es", "fr", etc. - [transcription] provider = "groq-transcription" model = "whisper-large-v3" # Or "whisper-large-v3-turbo" for faster processing +language = "" # Empty for auto-detect, or "en", "es", "fr", etc. ``` **Features:** @@ -156,12 +116,10 @@ model = "whisper-large-v3" Transcription using Mistral's Voxtral API, excellent for European languages: ```toml -[general] -language = "" # Empty for auto-detect - [transcription] provider = "mistral-transcription" model = "voxtral-mini-latest" # Or "voxtral-mini-2507" +language = "" # Empty for auto-detect ``` ### ElevenLabs Scribe @@ -169,12 +127,10 @@ model = "voxtral-mini-latest" # Or "voxtral-mini-2507" Transcription using ElevenLabs' Scribe API with 57+ language support: ```toml -[general] -language = "" # Empty for auto-detect - [transcription] provider = "elevenlabs" model = "scribe_v1" # Or "scribe_v2" for lower latency +language = "" # Empty for auto-detect ``` **Features:** @@ -188,15 +144,13 @@ model = "scribe_v1" # Or "scribe_v2" for lower latency Fast streaming transcription using Deepgram's Nova models: ```toml -[general] -language = "" # Empty for auto-detect - [providers.deepgram] api_key = "..." # Or set DEEPGRAM_API_KEY env var [transcription] provider = "deepgram" model = "nova-3" # Or "nova-2" for different language support +language = "" # Empty for auto-detect ``` **Features:** @@ -216,12 +170,10 @@ Run Whisper models locally on your machine. No API keys, no network latency, com 2. Download a model: `hyprvoice model download base.en` ```toml -[general] -language = "" # Empty for auto-detect - [transcription] provider = "whisper-cpp" model = "base.en" # English-only model (fastest) +language = "" # Empty for auto-detect (use "en" for English-only models) threads = 0 # 0 = auto (uses NumCPU - 1) ``` @@ -276,36 +228,27 @@ model = "gpt-4o-realtime-preview" | Deepgram | `nova-2` | Very Low | 33 | | OpenAI | `gpt-4o-realtime-preview` | Low | 57 | -## Language Configuration +### Language Configuration -Configure the expected spoken language for better accuracy. Language is set globally in `[general]`: +Language is configured per transcription model in the `[transcription]` section: ```toml -[general] +[transcription] +provider = "openai" +model = "whisper-1" language = "" # Empty for auto-detect (recommended) -# Or specify a language code: # language = "en" # English # language = "es" # Spanish # language = "fr" # French -# language = "zh" # Chinese -# language = "ja" # Japanese ``` -**Override per-provider:** If you need different languages for different setups: - -```toml -[general] -language = "en" # Global default - -[transcription] -# language = "es" # Uncomment to override for transcription only -``` +When using `hyprvoice configure`, you select the language after choosing the model. Only languages supported by the selected model are shown. **Recommendations:** - Use auto-detect (`language = ""`) for most cases - it works well - Specify a language if you always speak the same language (slight accuracy boost) -- Required for English-only models if you speak English +- English-only models (e.g., `base.en`) only support `language = "en"` or auto-detect ### Supported Languages @@ -315,7 +258,7 @@ Afrikaans (af), Arabic (ar), Armenian (hy), Azerbaijani (az), Belarusian (be), B ### Language-Model Compatibility -Some models only support English. Hyprvoice validates compatibility: +Some models only support English. When configuring via `hyprvoice configure`, only supported languages are shown for selection. **English-only models:** @@ -328,17 +271,15 @@ Some models only support English. Hyprvoice validates compatibility: **Validation behavior:** -1. **At config time (TUI/validation):** Selecting an English-only model with a non-English language shows an error and prevents saving -2. **At runtime (safety net):** If config was manually edited to an invalid combination, hyprvoice logs a warning, sends a desktop notification, and falls back to auto-detect +1. **At config time (TUI):** Only languages supported by the selected model are shown +2. **At runtime:** If config was manually edited to an invalid combination, hyprvoice logs a warning, sends a desktop notification, and falls back to auto-detect ```toml -# This combination will be rejected: -[general] -language = "es" # Error: model does not support Spanish - +# This combination will be rejected at validation: [transcription] provider = "groq-transcription" model = "distil-whisper-large-v3-en" # English only! +language = "es" # Error: model does not support Spanish ``` ## Model Management @@ -585,15 +526,13 @@ You can customize notification text via the `[notifications.messages]` section: ### Fast Transcription Only (No LLM) ```toml -[general] -language = "" # Auto-detect - [providers.groq] api_key = "gsk_..." [transcription] provider = "groq-transcription" model = "whisper-large-v3-turbo" + language = "" # Auto-detect [llm] enabled = false @@ -602,15 +541,13 @@ language = "" # Auto-detect ### High Quality with OpenAI (Default) ```toml -[general] -language = "" # Auto-detect - [providers.openai] api_key = "sk-..." [transcription] provider = "openai" model = "whisper-1" + language = "" # Auto-detect [llm] enabled = true @@ -621,15 +558,13 @@ language = "" # Auto-detect ### Budget-Friendly with Groq ```toml -[general] -language = "" # Auto-detect - [providers.groq] api_key = "gsk_..." [transcription] provider = "groq-transcription" model = "whisper-large-v3-turbo" + language = "" # Auto-detect [llm] enabled = true @@ -640,9 +575,6 @@ language = "" # Auto-detect ### Mixed Providers (Groq Transcription + OpenAI LLM) ```toml -[general] -language = "" # Auto-detect - [providers.openai] api_key = "sk-..." @@ -652,6 +584,7 @@ language = "" # Auto-detect [transcription] provider = "groq-transcription" model = "whisper-large-v3-turbo" + language = "" # Auto-detect [llm] enabled = true @@ -664,12 +597,10 @@ language = "" # Auto-detect ```toml # No API keys needed! -[general] -language = "" # Auto-detect - [transcription] provider = "whisper-cpp" model = "base.en" + language = "" # Auto-detect threads = 0 # Auto-detect (NumCPU - 1) [llm] @@ -679,15 +610,13 @@ language = "" # Auto-detect ### Real-Time Streaming with Deepgram ```toml -[general] -language = "" # Auto-detect - [providers.deepgram] api_key = "..." [transcription] provider = "deepgram" model = "nova-3" # All Deepgram models are streaming + language = "" # Auto-detect [llm] enabled = false # Streaming doesn't need LLM post-processing @@ -696,32 +625,28 @@ language = "" # Auto-detect ### Ultra-Low Latency Streaming ```toml -[general] -language = "" # Auto-detect - [providers.elevenlabs] api_key = "..." [transcription] provider = "elevenlabs" model = "scribe_v2-streaming" # <150ms latency + language = "" # Auto-detect [llm] enabled = false ``` -### Multilingual Setup with Specific Language +### Specific Language Setup ```toml -[general] -language = "es" # Always transcribe as Spanish - [providers.openai] api_key = "sk-..." [transcription] provider = "openai" model = "whisper-1" + language = "es" # Always transcribe as Spanish [llm] enabled = true @@ -731,32 +656,31 @@ language = "es" # Always transcribe as Spanish ## Migration from Old Config Format -### Language Migration +### Language Configuration Change -If you have `transcription.language` set in your config, it will continue to work but is now an override. The recommended approach is to move it to `[general]`: +Language is now configured per transcription model in `[transcription].language`. If you had `[general].language` set, move it to the transcription section: -**Old format (still works as override):** - -```toml -[transcription] - provider = "openai" - language = "en" # Works but is now an override - model = "whisper-1" -``` - -**New format (recommended):** +**Old format:** ```toml [general] - language = "en" # Global setting + language = "en" [transcription] provider = "openai" model = "whisper-1" - # language = "es" # Only set here to override [general] ``` -When loading, if `transcription.language` is set but `general.language` is not, the language is automatically migrated to the general section. Run `hyprvoice configure` and save to persist this change. +**New format:** + +```toml +[transcription] + provider = "openai" + model = "whisper-1" + language = "en" +``` + +Run `hyprvoice configure` to interactively update your config. ### API Key Migration diff --git a/docs/structure.md b/docs/structure.md new file mode 100644 index 0000000..ec83648 --- /dev/null +++ b/docs/structure.md @@ -0,0 +1,59 @@ +# Code Structure + +This doc explains how the CLI, daemon, and pipeline fit together and where to start reading the code. + +## Top-level layout +- cmd/hyprvoice: CLI entrypoint and commands +- internal/: core packages +- docs/: user and developer docs +- packaging/: AUR and systemd packaging +- .github/workflows/: CI and release workflows + +## Control flow (high level) +1. CLI command sends a single-character IPC command over a unix socket. +2. Daemon receives the command and owns lifecycle and state transitions. +3. Pipeline runs: recording -> transcribing -> processing -> injecting. +4. Notifications reflect state changes and errors. + +State machine: idle -> recording -> transcribing -> processing -> injecting -> idle + +## Key packages +- internal/bus: unix socket IPC, pid file, and client helpers +- internal/daemon: command handling, lifecycle, pipeline ownership +- internal/config: load/save/validate config and hot reload +- internal/pipeline: state machine coordinating recording/transcriber/llm/injection +- internal/recording: PipeWire audio capture +- internal/transcriber: batch and streaming provider adapters +- internal/llm: post-processing adapters and prompts +- internal/injection: wtype/ydotool/clipboard injection +- internal/notify: desktop notifications +- internal/provider: provider registry and model metadata +- internal/models/whisper: local whisper model registry and downloads +- internal/language: language metadata and compatibility rules +- internal/deps: dependency detection (ffmpeg, whisper-cli, etc.) +- internal/tui: interactive configuration wizard +- internal/testutil: shared test helpers + +## Entry points and key files +- cmd/hyprvoice/main.go: CLI entrypoint and command wiring +- internal/daemon/daemon.go: daemon lifecycle and command handling +- internal/config/manager.go: config manager and hot reload +- internal/pipeline/: pipeline orchestration and state machine +- internal/recording/: audio capture implementation +- internal/transcriber/: provider-specific adapters + +## IPC protocol (daemon control) +- Socket: ~/.cache/hyprvoice/control.sock +- Commands: t=toggle, c=cancel, s=status, v=version, q=quit + +## Data and config locations +- Config: ~/.config/hyprvoice/config.toml +- Models: ~/.local/share/hyprvoice/models/whisper/ +- PID file: ~/.cache/hyprvoice/hyprvoice.pid + +## Suggested reading order +1. cmd/hyprvoice/main.go for CLI command flow. +2. internal/daemon/daemon.go for lifecycle and IPC handling. +3. internal/pipeline for state transitions and orchestration. +4. internal/recording and internal/transcriber for audio and STT. +5. internal/llm and internal/injection for text cleanup and output. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 60ac8cc..e981436 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1984,16 +1984,13 @@ func TestConfig_ToTranscriberConfig_Threads(t *testing.T) { } } -func TestConfig_EffectiveLanguage(t *testing.T) { - t.Run("only general.language set", func(t *testing.T) { +func TestConfig_TranscriptionLanguage(t *testing.T) { + t.Run("language set in transcription", func(t *testing.T) { config := &Config{ - General: GeneralConfig{ - Language: "es", - }, Transcription: TranscriptionConfig{ Provider: "openai", Model: "whisper-1", - Language: "", // not set + Language: "es", }, } @@ -2003,29 +2000,8 @@ func TestConfig_EffectiveLanguage(t *testing.T) { } }) - t.Run("transcription.language overrides general.language", func(t *testing.T) { + t.Run("empty language results in auto-detect", func(t *testing.T) { config := &Config{ - General: GeneralConfig{ - Language: "es", - }, - Transcription: TranscriptionConfig{ - Provider: "openai", - Model: "whisper-1", - Language: "en", // overrides general - }, - } - - transcriberConfig := config.ToTranscriberConfig() - if transcriberConfig.Language != "en" { - t.Errorf("Language = %q, want %q", transcriberConfig.Language, "en") - } - }) - - t.Run("neither set results in auto", func(t *testing.T) { - config := &Config{ - General: GeneralConfig{ - Language: "", - }, Transcription: TranscriptionConfig{ Provider: "openai", Model: "whisper-1", @@ -2040,7 +2016,7 @@ func TestConfig_EffectiveLanguage(t *testing.T) { }) } -func TestConfig_Validate_GeneralLanguage(t *testing.T) { +func TestConfig_Validate_TranscriptionLanguage(t *testing.T) { baseConfig := func() *Config { return &Config{ Recording: RecordingConfig{ @@ -2066,63 +2042,46 @@ func TestConfig_Validate_GeneralLanguage(t *testing.T) { } } - t.Run("valid general.language passes validation", func(t *testing.T) { + t.Run("valid transcription.language passes validation", func(t *testing.T) { config := baseConfig() - config.General.Language = "es" + config.Transcription.Language = "es" err := config.Validate() if err != nil { - t.Errorf("Validate() should pass with valid general.language: %v", err) + t.Errorf("Validate() should pass with valid transcription.language: %v", err) } }) - t.Run("general.language validated against model", func(t *testing.T) { + t.Run("transcription.language validated against model", func(t *testing.T) { config := baseConfig() - config.General.Language = "es" + config.Transcription.Language = "es" // incompatible config.Transcription.Provider = "whisper-cpp" config.Transcription.Model = "base.en" // english-only model err := config.Validate() if err == nil { - t.Error("Validate() should fail when general.language incompatible with model") + t.Error("Validate() should fail when transcription.language incompatible with model") } if err != nil && !strings.Contains(err.Error(), "does not support Spanish") { t.Errorf("error should mention Spanish, got: %v", err) } }) - t.Run("transcription.language override validated against model", func(t *testing.T) { + t.Run("compatible language passes", func(t *testing.T) { config := baseConfig() - config.General.Language = "en" // compatible - config.Transcription.Language = "es" // override with incompatible - config.Transcription.Provider = "whisper-cpp" - config.Transcription.Model = "base.en" // english-only model - - err := config.Validate() - if err == nil { - t.Error("Validate() should fail when transcription.language override is incompatible") - } - if err != nil && !strings.Contains(err.Error(), "does not support Spanish") { - t.Errorf("error should mention Spanish, got: %v", err) - } - }) - - t.Run("valid override with compatible language", func(t *testing.T) { - config := baseConfig() - config.General.Language = "es" // would be incompatible - config.Transcription.Language = "en" // override with compatible + config.Transcription.Language = "en" // compatible config.Transcription.Provider = "whisper-cpp" config.Transcription.Model = "base.en" // english-only model err := config.Validate() if err != nil { - t.Errorf("Validate() should pass when transcription.language override is compatible: %v", err) + t.Errorf("Validate() should pass when transcription.language is compatible: %v", err) } }) t.Run("auto language always passes", func(t *testing.T) { config := baseConfig() - config.General.Language = "" // auto + config.Transcription.Language = "" // auto config.Transcription.Provider = "whisper-cpp" config.Transcription.Model = "base.en" // english-only model @@ -2133,8 +2092,8 @@ func TestConfig_Validate_GeneralLanguage(t *testing.T) { }) } -func TestConfig_MigrateLanguageToGeneral(t *testing.T) { - t.Run("old config with transcription.language migrates to general.language", func(t *testing.T) { +func TestConfig_LoadWithTranscriptionLanguage(t *testing.T) { + t.Run("config with transcription.language loads correctly", func(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "hyprvoice", "config.toml") @@ -2143,77 +2102,7 @@ func TestConfig_MigrateLanguageToGeneral(t *testing.T) { t.Fatalf("Failed to create config directory: %v", err) } - // Old config with language in transcription section - oldConfig := `[recording] -sample_rate = 16000 -channels = 1 -format = "s16" -buffer_size = 8192 -channel_buffer_size = 30 -timeout = "5m" - -[transcription] -provider = "openai" -api_key = "test-key" -model = "whisper-1" -language = "es" - -[injection] -backends = ["clipboard"] -ydotool_timeout = "5s" -wtype_timeout = "5s" -clipboard_timeout = "3s" - -[notifications] -type = "log"` - - err = os.WriteFile(configPath, []byte(oldConfig), 0644) - if err != nil { - t.Fatalf("Failed to create config file: %v", err) - } - - originalConfigDir := os.Getenv("XDG_CONFIG_HOME") - os.Setenv("XDG_CONFIG_HOME", tempDir) - defer func() { - if originalConfigDir == "" { - os.Unsetenv("XDG_CONFIG_HOME") - } else { - os.Setenv("XDG_CONFIG_HOME", originalConfigDir) - } - }() - - config, err := Load() - if err != nil { - t.Errorf("Load() error = %v", err) - return - } - - // Should have migrated to general.language - if config.General.Language != "es" { - t.Errorf("Expected general.language='es' after migration, got %q", config.General.Language) - } - - // Effective language should be 'es' - transcriberConfig := config.ToTranscriberConfig() - if transcriberConfig.Language != "es" { - t.Errorf("Expected effective language 'es', got %q", transcriberConfig.Language) - } - }) - - t.Run("migration does not run when general.language already set", func(t *testing.T) { - tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "hyprvoice", "config.toml") - - err := os.MkdirAll(filepath.Dir(configPath), 0755) - if err != nil { - t.Fatalf("Failed to create config directory: %v", err) - } - - // Config with both general.language and transcription.language set - configContent := `[general] -language = "fr" - -[recording] + configContent := `[recording] sample_rate = 16000 channels = 1 format = "s16" @@ -2257,80 +2146,10 @@ type = "log"` return } - // general.language should remain 'fr', not overwritten by migration - if config.General.Language != "fr" { - t.Errorf("Expected general.language='fr' (not migrated), got %q", config.General.Language) - } - - // transcription.language should still override + // Effective language should be 'es' transcriberConfig := config.ToTranscriberConfig() if transcriberConfig.Language != "es" { - t.Errorf("Expected effective language 'es' (transcription override), got %q", transcriberConfig.Language) - } - }) - - t.Run("original file not modified until explicit save", func(t *testing.T) { - tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "hyprvoice", "config.toml") - - err := os.MkdirAll(filepath.Dir(configPath), 0755) - if err != nil { - t.Fatalf("Failed to create config directory: %v", err) - } - - oldConfig := `[recording] -sample_rate = 16000 -channels = 1 -format = "s16" -buffer_size = 8192 -channel_buffer_size = 30 -timeout = "5m" - -[transcription] -provider = "openai" -api_key = "test-key" -model = "whisper-1" -language = "de" - -[injection] -backends = ["clipboard"] -ydotool_timeout = "5s" -wtype_timeout = "5s" -clipboard_timeout = "3s" - -[notifications] -type = "log"` - - err = os.WriteFile(configPath, []byte(oldConfig), 0644) - if err != nil { - t.Fatalf("Failed to create config file: %v", err) - } - - originalConfigDir := os.Getenv("XDG_CONFIG_HOME") - os.Setenv("XDG_CONFIG_HOME", tempDir) - defer func() { - if originalConfigDir == "" { - os.Unsetenv("XDG_CONFIG_HOME") - } else { - os.Setenv("XDG_CONFIG_HOME", originalConfigDir) - } - }() - - _, err = Load() - if err != nil { - t.Errorf("Load() error = %v", err) - return - } - - // Read the file again - should still have old format - content, err := os.ReadFile(configPath) - if err != nil { - t.Fatalf("Failed to read config file: %v", err) - } - - // File should NOT have [general] section (migration is in-memory only) - if strings.Contains(string(content), "[general]") { - t.Error("Original file should not be modified by migration - [general] section found") + t.Errorf("Expected effective language 'es', got %q", transcriberConfig.Language) } }) } diff --git a/internal/config/convert.go b/internal/config/convert.go index cefbe48..f320a2b 100644 --- a/internal/config/convert.go +++ b/internal/config/convert.go @@ -36,13 +36,9 @@ func (c *Config) ToTranscriberConfig() transcriber.Config { return config } -// resolveEffectiveLanguage returns the effective language for transcription. -// transcription.language overrides general.language if set. +// resolveEffectiveLanguage returns the language for transcription func (c *Config) resolveEffectiveLanguage() string { - if c.Transcription.Language != "" { - return c.Transcription.Language - } - return c.General.Language + return c.Transcription.Language } // resolveAPIKeyForProvider returns the API key for a provider from multiple sources diff --git a/internal/config/load.go b/internal/config/load.go index 00e430e..0740a46 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -78,7 +78,6 @@ func Load() (*Config, error) { config.applyLLMDefaults() config.applyThreadsDefault() - config.migrateLanguageToGeneral() log.Printf("Config: configuration loaded successfully") return &config, nil @@ -133,14 +132,6 @@ func (c *Config) applyLLMDefaults() { } } -// migrateLanguageToGeneral migrates old transcription.language to general.language -func (c *Config) migrateLanguageToGeneral() { - if c.Transcription.Language != "" && c.General.Language == "" { - c.General.Language = c.Transcription.Language - log.Printf("Config: migrated language setting to [general] section") - } -} - // migrateInjectionMode converts old mode field to new backends array func (c *Config) migrateInjectionMode(mode string) { switch mode { diff --git a/internal/config/save.go b/internal/config/save.go index c620b2c..ca44c3f 100644 --- a/internal/config/save.go +++ b/internal/config/save.go @@ -41,13 +41,6 @@ func Save(cfg *Config) error { sb.WriteString("]\n\n") } - // General section - sb.WriteString(`# General Settings -[general] -`) - sb.WriteString(fmt.Sprintf(" language = %q\n", cfg.General.Language)) - sb.WriteString("\n") - // Providers section if len(cfg.Providers) > 0 { sb.WriteString("# API Keys for providers\n") @@ -210,13 +203,6 @@ func SaveDefaultConfig() error { # will be automatically migrated to the new [providers.X] format. Run 'hyprvoice configure' # to update your config file structure. -# ───────────────────────────────────────────────────────────────────────────── -# General Settings -# ───────────────────────────────────────────────────────────────────────────── - -[general] - language = "" # Language for transcription (ISO 639-1 code, e.g., en, es, de). Empty for auto-detect. - # Keywords help both transcription and LLM understand domain-specific terms # Add names, technical terms, or brand names that might be misheard keywords = [] @@ -262,8 +248,8 @@ keywords = [] [transcription] provider = "openai" # "openai", "groq-transcription", "groq-translation", "mistral-transcription", "elevenlabs", "whisper-cpp" model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1" + language = "" # ISO 639-1 code (e.g., en, es, de). Empty for auto-detect. threads = 0 # CPU threads for local transcription (0 = auto: uses NumCPU-1) - # language = "" # Override general.language for this provider only # ───────────────────────────────────────────────────────────────────────────── # LLM Post-Processing (Recommended) @@ -353,6 +339,7 @@ keywords = [] # - "clipboard": Copies to clipboard only (most reliable, requires manual paste). # # Language codes: "" (auto-detect), "en", "it", "es", "fr", "de", "pt", etc. +# Language is configured per transcription model - only supported languages are shown during setup. ` if _, err := file.WriteString(configContent); err != nil { diff --git a/internal/config/types.go b/internal/config/types.go index 2000501..8784425 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -9,7 +9,7 @@ import ( // GeneralConfig holds global settings that apply across the application type GeneralConfig struct { - Language string `toml:"language"` // ISO 639-1 code (e.g., en, es, de). Empty for auto-detect. + // reserved for future use } type Config struct { diff --git a/internal/config/validate.go b/internal/config/validate.go index cbe2482..7d423c6 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -86,9 +86,6 @@ func (c *Config) Validate() error { } // validate language codes - warn if not recognized but don't error - if c.General.Language != "" && !language.IsValidCode(c.General.Language) { - log.Printf("warning: unrecognized language code '%s' in general.language, will be passed as-is to provider", c.General.Language) - } if c.Transcription.Language != "" && !language.IsValidCode(c.Transcription.Language) { log.Printf("warning: unrecognized language code '%s' in transcription.language, will be passed as-is to provider", c.Transcription.Language) } diff --git a/internal/transcriber/adapter_deepgram.go b/internal/transcriber/adapter_deepgram.go index fda830d..216a5d4 100644 --- a/internal/transcriber/adapter_deepgram.go +++ b/internal/transcriber/adapter_deepgram.go @@ -32,6 +32,14 @@ type DeepgramAdapter struct { // reconnection config maxRetries int retryDelays []time.Duration + + // finalization signaling + finalizeDone chan struct{} +} + +// deepgramCloseStream message to signal end of audio +type deepgramCloseStream struct { + Type string `json:"type"` } // Deepgram WebSocket response types (incoming) @@ -77,13 +85,14 @@ type deepgramError struct { // lang: canonical language code (will be converted to provider format) func NewDeepgramAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *DeepgramAdapter { return &DeepgramAdapter{ - endpoint: endpoint, - apiKey: apiKey, - model: model, - language: lang, - resultsCh: make(chan TranscriptionResult, 100), - maxRetries: 3, - retryDelays: defaultRetryDelays, + endpoint: endpoint, + apiKey: apiKey, + model: model, + language: lang, + resultsCh: make(chan TranscriptionResult, 100), + maxRetries: 3, + retryDelays: defaultRetryDelays, + finalizeDone: make(chan struct{}, 1), } } @@ -294,6 +303,11 @@ func (a *DeepgramAdapter) readLoop() { isFinal := resp.IsFinal || resp.SpeechFinal if isFinal { log.Printf("deepgram: final: %q", transcript) + // signal finalization (non-blocking) + select { + case a.finalizeDone <- struct{}{}: + default: + } } a.resultsCh <- TranscriptionResult{Text: transcript, IsFinal: isFinal} } @@ -371,6 +385,53 @@ func (a *DeepgramAdapter) Results() <-chan TranscriptionResult { return a.resultsCh } +// Finalize sends a CloseStream message to signal end of audio and waits for final results +func (a *DeepgramAdapter) Finalize(ctx context.Context) error { + a.mu.Lock() + if !a.started { + a.mu.Unlock() + return nil + } + conn := a.conn + a.mu.Unlock() + + if conn == nil { + return nil + } + + // drain any previous finalize signals + select { + case <-a.finalizeDone: + default: + } + + // send CloseStream message + msg := deepgramCloseStream{Type: "CloseStream"} + + a.mu.Lock() + err := a.conn.WriteJSON(msg) + a.mu.Unlock() + + if err != nil { + log.Printf("deepgram: finalize write error: %v", err) + return fmt.Errorf("finalize write: %w", err) + } + + log.Printf("deepgram: sent CloseStream, waiting for final transcript") + + // wait for final result or timeout + select { + case <-a.finalizeDone: + log.Printf("deepgram: finalize complete") + return nil + case <-ctx.Done(): + log.Printf("deepgram: finalize timeout") + return ctx.Err() + case <-a.ctx.Done(): + return a.ctx.Err() + } +} + // Close gracefully closes the WebSocket connection func (a *DeepgramAdapter) Close() error { a.mu.Lock() diff --git a/internal/transcriber/adapter_elevenlabs_streaming.go b/internal/transcriber/adapter_elevenlabs_streaming.go index c13b901..36ede29 100644 --- a/internal/transcriber/adapter_elevenlabs_streaming.go +++ b/internal/transcriber/adapter_elevenlabs_streaming.go @@ -36,6 +36,9 @@ type ElevenLabsStreamingAdapter struct { // reconnection config maxRetries int retryDelays []time.Duration + + // finalization signaling + commitDone chan struct{} } // ElevenLabs WebSocket message types (outgoing) @@ -69,6 +72,7 @@ func NewElevenLabsStreamingAdapter(endpoint *provider.EndpointConfig, apiKey, mo resultsCh: make(chan TranscriptionResult, 100), maxRetries: 3, retryDelays: defaultRetryDelays, + commitDone: make(chan struct{}, 1), } } @@ -270,10 +274,15 @@ func (a *ElevenLabsStreamingAdapter) readLoop() { case "committed_transcript", "committed_transcript_with_timestamps": // final result + log.Printf("elevenlabs-streaming: committed: %q", msg.Text) if msg.Text != "" { - log.Printf("elevenlabs-streaming: committed: %q", msg.Text) a.resultsCh <- TranscriptionResult{Text: msg.Text, IsFinal: true} } + // signal finalization is done (non-blocking) + select { + case a.commitDone <- struct{}{}: + default: + } case "error", "auth_error", "quota_exceeded", "rate_limited", "queue_overflow", "resource_exhausted", "session_time_limit_exceeded", @@ -353,6 +362,59 @@ func (a *ElevenLabsStreamingAdapter) Results() <-chan TranscriptionResult { return a.resultsCh } +// Finalize sends a commit message to force ElevenLabs to commit any pending audio +// and waits for the committed_transcript response +func (a *ElevenLabsStreamingAdapter) Finalize(ctx context.Context) error { + a.mu.Lock() + if !a.started { + a.mu.Unlock() + return nil + } + conn := a.conn + a.mu.Unlock() + + if conn == nil { + return nil + } + + // drain any previous commit signals + select { + case <-a.commitDone: + default: + } + + // send empty audio chunk with commit=true to force finalization + msg := elevenLabsInputAudioChunk{ + MessageType: "input_audio_chunk", + AudioBase64: "", + Commit: true, + SampleRate: 16000, + } + + a.mu.Lock() + err := a.conn.WriteJSON(msg) + a.mu.Unlock() + + if err != nil { + log.Printf("elevenlabs-streaming: finalize write error: %v", err) + return fmt.Errorf("finalize write: %w", err) + } + + log.Printf("elevenlabs-streaming: sent commit, waiting for final transcript") + + // wait for committed_transcript or timeout + select { + case <-a.commitDone: + log.Printf("elevenlabs-streaming: finalize complete") + return nil + case <-ctx.Done(): + log.Printf("elevenlabs-streaming: finalize timeout") + return ctx.Err() + case <-a.ctx.Done(): + return a.ctx.Err() + } +} + // Close gracefully closes the WebSocket connection func (a *ElevenLabsStreamingAdapter) Close() error { a.mu.Lock() diff --git a/internal/transcriber/adapter_openai_realtime.go b/internal/transcriber/adapter_openai_realtime.go index 8de1b65..47122c2 100644 --- a/internal/transcriber/adapter_openai_realtime.go +++ b/internal/transcriber/adapter_openai_realtime.go @@ -35,6 +35,9 @@ type OpenAIRealtimeAdapter struct { // track current item for transcription currentItemID string + + // finalization signaling + transcriptionDone chan struct{} } // OpenAI Realtime WebSocket message types (outgoing) @@ -103,13 +106,14 @@ type openaiRealtimeError struct { // lang: canonical language code (will be used for transcription config) func NewOpenAIRealtimeAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *OpenAIRealtimeAdapter { return &OpenAIRealtimeAdapter{ - endpoint: endpoint, - apiKey: apiKey, - model: model, - language: lang, - resultsCh: make(chan TranscriptionResult, 100), - maxRetries: 3, - retryDelays: defaultRetryDelays, + endpoint: endpoint, + apiKey: apiKey, + model: model, + language: lang, + resultsCh: make(chan TranscriptionResult, 100), + maxRetries: 3, + retryDelays: defaultRetryDelays, + transcriptionDone: make(chan struct{}, 1), } } @@ -372,10 +376,15 @@ func (a *OpenAIRealtimeAdapter) handleEvent(event openaiRealtimeServerEvent) { case "conversation.item.input_audio_transcription.completed": // final transcription result + log.Printf("openai-realtime: transcription completed: %q", event.Transcript) if event.Transcript != "" { - log.Printf("openai-realtime: transcription completed: %q", event.Transcript) a.resultsCh <- TranscriptionResult{Text: event.Transcript, IsFinal: true} } + // signal finalization (non-blocking) + select { + case a.transcriptionDone <- struct{}{}: + default: + } case "conversation.item.input_audio_transcription.failed": log.Printf("openai-realtime: transcription failed for item %s", event.ItemID) @@ -500,6 +509,56 @@ func (a *OpenAIRealtimeAdapter) Results() <-chan TranscriptionResult { return a.resultsCh } +// Finalize sends a commit message to force OpenAI to process any pending audio +// and waits for the transcription.completed response +func (a *OpenAIRealtimeAdapter) Finalize(ctx context.Context) error { + a.mu.Lock() + if !a.started { + a.mu.Unlock() + return nil + } + conn := a.conn + a.mu.Unlock() + + if conn == nil { + return nil + } + + // drain any previous transcription signals + select { + case <-a.transcriptionDone: + default: + } + + // send input_audio_buffer.commit to force processing of pending audio + msg := openaiRealtimeInputAudioCommit{ + Type: "input_audio_buffer.commit", + } + + a.mu.Lock() + err := a.conn.WriteJSON(msg) + a.mu.Unlock() + + if err != nil { + log.Printf("openai-realtime: finalize write error: %v", err) + return fmt.Errorf("finalize write: %w", err) + } + + log.Printf("openai-realtime: sent commit, waiting for final transcription") + + // wait for transcription.completed or timeout + select { + case <-a.transcriptionDone: + log.Printf("openai-realtime: finalize complete") + return nil + case <-ctx.Done(): + log.Printf("openai-realtime: finalize timeout") + return ctx.Err() + case <-a.ctx.Done(): + return a.ctx.Err() + } +} + // Close gracefully closes the WebSocket connection func (a *OpenAIRealtimeAdapter) Close() error { a.mu.Lock() diff --git a/internal/transcriber/streaming.go b/internal/transcriber/streaming.go index 8d5d734..a930abd 100644 --- a/internal/transcriber/streaming.go +++ b/internal/transcriber/streaming.go @@ -20,6 +20,11 @@ type StreamingAdapter interface { // Results returns a channel that receives transcription results (partial and final) Results() <-chan TranscriptionResult + // Finalize signals end of audio input and waits for final transcription results. + // This should be called before Close to ensure all pending audio is committed. + // The ctx controls the timeout for waiting on final results. + Finalize(ctx context.Context) error + // Close gracefully closes the streaming connection Close() error } diff --git a/internal/transcriber/streaming_transcriber.go b/internal/transcriber/streaming_transcriber.go index 66e2502..2e69837 100644 --- a/internal/transcriber/streaming_transcriber.go +++ b/internal/transcriber/streaming_transcriber.go @@ -5,6 +5,7 @@ import ( "log" "strings" "sync" + "time" "github.com/leonardotrapani/hyprvoice/internal/recording" ) @@ -83,18 +84,45 @@ func (t *StreamingTranscriber) receiveResults(errCh chan<- error) { for { select { case <-t.ctx.Done(): + // context cancelled, drain any remaining results before exiting + t.drainRemainingResults(resultsCh) return case result, ok := <-resultsCh: if !ok { return } - if result.Error != nil { - select { - case errCh <- result.Error: - default: - } - log.Printf("streaming transcriber: result error: %v", result.Error) - continue + t.processResult(result, errCh) + } + } +} + +func (t *StreamingTranscriber) processResult(result TranscriptionResult, errCh chan<- error) { + if result.Error != nil { + select { + case errCh <- result.Error: + default: + } + log.Printf("streaming transcriber: result error: %v", result.Error) + return + } + if result.IsFinal && result.Text != "" { + t.mu.Lock() + if t.finalText.Len() > 0 { + t.finalText.WriteString(" ") + } + t.finalText.WriteString(result.Text) + t.mu.Unlock() + } +} + +func (t *StreamingTranscriber) drainRemainingResults(resultsCh <-chan TranscriptionResult) { + // give a short window to collect any final results already in the channel + timeout := time.After(100 * time.Millisecond) + for { + select { + case result, ok := <-resultsCh: + if !ok { + return } if result.IsFinal && result.Text != "" { t.mu.Lock() @@ -104,11 +132,20 @@ func (t *StreamingTranscriber) receiveResults(errCh chan<- error) { t.finalText.WriteString(result.Text) t.mu.Unlock() } + case <-timeout: + return } } } func (t *StreamingTranscriber) Stop(ctx context.Context) error { + // finalize adapter first to commit pending audio and wait for final results + // this must happen before canceling context so receiveResults can collect them + if err := t.adapter.Finalize(ctx); err != nil { + log.Printf("streaming transcriber: finalize error (continuing): %v", err) + } + + // now cancel context to stop goroutines if t.cancel != nil { t.cancel() } diff --git a/internal/transcriber/transcriber_test.go b/internal/transcriber/transcriber_test.go index 888a424..741648a 100644 --- a/internal/transcriber/transcriber_test.go +++ b/internal/transcriber/transcriber_test.go @@ -688,6 +688,7 @@ type MockStreamingAdapter struct { StartFunc func(ctx context.Context, language string) error SendChunkFunc func(audio []byte) error ResultsFunc func() <-chan TranscriptionResult + FinalizeFunc func(ctx context.Context) error CloseFunc func() error resultsCh chan TranscriptionResult @@ -720,6 +721,13 @@ func (m *MockStreamingAdapter) Results() <-chan TranscriptionResult { return m.resultsCh } +func (m *MockStreamingAdapter) Finalize(ctx context.Context) error { + if m.FinalizeFunc != nil { + return m.FinalizeFunc(ctx) + } + return nil +} + func (m *MockStreamingAdapter) Close() error { if m.CloseFunc != nil { return m.CloseFunc() diff --git a/internal/tui/configure.go b/internal/tui/configure.go index a8c9653..abbb948 100644 --- a/internal/tui/configure.go +++ b/internal/tui/configure.go @@ -37,7 +37,6 @@ type ConfigSection string const ( SectionProviders ConfigSection = "providers" - SectionLanguage ConfigSection = "language" SectionTranscription ConfigSection = "transcription" SectionLLM ConfigSection = "llm" SectionKeywords ConfigSection = "keywords" @@ -111,11 +110,6 @@ func runEditExisting(cfg *config.Config) (*ConfigureResult, error) { } configuredProviders = getConfiguredProviders(cfg) - case SectionLanguage: - if err := editLanguage(cfg); err != nil { - continue - } - case SectionTranscription: var err error configuredProviders, err = editTranscription(cfg, configuredProviders) @@ -160,7 +154,6 @@ func runEditExisting(cfg *config.Config) (*ConfigureResult, error) { func selectSection(cfg *config.Config) (ConfigSection, error) { options := []huh.Option[ConfigSection]{ huh.NewOption(formatProvidersLabel(cfg), SectionProviders), - huh.NewOption(formatLanguageMenuLabel(cfg), SectionLanguage), huh.NewOption(formatTranscriptionLabel(cfg), SectionTranscription), huh.NewOption(formatLLMLabel(cfg), SectionLLM), huh.NewOption(formatKeywordsLabel(cfg), SectionKeywords), diff --git a/internal/tui/configure_helpers.go b/internal/tui/configure_helpers.go index 16e68d7..bd9a731 100644 --- a/internal/tui/configure_helpers.go +++ b/internal/tui/configure_helpers.go @@ -13,11 +13,6 @@ func formatProvidersLabel(cfg *config.Config) string { return "Providers" } -// formatLanguageMenuLabel formats the language menu option -func formatLanguageMenuLabel(cfg *config.Config) string { - return "Language" -} - // formatTranscriptionLabel formats the transcription menu option func formatTranscriptionLabel(cfg *config.Config) string { return "Transcription" @@ -54,10 +49,11 @@ func showSummary(cfg *config.Config) (bool, error) { } 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) + lang := cfg.Transcription.Language + if lang == "" { + lang = "auto-detect" } + fmt.Printf(" %s %s/%s (%s)\n", StyleLabel.Render("Transcription:"), cfg.Transcription.Provider, cfg.Transcription.Model, lang) if cfg.LLM.Enabled { fmt.Printf(" %s %s (%s)\n", StyleLabel.Render("LLM:"), cfg.LLM.Provider, cfg.LLM.Model) diff --git a/internal/tui/configure_language.go b/internal/tui/configure_language.go deleted file mode 100644 index 5d910d2..0000000 --- a/internal/tui/configure_language.go +++ /dev/null @@ -1,85 +0,0 @@ -package tui - -import ( - "fmt" - - "github.com/charmbracelet/huh" - "github.com/leonardotrapani/hyprvoice/internal/config" - "github.com/leonardotrapani/hyprvoice/internal/language" - "github.com/leonardotrapani/hyprvoice/internal/provider" -) - -// editLanguage allows the user to select the global transcription language -func editLanguage(cfg *config.Config) error { - // no model-specific warnings for global language selection - languageOptions := getLanguageOptions(nil, cfg.General.Language) - - selectedLanguage := cfg.General.Language - - form := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("Language"). - Description("Select language for transcription (applies globally)"). - Options(languageOptions...). - Filtering(true). - Value(&selectedLanguage), - ), - ).WithTheme(getTheme()) - - if err := form.Run(); err != nil { - return err - } - - // check if current transcription model supports the selected language - if selectedLanguage != "" && cfg.Transcription.Provider != "" && cfg.Transcription.Model != "" { - registryName := mapConfigProviderToRegistry(cfg.Transcription.Provider) - model, err := provider.GetModel(registryName, cfg.Transcription.Model) - if err == nil && !model.SupportsLanguage(selectedLanguage) { - langName := language.FromCode(selectedLanguage).Name - if langName == "" { - langName = selectedLanguage - } - - fmt.Println() - fmt.Println(StyleWarning.Render("Language-Model Compatibility Warning")) - fmt.Printf("Your current model '%s' does not support %s.\n", model.Name, langName) - fmt.Println() - fmt.Println(StyleMuted.Render("You can:")) - fmt.Println(StyleMuted.Render(" - Keep this language and change the model later")) - fmt.Println(StyleMuted.Render(" - Use 'Auto-detect' for language")) - fmt.Println(StyleMuted.Render(" - Choose a different language")) - fmt.Println() - - var action string - actionForm := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("What would you like to do?"). - Options( - huh.NewOption("Keep this language (change model later)", "keep"), - huh.NewOption("Use Auto-detect instead", "auto"), - huh.NewOption("Choose a different language", "retry"), - ). - Value(&action), - ), - ).WithTheme(getTheme()) - - if err := actionForm.Run(); err != nil { - return err - } - - switch action { - case "auto": - selectedLanguage = "" - case "retry": - return editLanguage(cfg) - case "keep": - // proceed with incompatible language - } - } - } - - cfg.General.Language = selectedLanguage - return nil -} diff --git a/internal/tui/configure_transcription.go b/internal/tui/configure_transcription.go index 77320bc..5865f99 100644 --- a/internal/tui/configure_transcription.go +++ b/internal/tui/configure_transcription.go @@ -7,7 +7,6 @@ import ( "github.com/charmbracelet/huh" "github.com/leonardotrapani/hyprvoice/internal/config" "github.com/leonardotrapani/hyprvoice/internal/deps" - "github.com/leonardotrapani/hyprvoice/internal/language" "github.com/leonardotrapani/hyprvoice/internal/models/whisper" "github.com/leonardotrapani/hyprvoice/internal/provider" ) @@ -114,13 +113,7 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri } cfg.Transcription.Provider = selectedProvider - // use effective language for model compatibility display - effectiveLanguage := cfg.General.Language - if cfg.Transcription.Language != "" { - effectiveLanguage = cfg.Transcription.Language - } - - modelOptions := getTranscriptionModelOptions(selectedProvider, effectiveLanguage) + modelOptions := getTranscriptionModelOptions(selectedProvider) selectedModel := cfg.Transcription.Model if selectedModel == "" && len(modelOptions) > 0 { // skip header options (empty value) to find first real model @@ -156,41 +149,7 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri return editTranscription(cfg, configuredProviders) } - // validate language-model compatibility before saving registryName := mapConfigProviderToRegistry(selectedProvider) - if err := provider.ValidateModelLanguage(registryName, selectedModel, effectiveLanguage); err != nil { - // show error dialog - user needs to change language in Language menu - fmt.Println() - fmt.Println(StyleError.Render("Language-Model Incompatibility")) - fmt.Println(StyleMuted.Render(err.Error())) - fmt.Println() - fmt.Println(StyleMuted.Render("You can:")) - fmt.Println(StyleMuted.Render(" - Choose a different model that supports your language")) - fmt.Println(StyleMuted.Render(" - Change language to 'Auto-detect' in the Language menu")) - fmt.Println() - - var retry bool - retryForm := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Try again?"). - Description("Choose a different model"). - Affirmative("Yes, let me pick another model"). - Negative("Cancel"). - Value(&retry), - ), - ).WithTheme(getTheme()) - - if err := retryForm.Run(); err != nil { - return configuredProviders, err - } - - if retry { - // recurse to let user pick another model - return editTranscription(cfg, configuredProviders) - } - return configuredProviders, nil - } // for whisper-cpp, check if model needs download if selectedProvider == "whisper-cpp" && !whisper.IsInstalled(selectedModel) { @@ -245,35 +204,55 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri cfg.Transcription.Model = selectedModel - // set streaming mode based on model capabilities + // select language for this model model, err := provider.GetModel(registryName, selectedModel) - if err == nil { - if model.SupportsBothModes() { - // model supports both: ask user - useStreaming := cfg.Transcription.Streaming - streamingForm := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Enable streaming mode?"). - Description("This model supports both batch and streaming modes"). - Affirmative("Yes, use streaming (real-time)"). - Negative("No, use batch (after recording)"). - Value(&useStreaming), - ), - ).WithTheme(getTheme()) + if err != nil { + return configuredProviders, err + } - if err := streamingForm.Run(); err != nil { - return configuredProviders, err - } - cfg.Transcription.Streaming = useStreaming - } else if model.SupportsStreaming { - // streaming-only model - cfg.Transcription.Streaming = true - fmt.Println(StyleSuccess.Render("Streaming mode enabled (this model only supports streaming)")) - } else { - // batch-only model - cfg.Transcription.Streaming = false + languageOptions := getModelLanguageOptions(model, cfg.Transcription.Language) + selectedLanguage := cfg.Transcription.Language + + languageForm := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Language"). + Description("Select language for transcription"). + Options(languageOptions...). + Filtering(true). + Value(&selectedLanguage), + ), + ).WithTheme(getTheme()) + + if err := languageForm.Run(); err != nil { + return configuredProviders, err + } + + cfg.Transcription.Language = selectedLanguage + + // set streaming mode based on model capabilities + if model.SupportsBothModes() { + useStreaming := cfg.Transcription.Streaming + streamingForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Enable streaming mode?"). + Description("This model supports both batch and streaming modes"). + Affirmative("Yes, use streaming (real-time)"). + Negative("No, use batch (after recording)"). + Value(&useStreaming), + ), + ).WithTheme(getTheme()) + + if err := streamingForm.Run(); err != nil { + return configuredProviders, err } + cfg.Transcription.Streaming = useStreaming + } else if model.SupportsStreaming { + cfg.Transcription.Streaming = true + fmt.Println(StyleSuccess.Render("Streaming mode enabled (this model only supports streaming)")) + } else { + cfg.Transcription.Streaming = false } return configuredProviders, nil @@ -304,7 +283,7 @@ func getUnconfiguredTranscriptionOptions(configuredProviders []string) []huh.Opt return options } -func getTranscriptionModelOptions(configProvider string, currentLang string) []huh.Option[string] { +func getTranscriptionModelOptions(configProvider string) []huh.Option[string] { // special case: groq-translation only supports whisper-large-v3 if configProvider == "groq-translation" { return []huh.Option[string]{ @@ -323,7 +302,7 @@ func getTranscriptionModelOptions(configProvider string, currentLang string) []h var options []huh.Option[string] for _, m := range models { - label := buildModelLabel(m, currentLang) + label := buildModelLabel(m) if m.Local && registryName == "whisper-cpp" { if whisper.IsInstalled(m.ID) { label = "[x] " + label @@ -350,7 +329,7 @@ func mapConfigProviderToRegistry(configProvider string) string { } // buildModelLabel creates the display label for a model option -func buildModelLabel(m provider.Model, currentLang string) string { +func buildModelLabel(m provider.Model) string { label := fmt.Sprintf("%s (%s)", m.Name, m.Description) // append size for local models @@ -364,22 +343,6 @@ func buildModelLabel(m provider.Model, currentLang string) string { } else if m.SupportsStreaming { label += " [streaming]" } - // batch-only models don't need a tag (it's the default) - - // append language warning if model doesn't support current language - if currentLang != "" && !m.SupportsLanguage(currentLang) { - langName := getLangName(currentLang) - label += fmt.Sprintf(" (does not support %s)", langName) - } return label } - -// getLangName returns a human-readable language name for a code -func getLangName(code string) string { - lang := language.FromCode(code) - if lang.Code == "" { - return code // unknown code, return as-is - } - return lang.Name -} diff --git a/internal/tui/configure_transcription_test.go b/internal/tui/configure_transcription_test.go index e812038..c1e814e 100644 --- a/internal/tui/configure_transcription_test.go +++ b/internal/tui/configure_transcription_test.go @@ -9,7 +9,7 @@ import ( func TestGetTranscriptionModelOptions_ShowsCapabilities(t *testing.T) { // test elevenlabs - has batch-only and streaming-only models - options := getTranscriptionModelOptions("elevenlabs", "") + options := getTranscriptionModelOptions("elevenlabs") // should have 3 models: scribe_v1, scribe_v2, scribe_v2_realtime if len(options) != 3 { @@ -40,7 +40,7 @@ func TestGetTranscriptionModelOptions_ShowsCapabilities(t *testing.T) { func TestGetTranscriptionModelOptions_NoHeadersAnymore(t *testing.T) { // we removed batch/streaming section headers - options := getTranscriptionModelOptions("elevenlabs", "") + options := getTranscriptionModelOptions("elevenlabs") for _, opt := range options { if opt.Value == "" { @@ -50,7 +50,7 @@ func TestGetTranscriptionModelOptions_NoHeadersAnymore(t *testing.T) { } func TestGetTranscriptionModelOptions_OpenAI_ShowsCapabilities(t *testing.T) { - options := getTranscriptionModelOptions("openai", "") + options := getTranscriptionModelOptions("openai") // OpenAI has 3 transcription models: whisper-1, gpt-4o-transcribe, gpt-4o-mini-transcribe if len(options) != 3 { @@ -68,7 +68,7 @@ func TestGetTranscriptionModelOptions_OpenAI_ShowsCapabilities(t *testing.T) { } func TestGetTranscriptionModelOptions_Deepgram_ShowsBothModes(t *testing.T) { - options := getTranscriptionModelOptions("deepgram", "") + options := getTranscriptionModelOptions("deepgram") // Deepgram has 2 models: nova-3, nova-2 - both support batch+streaming if len(options) != 2 { @@ -84,7 +84,7 @@ func TestGetTranscriptionModelOptions_Deepgram_ShowsBothModes(t *testing.T) { func TestGetTranscriptionModelOptions_Groq_BatchOnly(t *testing.T) { // test groq - batch only (no streaming models) - options := getTranscriptionModelOptions("groq-transcription", "") + options := getTranscriptionModelOptions("groq-transcription") // should have 2 models: whisper-large-v3, whisper-large-v3-turbo if len(options) != 2 { diff --git a/internal/tui/configure_wizard.go b/internal/tui/configure_wizard.go index f21ed26..acac48a 100644 --- a/internal/tui/configure_wizard.go +++ b/internal/tui/configure_wizard.go @@ -39,12 +39,7 @@ func runFreshInstall(cfg *config.Config) (*ConfigureResult, error) { return &ConfigureResult{Cancelled: true}, nil } - // 4. Language selection - if err := editLanguage(cfg); err != nil { - return &ConfigureResult{Cancelled: true}, nil - } - - // 5. Keywords + // 4. Keywords keywords, err := inputKeywords(cfg.Keywords) if err != nil { return &ConfigureResult{Cancelled: true}, nil diff --git a/internal/tui/languages.go b/internal/tui/languages.go index 5f59491..c381eab 100644 --- a/internal/tui/languages.go +++ b/internal/tui/languages.go @@ -8,10 +8,8 @@ import ( "github.com/leonardotrapani/hyprvoice/internal/provider" ) -// getLanguageOptions returns language options for the dropdown -// if currentModel is provided, languages unsupported by that model will be marked -// currentLang is the currently selected language code (empty string for auto-detect) -func getLanguageOptions(currentModel *provider.Model, currentLang string) []huh.Option[string] { +// getModelLanguageOptions returns language options supported by the given model +func getModelLanguageOptions(model *provider.Model, currentLang string) []huh.Option[string] { var options []huh.Option[string] // auto-detect is always first @@ -21,18 +19,15 @@ func getLanguageOptions(currentModel *provider.Model, currentLang string) []huh. } options = append(options, huh.NewOption(autoLabel, "")) - // add all languages + // only show languages supported by the model for _, lang := range language.List() { - label := formatLanguageLabel(lang) - - // mark current selection - if lang.Code == currentLang { - label += " (current)" + if model != nil && !model.SupportsLanguage(lang.Code) { + continue } - // add warning if model doesn't support this language - if currentModel != nil && !currentModel.SupportsLanguage(lang.Code) { - label += " (not supported by current model)" + label := formatLanguageLabel(lang) + if lang.Code == currentLang { + label += " (current)" } options = append(options, huh.NewOption(label, lang.Code))