diff --git a/AGENTS.md b/AGENTS.md index 9c8cb20..3317e02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,8 +7,24 @@ This repo is a Go CLI + daemon for voice-powered typing on Wayland/Hyprland. - go build -o hyprvoice ./cmd/hyprvoice - go run ./cmd/hyprvoice -## Where to look -- docs/structure.md: architecture and code map +## Main structure (short) +- cmd/hyprvoice: CLI entrypoint and commands +- internal/daemon: daemon lifecycle + IPC command handling +- internal/pipeline: recording -> transcription -> processing -> injection state machine +- internal/recording: PipeWire capture +- internal/transcriber: batch/streaming adapters +- internal/llm: post-processing adapters and prompts +- internal/injection: wtype/ydotool/clipboard backends +- internal/provider: provider registry + model metadata +- internal/config: config load/validate + hot reload + +## Runtime quick facts +- IPC: unix socket at ~/.cache/hyprvoice/control.sock, single-character commands +- Config: ~/.config/hyprvoice/config.toml (hot reloaded by daemon) + +## Docs +- docs/structure.md: code map and entry points +- docs/architecture.md: deeper architecture + adapters/interfaces - docs/config.md: config reference and paths - docs/providers.md: provider and model details - packaging/RELEASE.md: release and AUR workflow diff --git a/cmd/hyprvoice/main.go b/cmd/hyprvoice/main.go index 5d19829..4ff9116 100644 --- a/cmd/hyprvoice/main.go +++ b/cmd/hyprvoice/main.go @@ -183,12 +183,12 @@ func runConfigure(onboarding bool) error { fmt.Println() // Show next steps - showNextSteps(result.Config) + showNextSteps(result.Config, onboarding) return nil } -func showNextSteps(cfg *config.Config) { +func showNextSteps(cfg *config.Config, onboarding bool) { // Check if service is running serviceRunning := false if _, err := exec.Command("systemctl", "--user", "is-active", "--quiet", "hyprvoice.service").CombinedOutput(); err == nil { @@ -210,12 +210,16 @@ func showNextSteps(cfg *config.Config) { fmt.Printf("%d. Ensure ydotoold is running\n", step) step++ } - if !serviceRunning { - fmt.Printf("%d. Start the service: systemctl --user start hyprvoice.service\n", step) - } else { + if serviceRunning { fmt.Printf("%d. Restart the service to apply changes: systemctl --user restart hyprvoice.service\n", step) + step++ + } else if onboarding { + fmt.Printf("%d. Start the service: systemctl --user start hyprvoice.service\n", step) + step++ + } else { + fmt.Printf("%d. Start the service if it is not running\n", step) + step++ } - step++ fmt.Printf("%d. Test voice input: hyprvoice toggle\n", step) fmt.Println() diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..ac36183 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,110 @@ +# Architecture + +This doc describes how the CLI, daemon, pipeline, and adapters compose the system. + +## Overview +Hyprvoice is split into a thin CLI and a long-lived daemon. The CLI sends single-character IPC commands to the daemon. The daemon owns lifecycle and runs a pipeline state machine that coordinates recording, transcription, optional LLM cleanup, and text injection. + +## Components +- CLI: command parsing and IPC client (`cmd/hyprvoice/main.go`). +- Daemon: IPC server, lifecycle, pipeline ownership (`internal/daemon/daemon.go`). +- Pipeline: state machine orchestration (`internal/pipeline/`). +- Recording: PipeWire capture (`internal/recording/`). +- Transcription: batch + streaming adapters (`internal/transcriber/`). +- LLM post-processing: adapters and prompt builders (`internal/llm/`). +- Injection: wtype/ydotool/clipboard backends (`internal/injection/`). +- Provider registry: model metadata and adapter selection (`internal/provider/`). +- Config manager: load/validate + hot reload (`internal/config/`). + +## IPC control plane +The daemon listens on a unix socket and accepts single-character commands. + +- Socket path: `~/.cache/hyprvoice/control.sock` (see `internal/bus/bus.go`). +- Command bytes: `t` toggle, `c` cancel, `s` status, `v` version, `q` quit. +- Responses are line-based: `OK ...`, `STATUS ...`, or `ERR ...`. + +The CLI writes one command byte and reads the response; the daemon maps commands to pipeline actions. + +## Pipeline state machine +The pipeline is a long-lived goroutine managed by the daemon. It exposes a small interface and uses channels to coordinate actions and notifications. + +States (from `internal/pipeline/pipeline.go`): + +`idle -> recording -> transcribing -> processing -> injecting -> idle` + +Key transitions: +- Toggle while idle: start recorder + transcriber, move to recording/transcribing. +- Inject action: stop recorder, finalize transcription, optional LLM processing, inject text. +- Cancel: stop current action and return to idle. + +Key interface (simplified): +- `Pipeline.Run()` starts the pipeline loop. +- `Pipeline.Stop()` stops the current run. +- `Pipeline.GetActionCh()` receives actions (toggle inject). +- `Pipeline.GetNotifyCh()` emits user-facing events. +- `Pipeline.GetErrorCh()` emits errors for the daemon to handle. + +## Recording +`internal/recording/recording.go` defines `Recorder` with `Start/Stop/IsRecording`. +The default implementation wraps `pw-record` and emits `AudioFrame` chunks on a buffered channel. + +## Transcription +`internal/transcriber/transcriber.go` defines the core interfaces: + +- `Transcriber`: lifecycle + `GetFinalTranscription()`. +- `BatchAdapter`: `Transcribe(audio, opts)` for full-file transcription. +- `StreamingAdapter`: `Start/SendChunk/Results/Finalize/Close` for realtime. + +`NewTranscriber()` selects between `SimpleTranscriber` (batch) and `StreamingTranscriber` (streaming) based on provider model metadata. Streaming adapters deliver incremental `TranscriptionResult` events and a final transcript on stop/finalize. + +## LLM post-processing +`internal/llm/llm.go` defines an `Adapter` interface with `Process(text, config)`. +Adapters (OpenAI, Groq) use a shared prompt builder in `internal/llm/prompt.go`. +The pipeline invokes LLM processing only if enabled in config. + +## Injection +`internal/injection/injection.go` defines `Injector` and an ordered list of backends. +`internal/injection/backend.go` defines the `Backend` interface (`Name/Available/Inject`). +Backends include: +- `wtype` (Wayland typing) +- `ydotool` (uinput typing) +- `wl-clipboard` fallback + +The injector tries backends in order and falls back to clipboard when typing fails. + +## Provider registry and adapter selection +Providers register themselves via `internal/provider/provider.go` and return model catalogs. +Each `Model` includes: +- `AdapterType` (which adapter to use) +- `Endpoint` and optional `StreamingEndpoint` +- `SupportedLanguages` and model capabilities + +`internal/provider/names.go` holds adapter constants and provider names. `internal/provider/model.go` implements language compatibility checks. `internal/provider/provider.go` exposes helpers like `GetModel`, `ModelsForLanguage`, and `ValidateModelLanguage`. + +## Language compatibility +`internal/language/language.go` defines the canonical language list and provider-specific formatting (ex: Deepgram locale mapping). Model-level language filters enforce compatibility at config time and runtime. + +## Config lifecycle and hot reload +`internal/config/load.go` loads config, applies defaults, and resolves env-based API keys. `internal/config/validate.go` enforces model/language compatibility and provider requirements. `internal/config/convert.go` converts config into runtime structs for the pipeline. + +`internal/config/manager.go` watches `~/.config/hyprvoice/config.toml` and triggers reloads with a debounce. The daemon wires `onConfigReload` to stop any running pipeline, refresh notifiers, and apply new settings without a restart. + +## Notifications and errors +The pipeline emits notification events and errors via channels. The daemon consumes them and uses `internal/notify` to display status changes to the user. + +## Extending the system +Common extension points: + +- Add a new transcription provider: + - Define a provider catalog in `internal/provider/`. + - Implement a `BatchAdapter` or `StreamingAdapter` in `internal/transcriber/`. + - Add adapter constants in `internal/provider/names.go`. + - Update provider docs in `docs/providers.md`. + +- Add a new injection backend: + - Implement `Backend` in `internal/injection/`. + - Register it in the injector order (config driven). + +- Add a new LLM adapter: + - Implement `Adapter` in `internal/llm/`. + - Wire it in `NewAdapter()` and expose config knobs. diff --git a/docs/config.md b/docs/config.md index 06a00e6..4d4123f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -93,24 +93,6 @@ language = "" # Empty for auto-detect, or "en", "es", "fr", et - 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: @@ -384,7 +366,7 @@ keywords = ["Hyprland", "Wayland", "PipeWire", "Claude", "TypeScript"] **How keywords work:** -- **Transcription**: Passed as initial_prompt to Whisper, improving recognition of these terms +- **Transcription**: Passed as provider-specific hints (prompt/keyterms/keywords) when supported to improve recognition - **LLM**: Included in the system prompt to ensure correct spelling **When to use keywords:** diff --git a/go.mod b/go.mod index 8f31b47..8b8e118 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/muesli/termenv v0.16.0 github.com/sashabaranov/go-openai v1.41.1 github.com/spf13/cobra v1.9.1 + golang.org/x/text v0.23.0 ) require ( @@ -38,5 +39,4 @@ require ( github.com/spf13/pflag v1.0.6 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.23.0 // indirect ) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e981436..c5cba75 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1074,38 +1074,6 @@ func TestConfig_Validate_GroqTranscription(t *testing.T) { } } -func TestConfig_Validate_GroqTranslation(t *testing.T) { - config := &Config{ - Recording: RecordingConfig{ - SampleRate: 16000, - Channels: 1, - Format: "s16", - BufferSize: 8192, - ChannelBufferSize: 30, - Timeout: time.Minute, - }, - Transcription: TranscriptionConfig{ - Provider: "groq-translation", - APIKey: "gsk-test-key", - Language: "es", - Model: "whisper-large-v3", // Translation only supports non-turbo - }, - Injection: InjectionConfig{ - Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, - WtypeTimeout: time.Second, - ClipboardTimeout: time.Second, - }, - Notifications: NotificationsConfig{ - Type: "log", - }, - } - - err := config.Validate() - if err != nil { - t.Errorf("Validate() should have passed with valid groq-translation config: %v", err) - } -} - func TestConfig_Validate_GroqInvalidModel(t *testing.T) { config := &Config{ Recording: RecordingConfig{ @@ -1248,41 +1216,6 @@ func TestConfig_ToTranscriberConfig_GroqWithEnvVar(t *testing.T) { } } -func TestConfig_Validate_GroqTranslation_RejectsTurbo(t *testing.T) { - config := &Config{ - Recording: RecordingConfig{ - SampleRate: 16000, - Channels: 1, - Format: "s16", - BufferSize: 8192, - ChannelBufferSize: 30, - Timeout: time.Minute, - }, - Transcription: TranscriptionConfig{ - Provider: "groq-translation", - APIKey: "gsk-test-key", - Language: "es", - Model: "whisper-large-v3-turbo", // Turbo not supported for translation - }, - Injection: InjectionConfig{ - Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, - WtypeTimeout: time.Second, - ClipboardTimeout: time.Second, - }, - Notifications: NotificationsConfig{ - Type: "log", - }, - } - - err := config.Validate() - if err == nil { - t.Error("Validate() should have rejected whisper-large-v3-turbo for groq-translation") - } - if err != nil && err.Error() != "invalid model for groq-translation: whisper-large-v3-turbo (must be whisper-large-v3, turbo version not supported for translation)" { - t.Errorf("Unexpected error message: %v", err) - } -} - func TestMessagesConfig_Resolve_Defaults(t *testing.T) { cfg := createTestConfig() msgs := cfg.Notifications.Messages.Resolve() diff --git a/internal/config/load.go b/internal/config/load.go index 0740a46..0174839 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -104,7 +104,7 @@ func (c *Config) migrateTranscriptionAPIKey(apiKey string) { switch providerName { case "openai": c.Providers["openai"] = ProviderConfig{APIKey: apiKey} - case "groq-transcription", "groq-translation": + case "groq-transcription": c.Providers["groq"] = ProviderConfig{APIKey: apiKey} case "mistral-transcription": c.Providers["mistral"] = ProviderConfig{APIKey: apiKey} diff --git a/internal/config/save.go b/internal/config/save.go index ca44c3f..83b899a 100644 --- a/internal/config/save.go +++ b/internal/config/save.go @@ -246,7 +246,7 @@ keywords = [] # ───────────────────────────────────────────────────────────────────────────── [transcription] - provider = "openai" # "openai", "groq-transcription", "groq-translation", "mistral-transcription", "elevenlabs", "whisper-cpp" + provider = "openai" # "openai", "groq-transcription", "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) @@ -325,7 +325,6 @@ keywords = [] # 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) # diff --git a/internal/config/validate.go b/internal/config/validate.go index 7d423c6..eb258f7 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -2,19 +2,17 @@ package config import ( "fmt" - "log" "strings" - "github.com/leonardotrapani/hyprvoice/internal/language" "github.com/leonardotrapani/hyprvoice/internal/provider" ) // mapConfigProviderToRegistryName maps config provider names to provider registry names -// Config uses names like "groq-transcription", "groq-translation", "mistral-transcription" +// Config uses names like "groq-transcription", "mistral-transcription" // Registry uses base names like "groq", "mistral" func mapConfigProviderToRegistryName(configProvider string) string { switch configProvider { - case "groq-transcription", "groq-translation": + case "groq-transcription": return "groq" case "mistral-transcription": return "mistral" @@ -85,21 +83,11 @@ func (c *Config) Validate() error { } } - // validate language codes - warn if not recognized but don't error - 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) - } - // validate model exists if c.Transcription.Model == "" { return fmt.Errorf("invalid transcription.model: empty") } - // groq-translation is a special case - only supports whisper-large-v3 - if c.Transcription.Provider == "groq-translation" && 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) - } - // validate model exists in provider _, err := provider.GetModel(registryName, c.Transcription.Model) if err != nil { @@ -205,11 +193,6 @@ func ValidateModelLanguageCompatibility(registryProvider, modelID, langCode stri } // language not supported - build helpful error message - langName := language.FromCode(langCode).Name - if langName == "Auto-detect" { - langName = langCode // use code if not found - } - // truncate supported languages for error message supported := model.SupportedLanguages suffix := "" @@ -225,9 +208,8 @@ func ValidateModelLanguageCompatibility(registryProvider, modelID, langCode stri } return fmt.Errorf( - "model %s does not support %s (%s).%s Supported: %s%s", + "model %s does not support language '%s'.%s Supported: %s%s", model.Name, - langName, langCode, docsHint, strings.Join(supported, ", "), diff --git a/internal/language/language.go b/internal/language/language.go deleted file mode 100644 index eeaed99..0000000 --- a/internal/language/language.go +++ /dev/null @@ -1,166 +0,0 @@ -package language - -// Language represents a supported transcription language -type Language struct { - Code string // ISO 639-1 code (e.g., "en", "es", "zh") - Name string // English name (e.g., "English", "Spanish") - NativeName string // Native name (e.g., "English", "Espanol", "中文") -} - -// Auto represents auto-detection - used when user doesn't specify a language -var Auto = Language{Code: "", Name: "Auto-detect", NativeName: ""} - -// languages is the master list of supported languages -// derived from OpenAI Whisper's 57 supported languages -var languages = []Language{ - {Code: "af", Name: "Afrikaans", NativeName: "Afrikaans"}, - {Code: "ar", Name: "Arabic", NativeName: "العربية"}, - {Code: "hy", Name: "Armenian", NativeName: "Հdelays"}, - {Code: "az", Name: "Azerbaijani", NativeName: "Azərbaycan"}, - {Code: "be", Name: "Belarusian", NativeName: "Беларуская"}, - {Code: "bs", Name: "Bosnian", NativeName: "Bosanski"}, - {Code: "bg", Name: "Bulgarian", NativeName: "Български"}, - {Code: "ca", Name: "Catalan", NativeName: "Català"}, - {Code: "zh", Name: "Chinese", NativeName: "中文"}, - {Code: "hr", Name: "Croatian", NativeName: "Hrvatski"}, - {Code: "cs", Name: "Czech", NativeName: "Čeština"}, - {Code: "da", Name: "Danish", NativeName: "Dansk"}, - {Code: "nl", Name: "Dutch", NativeName: "Nederlands"}, - {Code: "en", Name: "English", NativeName: "English"}, - {Code: "et", Name: "Estonian", NativeName: "Eesti"}, - {Code: "fi", Name: "Finnish", NativeName: "Suomi"}, - {Code: "fr", Name: "French", NativeName: "Français"}, - {Code: "gl", Name: "Galician", NativeName: "Galego"}, - {Code: "de", Name: "German", NativeName: "Deutsch"}, - {Code: "el", Name: "Greek", NativeName: "Ελληνικά"}, - {Code: "he", Name: "Hebrew", NativeName: "עברית"}, - {Code: "hi", Name: "Hindi", NativeName: "हिन्दी"}, - {Code: "hu", Name: "Hungarian", NativeName: "Magyar"}, - {Code: "is", Name: "Icelandic", NativeName: "Íslenska"}, - {Code: "id", Name: "Indonesian", NativeName: "Bahasa Indonesia"}, - {Code: "it", Name: "Italian", NativeName: "Italiano"}, - {Code: "ja", Name: "Japanese", NativeName: "日本語"}, - {Code: "kn", Name: "Kannada", NativeName: "ಕನ್ನಡ"}, - {Code: "kk", Name: "Kazakh", NativeName: "Қазақ"}, - {Code: "ko", Name: "Korean", NativeName: "한국어"}, - {Code: "lv", Name: "Latvian", NativeName: "Latviešu"}, - {Code: "lt", Name: "Lithuanian", NativeName: "Lietuvių"}, - {Code: "mk", Name: "Macedonian", NativeName: "Македонски"}, - {Code: "ms", Name: "Malay", NativeName: "Bahasa Melayu"}, - {Code: "mr", Name: "Marathi", NativeName: "मराठी"}, - {Code: "mi", Name: "Maori", NativeName: "Māori"}, - {Code: "ne", Name: "Nepali", NativeName: "नेपाली"}, - {Code: "no", Name: "Norwegian", NativeName: "Norsk"}, - {Code: "fa", Name: "Persian", NativeName: "فارسی"}, - {Code: "pl", Name: "Polish", NativeName: "Polski"}, - {Code: "pt", Name: "Portuguese", NativeName: "Português"}, - {Code: "ro", Name: "Romanian", NativeName: "Română"}, - {Code: "ru", Name: "Russian", NativeName: "Русский"}, - {Code: "sr", Name: "Serbian", NativeName: "Српски"}, - {Code: "sk", Name: "Slovak", NativeName: "Slovenčina"}, - {Code: "sl", Name: "Slovenian", NativeName: "Slovenščina"}, - {Code: "es", Name: "Spanish", NativeName: "Español"}, - {Code: "sw", Name: "Swahili", NativeName: "Kiswahili"}, - {Code: "sv", Name: "Swedish", NativeName: "Svenska"}, - {Code: "tl", Name: "Tagalog", NativeName: "Tagalog"}, - {Code: "ta", Name: "Tamil", NativeName: "தமிழ்"}, - {Code: "th", Name: "Thai", NativeName: "ไทย"}, - {Code: "tr", Name: "Turkish", NativeName: "Türkçe"}, - {Code: "uk", Name: "Ukrainian", NativeName: "Українська"}, - {Code: "ur", Name: "Urdu", NativeName: "اردو"}, - {Code: "vi", Name: "Vietnamese", NativeName: "Tiếng Việt"}, - {Code: "cy", Name: "Welsh", NativeName: "Cymraeg"}, -} - -// codeIndex maps language codes to their Language structs for fast lookup -var codeIndex map[string]Language - -func init() { - codeIndex = make(map[string]Language, len(languages)+1) - codeIndex[""] = Auto // auto-detect is valid - for _, lang := range languages { - codeIndex[lang.Code] = lang - } -} - -// FromCode returns the Language for the given code. -// Returns Auto if code is not found. -func FromCode(code string) Language { - if lang, ok := codeIndex[code]; ok { - return lang - } - return Auto -} - -// List returns all supported languages (excluding Auto) -func List() []Language { - result := make([]Language, len(languages)) - copy(result, languages) - return result -} - -// Codes returns all language codes (excluding empty string for auto) -func Codes() []string { - codes := make([]string, len(languages)) - for i, lang := range languages { - codes[i] = lang.Code - } - return codes -} - -// AllLanguageCodes is an alias for Codes - used by models that support all languages -func AllLanguageCodes() []string { - return Codes() -} - -// IsValidCode returns true if the code is recognized (including empty for auto) -func IsValidCode(code string) bool { - _, ok := codeIndex[code] - return ok -} - -// ToProviderFormat converts a canonical language code to the format expected by a specific provider. -// Each provider may have different expectations: -// - whisper-cpp: uses standard codes like 'en', 'auto' for auto-detect -// - openai: uses standard codes like 'en', empty string for auto-detect -// - groq: same as openai (OpenAI-compatible) -// - mistral: same as openai (OpenAI-compatible) -// - deepgram: uses locale codes like 'en-US', 'es' for Spanish -// - elevenlabs: uses standard codes or full names depending on API version -func ToProviderFormat(code string, providerName string) string { - // handle auto-detect (empty code) - if code == "" { - switch providerName { - case "whisper-cpp": - return "auto" - default: - // most providers use empty string or omit the parameter - return "" - } - } - - switch providerName { - case "deepgram": - // deepgram prefers locale codes for some languages - return toDeepgramFormat(code) - default: - // whisper-cpp, openai, groq, mistral, elevenlabs use standard codes - return code - } -} - -// toDeepgramFormat maps standard codes to Deepgram's preferred format -func toDeepgramFormat(code string) string { - // deepgram uses locale codes for English variants, standard for most others - deepgramMappings := map[string]string{ - "en": "en-US", - "es": "es", // Spanish uses base code - "pt": "pt-BR", // Portuguese defaults to Brazilian - "zh": "zh-CN", // Chinese defaults to Simplified - } - - if mapped, ok := deepgramMappings[code]; ok { - return mapped - } - return code -} diff --git a/internal/language/language_test.go b/internal/language/language_test.go deleted file mode 100644 index 2f21a4b..0000000 --- a/internal/language/language_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package language - -import "testing" - -func TestFromCode(t *testing.T) { - tests := []struct { - code string - wantCode string - wantName string - }{ - {"en", "en", "English"}, - {"es", "es", "Spanish"}, - {"zh", "zh", "Chinese"}, - {"invalid", "", "Auto-detect"}, - {"", "", "Auto-detect"}, - } - - for _, tt := range tests { - t.Run(tt.code, func(t *testing.T) { - got := FromCode(tt.code) - if got.Code != tt.wantCode { - t.Errorf("FromCode(%q).Code = %q, want %q", tt.code, got.Code, tt.wantCode) - } - if got.Name != tt.wantName { - t.Errorf("FromCode(%q).Name = %q, want %q", tt.code, got.Name, tt.wantName) - } - }) - } -} - -func TestFromCodeEnglish(t *testing.T) { - lang := FromCode("en") - if lang.Code != "en" { - t.Errorf("FromCode('en').Code = %q, want 'en'", lang.Code) - } - if lang.Name != "English" { - t.Errorf("FromCode('en').Name = %q, want 'English'", lang.Name) - } - if lang.NativeName != "English" { - t.Errorf("FromCode('en').NativeName = %q, want 'English'", lang.NativeName) - } -} - -func TestIsValidCode(t *testing.T) { - tests := []struct { - code string - want bool - }{ - {"en", true}, - {"es", true}, - {"zh", true}, - {"invalid", false}, - {"", true}, // auto is valid - {"xyz", false}, - } - - for _, tt := range tests { - t.Run(tt.code, func(t *testing.T) { - got := IsValidCode(tt.code) - if got != tt.want { - t.Errorf("IsValidCode(%q) = %v, want %v", tt.code, got, tt.want) - } - }) - } -} - -func TestList(t *testing.T) { - list := List() - if len(list) != 57 { - t.Errorf("List() returned %d languages, want 57", len(list)) - } - - // verify English is in the list - found := false - for _, lang := range list { - if lang.Code == "en" { - found = true - break - } - } - if !found { - t.Error("List() does not contain English") - } -} - -func TestCodes(t *testing.T) { - codes := Codes() - if len(codes) != 57 { - t.Errorf("Codes() returned %d codes, want 57", len(codes)) - } - - // verify 'en' is in the codes - found := false - for _, code := range codes { - if code == "en" { - found = true - break - } - } - if !found { - t.Error("Codes() does not contain 'en'") - } -} - -func TestAllLanguageCodes(t *testing.T) { - codes := AllLanguageCodes() - if len(codes) != 57 { - t.Errorf("AllLanguageCodes() returned %d codes, want 57", len(codes)) - } -} - -func TestAuto(t *testing.T) { - if Auto.Code != "" { - t.Errorf("Auto.Code = %q, want empty string", Auto.Code) - } - if Auto.Name != "Auto-detect" { - t.Errorf("Auto.Name = %q, want 'Auto-detect'", Auto.Name) - } -} - -func TestToProviderFormat(t *testing.T) { - tests := []struct { - code string - provider string - want string - }{ - // whisper-cpp - {"en", "whisper-cpp", "en"}, - {"es", "whisper-cpp", "es"}, - {"", "whisper-cpp", "auto"}, - - // openai - {"en", "openai", "en"}, - {"", "openai", ""}, - - // groq (openai-compatible) - {"en", "groq", "en"}, - {"", "groq", ""}, - - // mistral (openai-compatible) - {"en", "mistral", "en"}, - {"", "mistral", ""}, - - // deepgram (uses locale codes) - {"en", "deepgram", "en-US"}, - {"es", "deepgram", "es"}, - {"pt", "deepgram", "pt-BR"}, - {"zh", "deepgram", "zh-CN"}, - {"fr", "deepgram", "fr"}, // no special mapping, passthrough - {"", "deepgram", ""}, - - // elevenlabs - {"en", "elevenlabs", "en"}, - {"", "elevenlabs", ""}, - } - - for _, tt := range tests { - t.Run(tt.code+"_"+tt.provider, func(t *testing.T) { - got := ToProviderFormat(tt.code, tt.provider) - if got != tt.want { - t.Errorf("ToProviderFormat(%q, %q) = %q, want %q", tt.code, tt.provider, got, tt.want) - } - }) - } -} diff --git a/internal/provider/deepgram.go b/internal/provider/deepgram.go index da8ee75..509912d 100644 --- a/internal/provider/deepgram.go +++ b/internal/provider/deepgram.go @@ -21,21 +21,10 @@ func (p *DeepgramProvider) IsLocal() bool { } func (p *DeepgramProvider) Models() []Model { - // Nova-3 language support - maps to our 57 language list - // from https://developers.deepgram.com/docs/models-languages-overview - nova3Langs := []string{ - "ar", "be", "bs", "bg", "ca", "hr", "cs", "da", "nl", "en", "et", "fi", - "fr", "de", "el", "hi", "hu", "id", "it", "ja", "kn", "ko", "lv", "lt", - "mk", "ms", "mr", "no", "pl", "pt", "ro", "ru", "sr", "sk", "sl", "es", - "sv", "tl", "ta", "tr", "uk", "vi", - } - - // Nova-2 language support - subset of nova-3 - nova2Langs := []string{ - "bg", "ca", "zh", "cs", "da", "nl", "en", "et", "fi", "fr", "de", "el", - "hi", "hu", "id", "it", "ja", "ko", "lv", "lt", "ms", "no", "pl", "pt", - "ro", "ru", "sk", "es", "sv", "th", "tr", "uk", "vi", - } + // https://developers.deepgram.com/docs/models-languages-overview + nova3Langs := deepgramNova3Languages + // https://developers.deepgram.com/docs/models-languages-overview + nova2Langs := deepgramNova2Languages docsURL := "https://developers.deepgram.com/docs/language" diff --git a/internal/provider/elevenlabs.go b/internal/provider/elevenlabs.go index d1cdcf0..c2a432b 100644 --- a/internal/provider/elevenlabs.go +++ b/internal/provider/elevenlabs.go @@ -1,7 +1,5 @@ package provider -import "github.com/leonardotrapani/hyprvoice/internal/language" - // ElevenLabsProvider implements Provider for ElevenLabs services (transcription only) type ElevenLabsProvider struct{} @@ -23,10 +21,9 @@ func (p *ElevenLabsProvider) IsLocal() bool { } func (p *ElevenLabsProvider) Models() []Model { - // ElevenLabs Scribe supports 90+ languages, including all 57 from our master list - // See: https://elevenlabs.io/speech-to-text - allLangs := language.AllLanguageCodes() - docsURL := "https://elevenlabs.io/docs/capabilities/speech-to-text#supported-languages" + // https://elevenlabs.io/speech-to-text + allLangs := elevenLabsTranscriptionLanguages + docsURL := "https://elevenlabs.io/speech-to-text" return []Model{ { diff --git a/internal/provider/groq.go b/internal/provider/groq.go index 5a74755..f2d4f6c 100644 --- a/internal/provider/groq.go +++ b/internal/provider/groq.go @@ -1,10 +1,6 @@ package provider -import ( - "strings" - - "github.com/leonardotrapani/hyprvoice/internal/language" -) +import "strings" // GroqProvider implements Provider for Groq services type GroqProvider struct{} @@ -26,7 +22,8 @@ func (p *GroqProvider) IsLocal() bool { } func (p *GroqProvider) Models() []Model { - allLangs := language.AllLanguageCodes() + // https://console.groq.com/docs/speech-to-text#supported-languages + allLangs := groqTranscriptionLanguages docsURL := "https://console.groq.com/docs/speech-to-text#supported-languages" return []Model{ @@ -59,40 +56,37 @@ func (p *GroqProvider) Models() []Model { }, // LLM models { - ID: "llama-3.3-70b-versatile", - Name: "Llama 3.3 70B Versatile", - Description: "Most capable Llama model", - Type: LLM, - SupportsBatch: true, - SupportsStreaming: false, - Local: false, - AdapterType: AdapterOpenAI, - SupportedLanguages: allLangs, - Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"}, + ID: "llama-3.3-70b-versatile", + Name: "Llama 3.3 70B Versatile", + Description: "Most capable Llama model", + Type: LLM, + SupportsBatch: true, + SupportsStreaming: false, + Local: false, + AdapterType: AdapterOpenAI, + Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"}, }, { - ID: "llama-3.1-8b-instant", - Name: "Llama 3.1 8B Instant", - Description: "Fast and efficient", - Type: LLM, - SupportsBatch: true, - SupportsStreaming: false, - Local: false, - AdapterType: AdapterOpenAI, - SupportedLanguages: allLangs, - Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"}, + ID: "llama-3.1-8b-instant", + Name: "Llama 3.1 8B Instant", + Description: "Fast and efficient", + Type: LLM, + SupportsBatch: true, + SupportsStreaming: false, + Local: false, + AdapterType: AdapterOpenAI, + Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"}, }, { - ID: "mixtral-8x7b-32768", - Name: "Mixtral 8x7B", - Description: "Mixture of experts model", - Type: LLM, - SupportsBatch: true, - SupportsStreaming: false, - Local: false, - AdapterType: AdapterOpenAI, - SupportedLanguages: allLangs, - Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"}, + ID: "mixtral-8x7b-32768", + Name: "Mixtral 8x7B", + Description: "Mixture of experts model", + Type: LLM, + SupportsBatch: true, + SupportsStreaming: false, + Local: false, + AdapterType: AdapterOpenAI, + Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"}, }, } } diff --git a/internal/provider/languages.go b/internal/provider/languages.go new file mode 100644 index 0000000..3064f8f --- /dev/null +++ b/internal/provider/languages.go @@ -0,0 +1,47 @@ +package provider + +var openaiTranscriptionLanguages = []string{ + "af", "ar", "hy", "az", "be", "bs", "bg", "ca", "zh", "hr", "cs", "da", + "nl", "en", "et", "fi", "fr", "gl", "de", "el", "he", "hi", "hu", "is", + "id", "it", "ja", "kn", "kk", "ko", "lv", "lt", "mk", "ms", "mr", "mi", + "ne", "no", "fa", "pl", "pt", "ro", "ru", "sr", "sk", "sl", "es", "sw", + "sv", "tl", "ta", "th", "tr", "uk", "ur", "vi", "cy", +} + +var groqTranscriptionLanguages = openaiTranscriptionLanguages +var mistralTranscriptionLanguages = openaiTranscriptionLanguages +var whisperTranscriptionLanguages = openaiTranscriptionLanguages + +var whisperEnglishOnlyLanguages = []string{"en"} + +var deepgramNova3Languages = []string{ + "multi", + "ar", "ar-AE", "ar-SA", "ar-QA", "ar-KW", "ar-SY", "ar-LB", "ar-PS", "ar-JO", "ar-EG", "ar-SD", "ar-TD", "ar-MA", "ar-DZ", "ar-TN", "ar-IQ", "ar-IR", + "be", "bn", "bs", "bg", "ca", "hr", "cs", "da", "da-DK", "nl", "nl-BE", + "en", "en-US", "en-AU", "en-GB", "en-IN", "en-NZ", "et", "fi", "fr", "fr-CA", + "de", "de-CH", "el", "hi", "hu", "id", "it", "ja", "kn", "ko", "ko-KR", + "lv", "lt", "mk", "ms", "mr", "no", "pl", "pt", "pt-BR", "pt-PT", "ro", + "ru", "sr", "sk", "sl", "es", "es-419", "sv", "sv-SE", "tl", "ta", "te", + "tr", "uk", "vi", +} + +var deepgramNova2Languages = []string{ + "multi", + "bg", "ca", "zh", "zh-CN", "zh-Hans", "zh-TW", "zh-Hant", "zh-HK", "cs", + "da", "da-DK", "nl", "nl-BE", "en", "en-US", "en-AU", "en-GB", "en-NZ", "en-IN", + "et", "fi", "fr", "fr-CA", "de", "de-CH", "el", "hi", "hu", "id", "it", "ja", + "ko", "ko-KR", "lv", "lt", "ms", "no", "pl", "pt", "pt-BR", "pt-PT", "ro", + "ru", "sk", "es", "es-419", "sv", "sv-SE", "th", "th-TH", "tr", "uk", "vi", +} + +var elevenLabsTranscriptionLanguages = []string{ + "bel", "bos", "bul", "cat", "hrv", "ces", "dan", "nld", "eng", "est", "fin", "fra", + "glg", "deu", "ell", "hun", "isl", "ind", "ita", "jpn", "kan", "lav", "mkd", "msa", + "mal", "nor", "pol", "por", "ron", "rus", "slk", "spa", "swe", "tur", "ukr", "vie", + "hye", "aze", "ben", "yue", "fil", "kat", "guj", "hin", "kaz", "lit", "mlt", "cmn", + "mar", "nep", "ori", "fas", "srp", "slv", "swa", "tam", "tel", + "afr", "ara", "asm", "ast", "mya", "hau", "heb", "jav", "kor", "kir", "ltz", "mri", + "oci", "pan", "tgk", "tha", "uzb", "cym", + "amh", "lug", "ibo", "gle", "khm", "kur", "lao", "mon", "nso", "pus", "sna", "snd", + "som", "urd", "wol", "xho", "yor", "zul", +} diff --git a/internal/provider/mistral.go b/internal/provider/mistral.go index 20f8759..d6a0373 100644 --- a/internal/provider/mistral.go +++ b/internal/provider/mistral.go @@ -1,7 +1,5 @@ package provider -import "github.com/leonardotrapani/hyprvoice/internal/language" - // MistralProvider implements Provider for Mistral services (transcription only) type MistralProvider struct{} @@ -23,8 +21,9 @@ func (p *MistralProvider) IsLocal() bool { } func (p *MistralProvider) Models() []Model { - allLangs := language.AllLanguageCodes() - docsURL := "https://docs.mistral.ai/capabilities/speech/" + // https://docs.mistral.ai/capabilities/audio/ + allLangs := mistralTranscriptionLanguages + docsURL := "https://docs.mistral.ai/capabilities/audio/" return []Model{ { diff --git a/internal/provider/model.go b/internal/provider/model.go index 78d492b..3ea174a 100644 --- a/internal/provider/model.go +++ b/internal/provider/model.go @@ -1,7 +1,5 @@ package provider -import "github.com/leonardotrapani/hyprvoice/internal/language" - // ModelType represents the type of a model type ModelType int @@ -22,7 +20,7 @@ type Model struct { AdapterType string // which adapter to use (e.g., "openai", "elevenlabs", "whisper-cpp") StreamingAdapter string // adapter for streaming mode (if different from AdapterType) StreamingEndpoint *EndpointConfig // endpoint for streaming mode (if different from Endpoint) - SupportedLanguages []string // explicit list of supported language codes + SupportedLanguages []string // explicit list of provider language codes Endpoint *EndpointConfig // nil for local models LocalInfo *LocalModelInfo // nil for cloud models DocsURL string // URL to provider's language support documentation @@ -69,9 +67,3 @@ func (m *Model) SupportsLanguage(code string) bool { } return false } - -// SupportsAllLanguages returns true if the model supports all 57 languages -func (m *Model) SupportsAllLanguages() bool { - allCodes := language.AllLanguageCodes() - return len(m.SupportedLanguages) == len(allCodes) -} diff --git a/internal/provider/model_test.go b/internal/provider/model_test.go index b0447f6..cf0ca06 100644 --- a/internal/provider/model_test.go +++ b/internal/provider/model_test.go @@ -1,10 +1,6 @@ package provider -import ( - "testing" - - "github.com/leonardotrapani/hyprvoice/internal/language" -) +import "testing" func TestModel_NeedsDownload(t *testing.T) { tests := []struct { @@ -118,11 +114,9 @@ func TestModel_SupportsBothModes(t *testing.T) { } func TestModel_SupportsLanguage(t *testing.T) { - allCodes := language.AllLanguageCodes() - multilingualModel := Model{ ID: "whisper-large-v3", - SupportedLanguages: allCodes, + SupportedLanguages: []string{"en", "es", "zh"}, } englishOnlyModel := Model{ @@ -207,65 +201,6 @@ func TestModel_SupportsLanguage(t *testing.T) { } } -func TestModel_SupportsAllLanguages(t *testing.T) { - allCodes := language.AllLanguageCodes() - - tests := []struct { - name string - model Model - expected bool - }{ - { - name: "model with all 57 languages", - model: Model{ - ID: "whisper-large-v3", - SupportedLanguages: allCodes, - }, - expected: true, - }, - { - name: "english-only model", - model: Model{ - ID: "base.en", - SupportedLanguages: []string{"en"}, - }, - expected: false, - }, - { - name: "model with some languages", - model: Model{ - ID: "partial", - SupportedLanguages: []string{"en", "es", "fr", "de"}, - }, - expected: false, - }, - { - name: "model with empty languages", - model: Model{ - ID: "empty", - SupportedLanguages: []string{}, - }, - expected: false, - }, - { - name: "model with nil languages", - model: Model{ - ID: "nil", - SupportedLanguages: nil, - }, - expected: false, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if got := tc.model.SupportsAllLanguages(); got != tc.expected { - t.Errorf("SupportsAllLanguages() = %v, want %v", got, tc.expected) - } - }) - } -} - func TestModelType_Constants(t *testing.T) { // verify ModelType constants exist and are distinct if Transcription == LLM { @@ -387,8 +322,8 @@ func TestAllTranscriptionModels_HaveDocsURL(t *testing.T) { expectedDocsURLs := map[string]string{ "openai": "https://platform.openai.com/docs/guides/speech-to-text#supported-languages", "groq": "https://console.groq.com/docs/speech-to-text#supported-languages", - "mistral": "https://docs.mistral.ai/capabilities/speech/", - "elevenlabs": "https://elevenlabs.io/docs/capabilities/speech-to-text#supported-languages", + "mistral": "https://docs.mistral.ai/capabilities/audio/", + "elevenlabs": "https://elevenlabs.io/speech-to-text", "deepgram": "https://developers.deepgram.com/docs/language", "whisper-cpp": "https://github.com/openai/whisper#available-models-and-languages", } diff --git a/internal/provider/names.go b/internal/provider/names.go index 49d18c0..1fd1f88 100644 --- a/internal/provider/names.go +++ b/internal/provider/names.go @@ -14,7 +14,6 @@ const ( const ( ConfigProviderOpenAI = "openai" ConfigProviderGroqTranscription = "groq-transcription" - ConfigProviderGroqTranslation = "groq-translation" ConfigProviderMistralTranscription = "mistral-transcription" ConfigProviderElevenLabs = "elevenlabs" ConfigProviderDeepgram = "deepgram" @@ -44,7 +43,7 @@ const ( // e.g. "groq-transcription" -> "groq", "mistral-transcription" -> "mistral" func BaseProviderName(configProvider string) string { switch configProvider { - case ConfigProviderGroqTranscription, ConfigProviderGroqTranslation: + case ConfigProviderGroqTranscription: return ProviderGroq case ConfigProviderMistralTranscription: return ProviderMistral diff --git a/internal/provider/openai.go b/internal/provider/openai.go index 2e3a0d2..53aa3db 100644 --- a/internal/provider/openai.go +++ b/internal/provider/openai.go @@ -1,10 +1,6 @@ package provider -import ( - "strings" - - "github.com/leonardotrapani/hyprvoice/internal/language" -) +import "strings" // OpenAIProvider implements Provider for OpenAI services type OpenAIProvider struct{} @@ -26,7 +22,8 @@ func (p *OpenAIProvider) IsLocal() bool { } func (p *OpenAIProvider) Models() []Model { - allLangs := language.AllLanguageCodes() + // https://platform.openai.com/docs/guides/speech-to-text#supported-languages + allLangs := openaiTranscriptionLanguages docsURL := "https://platform.openai.com/docs/guides/speech-to-text#supported-languages" @@ -77,28 +74,26 @@ func (p *OpenAIProvider) Models() []Model { }, // LLM models { - ID: "gpt-4o-mini", - Name: "GPT-4o Mini", - Description: "Fast and affordable GPT-4 variant", - Type: LLM, - SupportsBatch: true, - SupportsStreaming: false, - Local: false, - AdapterType: AdapterOpenAI, - SupportedLanguages: allLangs, - Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/chat/completions"}, + ID: "gpt-4o-mini", + Name: "GPT-4o Mini", + Description: "Fast and affordable GPT-4 variant", + Type: LLM, + SupportsBatch: true, + SupportsStreaming: false, + Local: false, + AdapterType: AdapterOpenAI, + Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/chat/completions"}, }, { - ID: "gpt-4o", - Name: "GPT-4o", - Description: "Most capable GPT-4 model", - Type: LLM, - SupportsBatch: true, - SupportsStreaming: false, - Local: false, - AdapterType: AdapterOpenAI, - SupportedLanguages: allLangs, - Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/chat/completions"}, + ID: "gpt-4o", + Name: "GPT-4o", + Description: "Most capable GPT-4 model", + Type: LLM, + SupportsBatch: true, + SupportsStreaming: false, + Local: false, + AdapterType: AdapterOpenAI, + Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/chat/completions"}, }, } } diff --git a/internal/provider/whisper_cpp.go b/internal/provider/whisper_cpp.go index 7d466aa..e919d8e 100644 --- a/internal/provider/whisper_cpp.go +++ b/internal/provider/whisper_cpp.go @@ -1,9 +1,6 @@ package provider -import ( - "github.com/leonardotrapani/hyprvoice/internal/language" - "github.com/leonardotrapani/hyprvoice/internal/models/whisper" -) +import "github.com/leonardotrapani/hyprvoice/internal/models/whisper" // WhisperCppProvider implements Provider for local whisper.cpp transcription type WhisperCppProvider struct{} @@ -25,8 +22,10 @@ func (p *WhisperCppProvider) IsLocal() bool { } func (p *WhisperCppProvider) Models() []Model { - allLangs := language.AllLanguageCodes() - englishOnly := []string{"en"} + // https://github.com/openai/whisper#available-models-and-languages + allLangs := whisperTranscriptionLanguages + // https://github.com/openai/whisper#available-models-and-languages + englishOnly := whisperEnglishOnlyLanguages docsURL := "https://github.com/openai/whisper#available-models-and-languages" whisperModels := whisper.ListModels() diff --git a/internal/provider/whisper_cpp_test.go b/internal/provider/whisper_cpp_test.go index 189a96d..0995ed7 100644 --- a/internal/provider/whisper_cpp_test.go +++ b/internal/provider/whisper_cpp_test.go @@ -1,10 +1,6 @@ package provider -import ( - "testing" - - "github.com/leonardotrapani/hyprvoice/internal/language" -) +import "testing" func TestWhisperCppProvider_GetProvider(t *testing.T) { p := GetProvider("whisper-cpp") @@ -88,20 +84,18 @@ func TestWhisperCppProvider_MultilingualModels(t *testing.T) { "large-v3": true, } - allLangs := language.AllLanguageCodes() - for _, m := range models { isMultilingual := multilingualIDs[m.ID] if isMultilingual { - if len(m.SupportedLanguages) != len(allLangs) { - t.Errorf("model %s: expected %d languages, got %d", m.ID, len(allLangs), len(m.SupportedLanguages)) - } - if !m.SupportsAllLanguages() { - t.Errorf("model %s: SupportsAllLanguages() should be true", m.ID) + if len(m.SupportedLanguages) <= 1 { + t.Errorf("model %s: expected multiple languages, got %d", m.ID, len(m.SupportedLanguages)) } if !m.SupportsLanguage("es") { t.Errorf("model %s: SupportsLanguage('es') should be true", m.ID) } + if !m.SupportsLanguage("en") { + t.Errorf("model %s: SupportsLanguage('en') should be true", m.ID) + } } } } diff --git a/internal/transcriber/adapter_deepgram.go b/internal/transcriber/adapter_deepgram.go index 216a5d4..4e70784 100644 --- a/internal/transcriber/adapter_deepgram.go +++ b/internal/transcriber/adapter_deepgram.go @@ -7,11 +7,11 @@ import ( "log" "net/http" "net/url" + "strings" "sync" "time" "github.com/gorilla/websocket" - "github.com/leonardotrapani/hyprvoice/internal/language" "github.com/leonardotrapani/hyprvoice/internal/provider" ) @@ -21,6 +21,7 @@ type DeepgramAdapter struct { apiKey string model string language string + keywords []string conn *websocket.Conn resultsCh chan TranscriptionResult mu sync.Mutex @@ -82,13 +83,14 @@ type deepgramError struct { // endpoint: the WebSocket endpoint config (e.g., wss://api.deepgram.com, /v1/listen) // apiKey: Deepgram API key // model: model ID (e.g., "nova-3") -// lang: canonical language code (will be converted to provider format) -func NewDeepgramAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *DeepgramAdapter { +// lang: provider language code +func NewDeepgramAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *DeepgramAdapter { return &DeepgramAdapter{ endpoint: endpoint, apiKey: apiKey, model: model, language: lang, + keywords: keywords, resultsCh: make(chan TranscriptionResult, 100), maxRetries: 3, retryDelays: defaultRetryDelays, @@ -228,9 +230,12 @@ func (a *DeepgramAdapter) buildURL() (string, error) { q.Set("punctuate", "true") // add language if specified - providerLang := language.ToProviderFormat(a.language, "deepgram") - if providerLang != "" { - q.Set("language", providerLang) + if a.language != "" { + q.Set("language", a.language) + } + + if len(a.keywords) > 0 { + q.Set("keywords", strings.Join(a.keywords, ",")) } u.RawQuery = q.Encode() diff --git a/internal/transcriber/adapter_deepgram_batch.go b/internal/transcriber/adapter_deepgram_batch.go index 6b307a3..03c45c0 100644 --- a/internal/transcriber/adapter_deepgram_batch.go +++ b/internal/transcriber/adapter_deepgram_batch.go @@ -8,8 +8,8 @@ import ( "io" "net/http" "net/url" + "strings" - "github.com/leonardotrapani/hyprvoice/internal/language" "github.com/leonardotrapani/hyprvoice/internal/provider" ) @@ -19,6 +19,7 @@ type DeepgramBatchAdapter struct { apiKey string model string language string + keywords []string } // deepgramBatchResponse is the response from the pre-recorded API @@ -36,12 +37,13 @@ type deepgramBatchChannel struct { } // NewDeepgramBatchAdapter creates a new batch adapter for Deepgram -func NewDeepgramBatchAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *DeepgramBatchAdapter { +func NewDeepgramBatchAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *DeepgramBatchAdapter { return &DeepgramBatchAdapter{ endpoint: endpoint, apiKey: apiKey, model: model, language: lang, + keywords: keywords, } } @@ -116,9 +118,12 @@ func (a *DeepgramBatchAdapter) buildURL() (string, error) { q.Set("punctuate", "true") // add language if specified - providerLang := language.ToProviderFormat(a.language, "deepgram") - if providerLang != "" { - q.Set("language", providerLang) + if a.language != "" { + q.Set("language", a.language) + } + + if len(a.keywords) > 0 { + q.Set("keywords", strings.Join(a.keywords, ",")) } u.RawQuery = q.Encode() diff --git a/internal/transcriber/adapter_deepgram_test.go b/internal/transcriber/adapter_deepgram_test.go index b31110f..7008073 100644 --- a/internal/transcriber/adapter_deepgram_test.go +++ b/internal/transcriber/adapter_deepgram_test.go @@ -23,7 +23,7 @@ func TestDeepgramAdapter_Creation(t *testing.T) { BaseURL: "wss://api.deepgram.com", Path: "/v1/listen", } - adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en") + adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil) if adapter.apiKey != "test-api-key" { t.Errorf("apiKey = %q, want %q", adapter.apiKey, "test-api-key") @@ -72,7 +72,7 @@ func TestDeepgramAdapter_BuildURL(t *testing.T) { BaseURL: "wss://api.deepgram.com", Path: "/v1/listen", } - adapter := NewDeepgramAdapter(endpoint, "test-key", tt.model, tt.language) + adapter := NewDeepgramAdapter(endpoint, "test-key", tt.model, tt.language, nil) url, err := adapter.buildURL() if err != nil { @@ -93,7 +93,7 @@ func TestDeepgramAdapter_SendChunkNotStarted(t *testing.T) { BaseURL: "wss://api.deepgram.com", Path: "/v1/listen", } - adapter := NewDeepgramAdapter(endpoint, "test-key", "nova-3", "en") + adapter := NewDeepgramAdapter(endpoint, "test-key", "nova-3", "en", nil) err := adapter.SendChunk([]byte("audio data")) if err == nil { @@ -109,7 +109,7 @@ func TestDeepgramAdapter_CloseNotStarted(t *testing.T) { BaseURL: "wss://api.deepgram.com", Path: "/v1/listen", } - adapter := NewDeepgramAdapter(endpoint, "test-key", "nova-3", "en") + adapter := NewDeepgramAdapter(endpoint, "test-key", "nova-3", "en", nil) // closing not-started adapter should not error err := adapter.Close() @@ -173,7 +173,7 @@ func TestDeepgramAdapter_StartAndClose(t *testing.T) { Path: "", } - adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en") + adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil) ctx := context.Background() if err := adapter.Start(ctx, ""); err != nil { @@ -229,7 +229,7 @@ func TestDeepgramAdapter_ReceivesResults(t *testing.T) { wsURL := "ws" + strings.TrimPrefix(server.URL, "http") endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""} - adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en") + adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil) ctx := context.Background() if err := adapter.Start(ctx, ""); err != nil { @@ -312,7 +312,7 @@ func TestDeepgramAdapter_SendsRawBinaryAudio(t *testing.T) { wsURL := "ws" + strings.TrimPrefix(server.URL, "http") endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""} - adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en") + adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil) ctx := context.Background() if err := adapter.Start(ctx, ""); err != nil { @@ -357,7 +357,7 @@ func TestDeepgramAdapter_HandlesError(t *testing.T) { wsURL := "ws" + strings.TrimPrefix(server.URL, "http") endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""} - adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en") + adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil) ctx := context.Background() if err := adapter.Start(ctx, ""); err != nil { @@ -398,7 +398,7 @@ func TestDeepgramAdapter_ContextCancellation(t *testing.T) { wsURL := "ws" + strings.TrimPrefix(server.URL, "http") endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""} - adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en") + adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil) ctx, cancel := context.WithCancel(context.Background()) if err := adapter.Start(ctx, ""); err != nil { diff --git a/internal/transcriber/adapter_elevenlabs.go b/internal/transcriber/adapter_elevenlabs.go index 994417b..680c7b4 100644 --- a/internal/transcriber/adapter_elevenlabs.go +++ b/internal/transcriber/adapter_elevenlabs.go @@ -11,7 +11,6 @@ import ( "net/http" "time" - "github.com/leonardotrapani/hyprvoice/internal/language" "github.com/leonardotrapani/hyprvoice/internal/provider" ) @@ -22,6 +21,7 @@ type ElevenLabsAdapter struct { apiKey string model string language string + keywords []string } // ElevenLabsResponse represents the API response @@ -33,14 +33,15 @@ type ElevenLabsResponse struct { // endpoint: the endpoint config (BaseURL + Path) // apiKey: ElevenLabs API key // model: model ID (e.g., "scribe_v1") -// lang: canonical language code (will be converted to provider format) -func NewElevenLabsAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *ElevenLabsAdapter { +// lang: provider language code +func NewElevenLabsAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *ElevenLabsAdapter { return &ElevenLabsAdapter{ client: &http.Client{Timeout: 30 * time.Second}, endpoint: endpoint, apiKey: apiKey, model: model, language: lang, + keywords: keywords, } } @@ -52,6 +53,7 @@ func NewElevenLabsAdapterFromConfig(config Config) *ElevenLabsAdapter { config.APIKey, config.Model, config.Language, + config.Keywords, ) } @@ -85,14 +87,23 @@ func (a *ElevenLabsAdapter) Transcribe(ctx context.Context, audioData []byte) (s return "", fmt.Errorf("write model_id: %w", err) } - // Add language_code if specified (convert to provider format) - providerLang := language.ToProviderFormat(a.language, "elevenlabs") - if providerLang != "" { - if err := writer.WriteField("language_code", providerLang); err != nil { + // Add language_code if specified + if a.language != "" { + if err := writer.WriteField("language_code", a.language); err != nil { return "", fmt.Errorf("write language_code: %w", err) } } + if len(a.keywords) > 0 { + keytermsJSON, err := json.Marshal(a.keywords) + if err != nil { + return "", fmt.Errorf("marshal keyterms: %w", err) + } + if err := writer.WriteField("keyterms", string(keytermsJSON)); err != nil { + return "", fmt.Errorf("write keyterms: %w", err) + } + } + if err := writer.Close(); err != nil { return "", fmt.Errorf("close writer: %w", err) } diff --git a/internal/transcriber/adapter_elevenlabs_streaming.go b/internal/transcriber/adapter_elevenlabs_streaming.go index 36ede29..641245b 100644 --- a/internal/transcriber/adapter_elevenlabs_streaming.go +++ b/internal/transcriber/adapter_elevenlabs_streaming.go @@ -8,11 +8,11 @@ import ( "log" "net/http" "net/url" + "strings" "sync" "time" "github.com/gorilla/websocket" - "github.com/leonardotrapani/hyprvoice/internal/language" "github.com/leonardotrapani/hyprvoice/internal/provider" ) @@ -25,6 +25,7 @@ type ElevenLabsStreamingAdapter struct { apiKey string model string language string + keywords []string conn *websocket.Conn resultsCh chan TranscriptionResult mu sync.Mutex @@ -38,15 +39,17 @@ type ElevenLabsStreamingAdapter struct { retryDelays []time.Duration // finalization signaling - commitDone chan struct{} + commitDone chan struct{} + contextSent bool } // ElevenLabs WebSocket message types (outgoing) type elevenLabsInputAudioChunk struct { - MessageType string `json:"message_type"` - AudioBase64 string `json:"audio_base_64"` - Commit bool `json:"commit"` - SampleRate int `json:"sample_rate"` + MessageType string `json:"message_type"` + AudioBase64 string `json:"audio_base_64"` + Commit bool `json:"commit"` + SampleRate int `json:"sample_rate"` + PreviousText string `json:"previous_text,omitempty"` } // ElevenLabs WebSocket response types (incoming) @@ -62,13 +65,14 @@ type elevenLabsWSMessage struct { // endpoint: the WebSocket endpoint config (e.g., wss://api.elevenlabs.io, /v1/speech-to-text/realtime) // apiKey: ElevenLabs API key // model: model ID (e.g., "scribe_v1") -// lang: canonical language code (will be converted to provider format) -func NewElevenLabsStreamingAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *ElevenLabsStreamingAdapter { +// lang: provider language code +func NewElevenLabsStreamingAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *ElevenLabsStreamingAdapter { return &ElevenLabsStreamingAdapter{ endpoint: endpoint, apiKey: apiKey, model: model, language: lang, + keywords: keywords, resultsCh: make(chan TranscriptionResult, 100), maxRetries: 3, retryDelays: defaultRetryDelays, @@ -126,6 +130,7 @@ func (a *ElevenLabsStreamingAdapter) connectLocked() error { return fmt.Errorf("websocket dial: %w", err) } a.conn = conn + a.contextSent = false return nil } @@ -199,9 +204,8 @@ func (a *ElevenLabsStreamingAdapter) buildURL() (string, error) { q.Set("audio_format", "pcm_16000") // we use 16kHz PCM // add language if specified - providerLang := language.ToProviderFormat(a.language, "elevenlabs") - if providerLang != "" { - q.Set("language_code", providerLang) + if a.language != "" { + q.Set("language_code", a.language) } // use VAD for automatic commit (easier for real-time use) @@ -334,6 +338,13 @@ func (a *ElevenLabsStreamingAdapter) SendChunk(audio []byte) error { SampleRate: 16000, } + a.mu.Lock() + if !a.contextSent && len(a.keywords) > 0 { + msg.PreviousText = strings.Join(a.keywords, ", ") + a.contextSent = true + } + a.mu.Unlock() + // send as JSON a.mu.Lock() err := a.conn.WriteJSON(msg) diff --git a/internal/transcriber/adapter_elevenlabs_streaming_test.go b/internal/transcriber/adapter_elevenlabs_streaming_test.go index cb0bf17..18a8b04 100644 --- a/internal/transcriber/adapter_elevenlabs_streaming_test.go +++ b/internal/transcriber/adapter_elevenlabs_streaming_test.go @@ -72,6 +72,7 @@ func TestElevenLabsStreamingAdapter_Start(t *testing.T) { "test-api-key", "scribe_v1", "en", + nil, ) ctx := context.Background() @@ -126,6 +127,7 @@ func TestElevenLabsStreamingAdapter_SendChunk(t *testing.T) { "test-api-key", "scribe_v1", "en", + nil, ) ctx := context.Background() @@ -188,6 +190,7 @@ func TestElevenLabsStreamingAdapter_Results(t *testing.T) { "test-api-key", "scribe_v1", "en", + nil, ) ctx := context.Background() @@ -262,6 +265,7 @@ func TestElevenLabsStreamingAdapter_ErrorMessages(t *testing.T) { "test-api-key", "scribe_v1", "en", + nil, ) ctx := context.Background() @@ -325,6 +329,7 @@ func TestElevenLabsStreamingAdapter_LanguageConversion(t *testing.T) { "test-api-key", "scribe_v1", "es", // Spanish + nil, ) ctx := context.Background() @@ -372,6 +377,7 @@ func TestElevenLabsStreamingAdapter_Close(t *testing.T) { "test-api-key", "scribe_v1", "en", + nil, ) ctx := context.Background() @@ -408,6 +414,7 @@ func TestElevenLabsStreamingAdapter_NotStarted(t *testing.T) { "test-api-key", "scribe_v1", "en", + nil, ) // SendChunk should fail when not started @@ -470,6 +477,7 @@ func TestElevenLabsStreamingAdapter_ReconnectOnReadError(t *testing.T) { "test-api-key", "scribe_v1", "en", + nil, ) // use very short delays for testing adapter.retryDelays = []time.Duration{10 * time.Millisecond, 20 * time.Millisecond, 40 * time.Millisecond} @@ -547,6 +555,7 @@ func TestElevenLabsStreamingAdapter_ReconnectNotifiesClient(t *testing.T) { "test-api-key", "scribe_v1", "en", + nil, ) adapter.retryDelays = []time.Duration{10 * time.Millisecond, 20 * time.Millisecond, 40 * time.Millisecond} @@ -633,6 +642,7 @@ func TestElevenLabsStreamingAdapter_MaxRetriesExhausted(t *testing.T) { "test-api-key", "scribe_v1", "en", + nil, ) adapter.retryDelays = []time.Duration{5 * time.Millisecond, 10 * time.Millisecond, 15 * time.Millisecond} adapter.maxRetries = 2 @@ -709,6 +719,7 @@ func TestElevenLabsStreamingAdapter_ReconnectExponentialBackoff(t *testing.T) { "test-api-key", "scribe_v1", "en", + nil, ) // use measurable delays adapter.retryDelays = []time.Duration{50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond} diff --git a/internal/transcriber/adapter_elevenlabs_test.go b/internal/transcriber/adapter_elevenlabs_test.go index 1d50b6d..cdbe914 100644 --- a/internal/transcriber/adapter_elevenlabs_test.go +++ b/internal/transcriber/adapter_elevenlabs_test.go @@ -13,7 +13,7 @@ func TestNewElevenLabsAdapter(t *testing.T) { Path: "/v1/speech-to-text", } - adapter := NewElevenLabsAdapter(endpoint, "test-api-key", "scribe_v1", "en") + adapter := NewElevenLabsAdapter(endpoint, "test-api-key", "scribe_v1", "en", nil) if adapter == nil { t.Fatalf("NewElevenLabsAdapter() returned nil") @@ -74,7 +74,7 @@ func TestElevenLabsAdapter_Transcribe_EmptyAudio(t *testing.T) { Path: "/v1/speech-to-text", } - adapter := NewElevenLabsAdapter(endpoint, "test-key", "scribe_v1", "") + adapter := NewElevenLabsAdapter(endpoint, "test-key", "scribe_v1", "", nil) ctx := context.Background() result, err := adapter.Transcribe(ctx, []byte{}) @@ -94,7 +94,7 @@ func TestElevenLabsAdapter_Transcribe_ValidAudio(t *testing.T) { Path: "/v1/speech-to-text", } - adapter := NewElevenLabsAdapter(endpoint, "test-key", "scribe_v1", "en") + adapter := NewElevenLabsAdapter(endpoint, "test-key", "scribe_v1", "en", nil) if adapter == nil { t.Fatal("NewElevenLabsAdapter() returned nil") diff --git a/internal/transcriber/adapter_groq_translation.go b/internal/transcriber/adapter_groq_translation.go deleted file mode 100644 index 4e6a2f2..0000000 --- a/internal/transcriber/adapter_groq_translation.go +++ /dev/null @@ -1,69 +0,0 @@ -package transcriber - -import ( - "bytes" - "context" - "fmt" - "log" - "strings" - "time" - - "github.com/sashabaranov/go-openai" -) - -// GroqTranslationAdapter implements BatchAdapter for Groq Translation API -// Translates audio to English text. The Language field in config hints at the source language. -type GroqTranslationAdapter struct { - client *openai.Client - config Config -} - -func NewGroqTranslationAdapter(config Config) *GroqTranslationAdapter { - clientConfig := openai.DefaultConfig(config.APIKey) - clientConfig.BaseURL = "https://api.groq.com/openai/v1" - client := openai.NewClientWithConfig(clientConfig) - - return &GroqTranslationAdapter{ - client: client, - config: config, - } -} - -func (a *GroqTranslationAdapter) Transcribe(ctx context.Context, audioData []byte) (string, error) { - if len(audioData) == 0 { - return "", nil - } - - // Convert raw PCM to WAV format - wavData, err := convertToWAV(audioData) - if err != nil { - return "", fmt.Errorf("convert to WAV: %w", err) - } - - // Create translation request - // Note: Translation always outputs English, regardless of target language - // The Language field in the request hints at the source audio language for better accuracy - req := openai.AudioRequest{ - Model: a.config.Model, - Reader: bytes.NewReader(wavData), - FilePath: "audio.wav", - Language: a.config.Language, // Source language hint - } - - // Add keywords as prompt to help with spelling hints - if len(a.config.Keywords) > 0 { - req.Prompt = strings.Join(a.config.Keywords, ", ") - } - - start := time.Now() - resp, err := a.client.CreateTranslation(ctx, req) - duration := time.Since(start) - - if err != nil { - log.Printf("groq-translation-adapter: API call failed after %v: %v", duration, err) - return "", fmt.Errorf("groq translation: %w", err) - } - - log.Printf("groq-translation-adapter: translated %d bytes in %v: %q", len(audioData), duration, resp.Text) - return resp.Text, nil -} diff --git a/internal/transcriber/adapter_openai.go b/internal/transcriber/adapter_openai.go index dfb99c5..6e76074 100644 --- a/internal/transcriber/adapter_openai.go +++ b/internal/transcriber/adapter_openai.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/leonardotrapani/hyprvoice/internal/language" "github.com/leonardotrapani/hyprvoice/internal/provider" "github.com/sashabaranov/go-openai" ) @@ -27,7 +26,7 @@ type OpenAIAdapter struct { // endpoint: the BaseURL for the API (e.g., "https://api.openai.com", "https://api.groq.com/openai") // apiKey: the API key for authentication // model: model ID to use -// lang: canonical language code (will be converted to provider format) +// lang: provider language code // keywords: optional spelling hints // providerName: used for logging and language format conversion func NewOpenAIAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string, providerName string) *OpenAIAdapter { @@ -69,15 +68,12 @@ func (a *OpenAIAdapter) Transcribe(ctx context.Context, audioData []byte) (strin return "", fmt.Errorf("convert to WAV: %w", err) } - // Convert language code to provider format - providerLang := language.ToProviderFormat(a.language, a.providerName) - // Create transcription request req := openai.AudioRequest{ Model: a.model, Reader: bytes.NewReader(wavData), FilePath: "audio.wav", - Language: providerLang, + Language: a.language, } // Add keywords as initial_prompt to help with spelling hints diff --git a/internal/transcriber/adapter_openai_realtime.go b/internal/transcriber/adapter_openai_realtime.go index 47122c2..cac5c9e 100644 --- a/internal/transcriber/adapter_openai_realtime.go +++ b/internal/transcriber/adapter_openai_realtime.go @@ -8,6 +8,7 @@ import ( "log" "net/http" "net/url" + "strings" "sync" "time" @@ -21,6 +22,7 @@ type OpenAIRealtimeAdapter struct { apiKey string model string language string + keywords []string conn *websocket.Conn resultsCh chan TranscriptionResult mu sync.Mutex @@ -56,6 +58,7 @@ type openaiRealtimeSessionConfig struct { type openaiRealtimeTranscription struct { Model string `json:"model,omitempty"` Language string `json:"language,omitempty"` + Prompt string `json:"prompt,omitempty"` } type openaiRealtimeTurnDetection struct { @@ -104,12 +107,13 @@ type openaiRealtimeError struct { // apiKey: OpenAI API key // model: model ID (e.g., "gpt-4o-realtime-preview") // lang: canonical language code (will be used for transcription config) -func NewOpenAIRealtimeAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *OpenAIRealtimeAdapter { +func NewOpenAIRealtimeAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *OpenAIRealtimeAdapter { return &OpenAIRealtimeAdapter{ endpoint: endpoint, apiKey: apiKey, model: model, language: lang, + keywords: keywords, resultsCh: make(chan TranscriptionResult, 100), maxRetries: 3, retryDelays: defaultRetryDelays, @@ -206,6 +210,10 @@ func (a *OpenAIRealtimeAdapter) configureSession() error { sessionUpdate.Session.InputAudioTranscription.Language = a.language } + if len(a.keywords) > 0 { + sessionUpdate.Session.InputAudioTranscription.Prompt = strings.Join(a.keywords, ", ") + } + return a.conn.WriteJSON(sessionUpdate) } diff --git a/internal/transcriber/adapter_openai_realtime_test.go b/internal/transcriber/adapter_openai_realtime_test.go index be0c5ca..89d032d 100644 --- a/internal/transcriber/adapter_openai_realtime_test.go +++ b/internal/transcriber/adapter_openai_realtime_test.go @@ -118,7 +118,7 @@ func TestOpenAIRealtimeAdapter_Start(t *testing.T) { Path: "", } - adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test-key", "gpt-4o-realtime-preview", "en") + adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test-key", "gpt-4o-realtime-preview", "en", nil) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -198,7 +198,7 @@ func TestOpenAIRealtimeAdapter_SendChunk(t *testing.T) { wsURL := "ws" + strings.TrimPrefix(server.URL, "http") endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""} - adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "") + adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "", nil) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -282,7 +282,7 @@ func TestOpenAIRealtimeAdapter_TranscriptionResults(t *testing.T) { wsURL := "ws" + strings.TrimPrefix(server.URL, "http") endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""} - adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "en") + adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "en", nil) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() @@ -369,7 +369,7 @@ func TestOpenAIRealtimeAdapter_ErrorHandling(t *testing.T) { wsURL := "ws" + strings.TrimPrefix(server.URL, "http") endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""} - adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "") + adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "", nil) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -433,7 +433,7 @@ func TestOpenAIRealtimeAdapter_Reconnection(t *testing.T) { wsURL := "ws" + strings.TrimPrefix(server.URL, "http") endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""} - adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "") + adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "", nil) adapter.retryDelays = []time.Duration{10 * time.Millisecond, 20 * time.Millisecond, 40 * time.Millisecond} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -473,7 +473,7 @@ func TestOpenAIRealtimeAdapter_Close(t *testing.T) { wsURL := "ws" + strings.TrimPrefix(server.URL, "http") endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""} - adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "") + adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "", nil) ctx := context.Background() diff --git a/internal/transcriber/adapter_whisper_cpp.go b/internal/transcriber/adapter_whisper_cpp.go index 75e2754..d32196e 100644 --- a/internal/transcriber/adapter_whisper_cpp.go +++ b/internal/transcriber/adapter_whisper_cpp.go @@ -10,8 +10,6 @@ import ( "path/filepath" "strings" "time" - - "github.com/leonardotrapani/hyprvoice/internal/language" ) // WhisperCppAdapter implements BatchAdapter for local whisper-cpp transcription @@ -23,7 +21,7 @@ type WhisperCppAdapter struct { // NewWhisperCppAdapter creates a new whisper-cpp adapter // modelPath: full path to the model file (e.g., ~/.local/share/hyprvoice/models/whisper/ggml-base.en.bin) -// lang: canonical language code (will be converted to whisper-cpp format) +// lang: whisper-cpp language code // threads: number of CPU threads (0 for auto) func NewWhisperCppAdapter(modelPath, lang string, threads int) *WhisperCppAdapter { return &WhisperCppAdapter{ @@ -63,8 +61,11 @@ func (a *WhisperCppAdapter) Transcribe(ctx context.Context, audioData []byte) (s } defer os.Remove(tmpFile) - // convert language to whisper-cpp format - lang := language.ToProviderFormat(a.language, "whisper-cpp") + // use whisper-cpp auto if unspecified + lang := a.language + if lang == "" { + lang = "auto" + } // build command args args := []string{ diff --git a/internal/transcriber/transcriber.go b/internal/transcriber/transcriber.go index 0c713b0..cc194af 100644 --- a/internal/transcriber/transcriber.go +++ b/internal/transcriber/transcriber.go @@ -8,7 +8,6 @@ import ( "golang.org/x/text/cases" "golang.org/x/text/language" - lang "github.com/leonardotrapani/hyprvoice/internal/language" "github.com/leonardotrapani/hyprvoice/internal/models/whisper" "github.com/leonardotrapani/hyprvoice/internal/provider" "github.com/leonardotrapani/hyprvoice/internal/recording" @@ -43,15 +42,6 @@ func NewTranscriber(config Config) (Transcriber, error) { return nil, fmt.Errorf("provider is required") } - // special case: groq-translation uses CreateTranslation API (different from transcription) - if config.Provider == provider.ConfigProviderGroqTranslation { - if config.APIKey == "" { - return nil, fmt.Errorf("Groq API key required") - } - adapter := NewGroqTranslationAdapter(config) - return NewSimpleTranscriber(config, adapter), nil - } - // map config provider name to registry provider name registryProvider := provider.BaseProviderName(config.Provider) @@ -89,8 +79,7 @@ func NewTranscriber(config Config) (Transcriber, error) { // runtime language-model compatibility check with fallback // primary validation happens at config time (hard error), this is a safety net if config.Language != "" && !model.SupportsLanguage(config.Language) { - langName := lang.FromCode(config.Language).Name - log.Printf("warning: model %s does not support language %s, falling back to auto-detect", model.ID, langName) + log.Printf("warning: model %s does not support language %s, falling back to auto-detect", model.ID, config.Language) config.Language = "" } @@ -119,11 +108,11 @@ func NewTranscriber(config Config) (Transcriber, error) { var streamingAdapter StreamingAdapter switch adapterType { case provider.AdapterElevenLabsStream: - streamingAdapter = NewElevenLabsStreamingAdapter(endpoint, config.APIKey, model.ID, config.Language) + streamingAdapter = NewElevenLabsStreamingAdapter(endpoint, config.APIKey, model.ID, config.Language, config.Keywords) case provider.AdapterDeepgram: - streamingAdapter = NewDeepgramAdapter(endpoint, config.APIKey, model.ID, config.Language) + streamingAdapter = NewDeepgramAdapter(endpoint, config.APIKey, model.ID, config.Language, config.Keywords) case provider.AdapterOpenAIRealtime: - streamingAdapter = NewOpenAIRealtimeAdapter(endpoint, config.APIKey, model.ID, config.Language) + streamingAdapter = NewOpenAIRealtimeAdapter(endpoint, config.APIKey, model.ID, config.Language, config.Keywords) default: return nil, fmt.Errorf("unsupported streaming adapter type: %s", adapterType) } @@ -136,9 +125,9 @@ func NewTranscriber(config Config) (Transcriber, error) { case provider.AdapterOpenAI: adapter = NewOpenAIAdapter(model.Endpoint, config.APIKey, model.ID, config.Language, config.Keywords, registryProvider) case provider.AdapterElevenLabs: - adapter = NewElevenLabsAdapter(model.Endpoint, config.APIKey, model.ID, config.Language) + adapter = NewElevenLabsAdapter(model.Endpoint, config.APIKey, model.ID, config.Language, config.Keywords) case provider.AdapterDeepgram: - adapter = NewDeepgramBatchAdapter(model.Endpoint, config.APIKey, model.ID, config.Language) + adapter = NewDeepgramBatchAdapter(model.Endpoint, config.APIKey, model.ID, config.Language, config.Keywords) case provider.AdapterWhisperCpp: modelPath := whisper.GetModelPath(config.Model) if modelPath == "" { diff --git a/internal/transcriber/transcriber_test.go b/internal/transcriber/transcriber_test.go index 741648a..edcc43f 100644 --- a/internal/transcriber/transcriber_test.go +++ b/internal/transcriber/transcriber_test.go @@ -56,26 +56,6 @@ func TestNewTranscriber(t *testing.T) { }, wantErr: true, }, - { - name: "valid groq-translation config", - config: Config{ - Provider: "groq-translation", - APIKey: "gsk-test-key", - Language: "es", - Model: "whisper-large-v3-turbo", - }, - wantErr: false, - }, - { - name: "groq-translation config without api key", - config: Config{ - Provider: "groq-translation", - APIKey: "", - Language: "es", - Model: "whisper-large-v3-turbo", - }, - wantErr: true, - }, { name: "valid mistral-transcription config", config: Config{ diff --git a/internal/tui/configure_llm.go b/internal/tui/configure_llm.go index da4de70..f2526ca 100644 --- a/internal/tui/configure_llm.go +++ b/internal/tui/configure_llm.go @@ -37,7 +37,7 @@ func editLLM(cfg *config.Config, configuredProviders []string) ([]string, error) enableLLM := cfg.LLM.Enabled - enableDesc := "LLM improves transcription by fixing grammar, removing stutters, and cleaning up text" + enableDesc := "LLM improves transcription by fixing grammar, removing stutters, and cleaning up text. Recommended for weak voice models" if cfg.LLM.Enabled { enableDesc = fmt.Sprintf("Currently: enabled (%s/%s). %s", cfg.LLM.Provider, cfg.LLM.Model, enableDesc) } else { diff --git a/internal/tui/configure_providers.go b/internal/tui/configure_providers.go index 98737b9..9ab8a47 100644 --- a/internal/tui/configure_providers.go +++ b/internal/tui/configure_providers.go @@ -47,6 +47,7 @@ func editProviders(cfg *config.Config, onboarding bool) error { for { var options []huh.Option[string] + options = append(options, huh.NewOption("Local", "local")) for _, name := range AllProviders { options = append(options, huh.NewOption(formatProviderOption(cfg, name), name)) } @@ -75,6 +76,13 @@ func editProviders(cfg *config.Config, onboarding bool) error { return nil } + if selected == "local" { + if err := showLocalProviderInfo(); err != nil { + continue + } + return nil + } + apiKey, err := configureSingleProvider(cfg, selected) if err != nil { continue @@ -90,6 +98,25 @@ func editProviders(cfg *config.Config, onboarding bool) error { } } +func showLocalProviderInfo() error { + selected := "done" + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Local Models"). + Description("No need to configure any API keys for local models, go to the next step."). + Options(huh.NewOption("Done", "done")). + Value(&selected), + ), + ).WithTheme(getTheme()) + + if err := form.Run(); err != nil { + return err + } + + return nil +} + // formatProviderOption formats a provider menu option with status func formatProviderOption(cfg *config.Config, name string) string { var status string @@ -190,7 +217,7 @@ func inputAPIKey(providerName string) (string, error) { func ensureProviderConfigured(cfg *config.Config, selectedProvider string, configuredProviders []string) []string { providerName := selectedProvider switch selectedProvider { - case "groq-transcription", "groq-translation": + case "groq-transcription": providerName = "groq" case "mistral-transcription": providerName = "mistral" diff --git a/internal/tui/configure_transcription.go b/internal/tui/configure_transcription.go index 5865f99..3353e79 100644 --- a/internal/tui/configure_transcription.go +++ b/internal/tui/configure_transcription.go @@ -35,8 +35,7 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri 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")) + huh.NewOption("Groq Whisper", "groq-transcription")) case "mistral": transcriptionOptions = append(transcriptionOptions, huh.NewOption("Mistral Voxtral", "mistral-transcription")) @@ -210,25 +209,37 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri return configuredProviders, err } - 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 + if cfg.Transcription.Language != "" && !model.SupportsLanguage(cfg.Transcription.Language) { + cfg.Transcription.Language = "" } - cfg.Transcription.Language = selectedLanguage + if len(model.SupportedLanguages) <= 1 { + if len(model.SupportedLanguages) == 1 { + cfg.Transcription.Language = model.SupportedLanguages[0] + } else { + cfg.Transcription.Language = "" + } + } else { + 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() { @@ -271,8 +282,7 @@ func getUnconfiguredTranscriptionOptions(configuredProviders []string) []huh.Opt } if !configured["groq"] { options = append(options, - huh.NewOption("Groq Whisper transcription (not configured)", "groq-transcription"), - huh.NewOption("Groq Whisper translation (not configured)", "groq-translation")) + huh.NewOption("Groq Whisper (not configured)", "groq-transcription")) } if !configured["mistral"] { options = append(options, huh.NewOption("Mistral Voxtral (not configured)", "mistral-transcription")) @@ -284,13 +294,6 @@ func getUnconfiguredTranscriptionOptions(configuredProviders []string) []huh.Opt } func getTranscriptionModelOptions(configProvider string) []huh.Option[string] { - // special case: groq-translation only supports whisper-large-v3 - if configProvider == "groq-translation" { - return []huh.Option[string]{ - huh.NewOption("whisper-large-v3 (only option)", "whisper-large-v3"), - } - } - // map config provider name to registry provider name registryName := mapConfigProviderToRegistry(configProvider) p := provider.GetProvider(registryName) @@ -319,7 +322,7 @@ func getTranscriptionModelOptions(configProvider string) []huh.Option[string] { // mapConfigProviderToRegistry maps config provider names to registry provider names func mapConfigProviderToRegistry(configProvider string) string { switch configProvider { - case "groq-transcription", "groq-translation": + case "groq-transcription": return "groq" case "mistral-transcription": return "mistral" diff --git a/internal/tui/languages.go b/internal/tui/languages.go index c381eab..ca2d33b 100644 --- a/internal/tui/languages.go +++ b/internal/tui/languages.go @@ -1,10 +1,7 @@ package tui import ( - "fmt" - "github.com/charmbracelet/huh" - "github.com/leonardotrapani/hyprvoice/internal/language" "github.com/leonardotrapani/hyprvoice/internal/provider" ) @@ -13,33 +10,23 @@ func getModelLanguageOptions(model *provider.Model, currentLang string) []huh.Op var options []huh.Option[string] // auto-detect is always first - autoLabel := "Auto-detect" + autoLabel := "Auto-detect (recommended)" if currentLang == "" { autoLabel += " (current)" } options = append(options, huh.NewOption(autoLabel, "")) - // only show languages supported by the model - for _, lang := range language.List() { - if model != nil && !model.SupportsLanguage(lang.Code) { - continue - } + if model == nil { + return options + } - label := formatLanguageLabel(lang) - if lang.Code == currentLang { + for _, code := range model.SupportedLanguages { + label := code + if code == currentLang { label += " (current)" } - - options = append(options, huh.NewOption(label, lang.Code)) + options = append(options, huh.NewOption(label, code)) } return options } - -// formatLanguageLabel formats a language for display -func formatLanguageLabel(lang language.Language) string { - if lang.Name == lang.NativeName || lang.NativeName == "" { - return fmt.Sprintf("%s (%s)", lang.Name, lang.Code) - } - return fmt.Sprintf("%s - %s (%s)", lang.Name, lang.NativeName, lang.Code) -}