feat: better language selection

This commit is contained in:
leonardotrapani
2026-02-01 18:53:44 +01:00
parent 0025bf97b6
commit 3ae86f7bae
40 changed files with 452 additions and 852 deletions
+18 -2
View File
@@ -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 build -o hyprvoice ./cmd/hyprvoice
- go run ./cmd/hyprvoice - go run ./cmd/hyprvoice
## Where to look ## Main structure (short)
- docs/structure.md: architecture and code map - 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/config.md: config reference and paths
- docs/providers.md: provider and model details - docs/providers.md: provider and model details
- packaging/RELEASE.md: release and AUR workflow - packaging/RELEASE.md: release and AUR workflow
+10 -6
View File
@@ -183,12 +183,12 @@ func runConfigure(onboarding bool) error {
fmt.Println() fmt.Println()
// Show next steps // Show next steps
showNextSteps(result.Config) showNextSteps(result.Config, onboarding)
return nil return nil
} }
func showNextSteps(cfg *config.Config) { func showNextSteps(cfg *config.Config, onboarding bool) {
// Check if service is running // Check if service is running
serviceRunning := false serviceRunning := false
if _, err := exec.Command("systemctl", "--user", "is-active", "--quiet", "hyprvoice.service").CombinedOutput(); err == nil { 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) fmt.Printf("%d. Ensure ydotoold is running\n", step)
step++ step++
} }
if !serviceRunning { if serviceRunning {
fmt.Printf("%d. Start the service: systemctl --user start hyprvoice.service\n", step)
} else {
fmt.Printf("%d. Restart the service to apply changes: systemctl --user restart hyprvoice.service\n", step) 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.Printf("%d. Test voice input: hyprvoice toggle\n", step)
fmt.Println() fmt.Println()
+110
View File
@@ -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.
+1 -19
View File
@@ -93,24 +93,6 @@ language = "" # Empty for auto-detect, or "en", "es", "fr", et
- Supports 50+ languages - Supports 50+ languages
- Free tier available with generous limits - 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 ### Mistral Voxtral
Transcription using Mistral's Voxtral API, excellent for European languages: Transcription using Mistral's Voxtral API, excellent for European languages:
@@ -384,7 +366,7 @@ keywords = ["Hyprland", "Wayland", "PipeWire", "Claude", "TypeScript"]
**How keywords work:** **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 - **LLM**: Included in the system prompt to ensure correct spelling
**When to use keywords:** **When to use keywords:**
+1 -1
View File
@@ -11,6 +11,7 @@ require (
github.com/muesli/termenv v0.16.0 github.com/muesli/termenv v0.16.0
github.com/sashabaranov/go-openai v1.41.1 github.com/sashabaranov/go-openai v1.41.1
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
golang.org/x/text v0.23.0
) )
require ( require (
@@ -38,5 +39,4 @@ require (
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.23.0 // indirect
) )
-67
View File
@@ -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) { func TestConfig_Validate_GroqInvalidModel(t *testing.T) {
config := &Config{ config := &Config{
Recording: RecordingConfig{ 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) { func TestMessagesConfig_Resolve_Defaults(t *testing.T) {
cfg := createTestConfig() cfg := createTestConfig()
msgs := cfg.Notifications.Messages.Resolve() msgs := cfg.Notifications.Messages.Resolve()
+1 -1
View File
@@ -104,7 +104,7 @@ func (c *Config) migrateTranscriptionAPIKey(apiKey string) {
switch providerName { switch providerName {
case "openai": case "openai":
c.Providers["openai"] = ProviderConfig{APIKey: apiKey} c.Providers["openai"] = ProviderConfig{APIKey: apiKey}
case "groq-transcription", "groq-translation": case "groq-transcription":
c.Providers["groq"] = ProviderConfig{APIKey: apiKey} c.Providers["groq"] = ProviderConfig{APIKey: apiKey}
case "mistral-transcription": case "mistral-transcription":
c.Providers["mistral"] = ProviderConfig{APIKey: apiKey} c.Providers["mistral"] = ProviderConfig{APIKey: apiKey}
+1 -2
View File
@@ -246,7 +246,7 @@ keywords = []
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
[transcription] [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" model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1"
language = "" # ISO 639-1 code (e.g., en, es, de). Empty for auto-detect. language = "" # ISO 639-1 code (e.g., en, es, de). Empty for auto-detect.
threads = 0 # CPU threads for local transcription (0 = auto: uses NumCPU-1) threads = 0 # CPU threads for local transcription (0 = auto: uses NumCPU-1)
@@ -325,7 +325,6 @@ keywords = []
# Transcription providers: # Transcription providers:
# - "openai": OpenAI Whisper API (cloud-based, excellent accuracy) # - "openai": OpenAI Whisper API (cloud-based, excellent accuracy)
# - "groq-transcription": Groq Whisper API (very fast, models: whisper-large-v3, whisper-large-v3-turbo) # - "groq-transcription": Groq Whisper API (very fast, models: whisper-large-v3, whisper-large-v3-turbo)
# - "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) # - "mistral-transcription": Mistral Voxtral API (excellent for European languages, model: voxtral-mini-latest)
# - "elevenlabs": ElevenLabs Scribe API (99 languages, models: scribe_v1, scribe_v2) # - "elevenlabs": ElevenLabs Scribe API (99 languages, models: scribe_v1, scribe_v2)
# #
+3 -21
View File
@@ -2,19 +2,17 @@ package config
import ( import (
"fmt" "fmt"
"log"
"strings" "strings"
"github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/provider" "github.com/leonardotrapani/hyprvoice/internal/provider"
) )
// mapConfigProviderToRegistryName maps config provider names to provider registry names // 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" // Registry uses base names like "groq", "mistral"
func mapConfigProviderToRegistryName(configProvider string) string { func mapConfigProviderToRegistryName(configProvider string) string {
switch configProvider { switch configProvider {
case "groq-transcription", "groq-translation": case "groq-transcription":
return "groq" return "groq"
case "mistral-transcription": case "mistral-transcription":
return "mistral" 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 // validate model exists
if c.Transcription.Model == "" { if c.Transcription.Model == "" {
return fmt.Errorf("invalid transcription.model: empty") return fmt.Errorf("invalid transcription.model: empty")
} }
// 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 // validate model exists in provider
_, err := provider.GetModel(registryName, c.Transcription.Model) _, err := provider.GetModel(registryName, c.Transcription.Model)
if err != nil { if err != nil {
@@ -205,11 +193,6 @@ func ValidateModelLanguageCompatibility(registryProvider, modelID, langCode stri
} }
// language not supported - build helpful error message // 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 // truncate supported languages for error message
supported := model.SupportedLanguages supported := model.SupportedLanguages
suffix := "" suffix := ""
@@ -225,9 +208,8 @@ func ValidateModelLanguageCompatibility(registryProvider, modelID, langCode stri
} }
return fmt.Errorf( 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, model.Name,
langName,
langCode, langCode,
docsHint, docsHint,
strings.Join(supported, ", "), strings.Join(supported, ", "),
-166
View File
@@ -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
}
-165
View File
@@ -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)
}
})
}
}
+4 -15
View File
@@ -21,21 +21,10 @@ func (p *DeepgramProvider) IsLocal() bool {
} }
func (p *DeepgramProvider) Models() []Model { func (p *DeepgramProvider) Models() []Model {
// Nova-3 language support - maps to our 57 language list // https://developers.deepgram.com/docs/models-languages-overview
// from https://developers.deepgram.com/docs/models-languages-overview nova3Langs := deepgramNova3Languages
nova3Langs := []string{ // https://developers.deepgram.com/docs/models-languages-overview
"ar", "be", "bs", "bg", "ca", "hr", "cs", "da", "nl", "en", "et", "fi", nova2Langs := deepgramNova2Languages
"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",
}
docsURL := "https://developers.deepgram.com/docs/language" docsURL := "https://developers.deepgram.com/docs/language"
+3 -6
View File
@@ -1,7 +1,5 @@
package provider package provider
import "github.com/leonardotrapani/hyprvoice/internal/language"
// ElevenLabsProvider implements Provider for ElevenLabs services (transcription only) // ElevenLabsProvider implements Provider for ElevenLabs services (transcription only)
type ElevenLabsProvider struct{} type ElevenLabsProvider struct{}
@@ -23,10 +21,9 @@ func (p *ElevenLabsProvider) IsLocal() bool {
} }
func (p *ElevenLabsProvider) Models() []Model { func (p *ElevenLabsProvider) Models() []Model {
// ElevenLabs Scribe supports 90+ languages, including all 57 from our master list // https://elevenlabs.io/speech-to-text
// See: https://elevenlabs.io/speech-to-text allLangs := elevenLabsTranscriptionLanguages
allLangs := language.AllLanguageCodes() docsURL := "https://elevenlabs.io/speech-to-text"
docsURL := "https://elevenlabs.io/docs/capabilities/speech-to-text#supported-languages"
return []Model{ return []Model{
{ {
+30 -36
View File
@@ -1,10 +1,6 @@
package provider package provider
import ( import "strings"
"strings"
"github.com/leonardotrapani/hyprvoice/internal/language"
)
// GroqProvider implements Provider for Groq services // GroqProvider implements Provider for Groq services
type GroqProvider struct{} type GroqProvider struct{}
@@ -26,7 +22,8 @@ func (p *GroqProvider) IsLocal() bool {
} }
func (p *GroqProvider) Models() []Model { 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" docsURL := "https://console.groq.com/docs/speech-to-text#supported-languages"
return []Model{ return []Model{
@@ -59,40 +56,37 @@ func (p *GroqProvider) Models() []Model {
}, },
// LLM models // LLM models
{ {
ID: "llama-3.3-70b-versatile", ID: "llama-3.3-70b-versatile",
Name: "Llama 3.3 70B Versatile", Name: "Llama 3.3 70B Versatile",
Description: "Most capable Llama model", Description: "Most capable Llama model",
Type: LLM, Type: LLM,
SupportsBatch: true, SupportsBatch: true,
SupportsStreaming: false, SupportsStreaming: false,
Local: false, Local: false,
AdapterType: AdapterOpenAI, AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs, Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"},
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"},
}, },
{ {
ID: "llama-3.1-8b-instant", ID: "llama-3.1-8b-instant",
Name: "Llama 3.1 8B Instant", Name: "Llama 3.1 8B Instant",
Description: "Fast and efficient", Description: "Fast and efficient",
Type: LLM, Type: LLM,
SupportsBatch: true, SupportsBatch: true,
SupportsStreaming: false, SupportsStreaming: false,
Local: false, Local: false,
AdapterType: AdapterOpenAI, AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs, Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"},
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"},
}, },
{ {
ID: "mixtral-8x7b-32768", ID: "mixtral-8x7b-32768",
Name: "Mixtral 8x7B", Name: "Mixtral 8x7B",
Description: "Mixture of experts model", Description: "Mixture of experts model",
Type: LLM, Type: LLM,
SupportsBatch: true, SupportsBatch: true,
SupportsStreaming: false, SupportsStreaming: false,
Local: false, Local: false,
AdapterType: AdapterOpenAI, AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs, Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"},
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"},
}, },
} }
} }
+47
View File
@@ -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",
}
+3 -4
View File
@@ -1,7 +1,5 @@
package provider package provider
import "github.com/leonardotrapani/hyprvoice/internal/language"
// MistralProvider implements Provider for Mistral services (transcription only) // MistralProvider implements Provider for Mistral services (transcription only)
type MistralProvider struct{} type MistralProvider struct{}
@@ -23,8 +21,9 @@ func (p *MistralProvider) IsLocal() bool {
} }
func (p *MistralProvider) Models() []Model { func (p *MistralProvider) Models() []Model {
allLangs := language.AllLanguageCodes() // https://docs.mistral.ai/capabilities/audio/
docsURL := "https://docs.mistral.ai/capabilities/speech/" allLangs := mistralTranscriptionLanguages
docsURL := "https://docs.mistral.ai/capabilities/audio/"
return []Model{ return []Model{
{ {
+1 -9
View File
@@ -1,7 +1,5 @@
package provider package provider
import "github.com/leonardotrapani/hyprvoice/internal/language"
// ModelType represents the type of a model // ModelType represents the type of a model
type ModelType int type ModelType int
@@ -22,7 +20,7 @@ type Model struct {
AdapterType string // which adapter to use (e.g., "openai", "elevenlabs", "whisper-cpp") AdapterType string // which adapter to use (e.g., "openai", "elevenlabs", "whisper-cpp")
StreamingAdapter string // adapter for streaming mode (if different from AdapterType) StreamingAdapter string // adapter for streaming mode (if different from AdapterType)
StreamingEndpoint *EndpointConfig // endpoint for streaming mode (if different from Endpoint) 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 Endpoint *EndpointConfig // nil for local models
LocalInfo *LocalModelInfo // nil for cloud models LocalInfo *LocalModelInfo // nil for cloud models
DocsURL string // URL to provider's language support documentation DocsURL string // URL to provider's language support documentation
@@ -69,9 +67,3 @@ func (m *Model) SupportsLanguage(code string) bool {
} }
return false 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)
}
+4 -69
View File
@@ -1,10 +1,6 @@
package provider package provider
import ( import "testing"
"testing"
"github.com/leonardotrapani/hyprvoice/internal/language"
)
func TestModel_NeedsDownload(t *testing.T) { func TestModel_NeedsDownload(t *testing.T) {
tests := []struct { tests := []struct {
@@ -118,11 +114,9 @@ func TestModel_SupportsBothModes(t *testing.T) {
} }
func TestModel_SupportsLanguage(t *testing.T) { func TestModel_SupportsLanguage(t *testing.T) {
allCodes := language.AllLanguageCodes()
multilingualModel := Model{ multilingualModel := Model{
ID: "whisper-large-v3", ID: "whisper-large-v3",
SupportedLanguages: allCodes, SupportedLanguages: []string{"en", "es", "zh"},
} }
englishOnlyModel := Model{ 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) { func TestModelType_Constants(t *testing.T) {
// verify ModelType constants exist and are distinct // verify ModelType constants exist and are distinct
if Transcription == LLM { if Transcription == LLM {
@@ -387,8 +322,8 @@ func TestAllTranscriptionModels_HaveDocsURL(t *testing.T) {
expectedDocsURLs := map[string]string{ expectedDocsURLs := map[string]string{
"openai": "https://platform.openai.com/docs/guides/speech-to-text#supported-languages", "openai": "https://platform.openai.com/docs/guides/speech-to-text#supported-languages",
"groq": "https://console.groq.com/docs/speech-to-text#supported-languages", "groq": "https://console.groq.com/docs/speech-to-text#supported-languages",
"mistral": "https://docs.mistral.ai/capabilities/speech/", "mistral": "https://docs.mistral.ai/capabilities/audio/",
"elevenlabs": "https://elevenlabs.io/docs/capabilities/speech-to-text#supported-languages", "elevenlabs": "https://elevenlabs.io/speech-to-text",
"deepgram": "https://developers.deepgram.com/docs/language", "deepgram": "https://developers.deepgram.com/docs/language",
"whisper-cpp": "https://github.com/openai/whisper#available-models-and-languages", "whisper-cpp": "https://github.com/openai/whisper#available-models-and-languages",
} }
+1 -2
View File
@@ -14,7 +14,6 @@ const (
const ( const (
ConfigProviderOpenAI = "openai" ConfigProviderOpenAI = "openai"
ConfigProviderGroqTranscription = "groq-transcription" ConfigProviderGroqTranscription = "groq-transcription"
ConfigProviderGroqTranslation = "groq-translation"
ConfigProviderMistralTranscription = "mistral-transcription" ConfigProviderMistralTranscription = "mistral-transcription"
ConfigProviderElevenLabs = "elevenlabs" ConfigProviderElevenLabs = "elevenlabs"
ConfigProviderDeepgram = "deepgram" ConfigProviderDeepgram = "deepgram"
@@ -44,7 +43,7 @@ const (
// e.g. "groq-transcription" -> "groq", "mistral-transcription" -> "mistral" // e.g. "groq-transcription" -> "groq", "mistral-transcription" -> "mistral"
func BaseProviderName(configProvider string) string { func BaseProviderName(configProvider string) string {
switch configProvider { switch configProvider {
case ConfigProviderGroqTranscription, ConfigProviderGroqTranslation: case ConfigProviderGroqTranscription:
return ProviderGroq return ProviderGroq
case ConfigProviderMistralTranscription: case ConfigProviderMistralTranscription:
return ProviderMistral return ProviderMistral
+21 -26
View File
@@ -1,10 +1,6 @@
package provider package provider
import ( import "strings"
"strings"
"github.com/leonardotrapani/hyprvoice/internal/language"
)
// OpenAIProvider implements Provider for OpenAI services // OpenAIProvider implements Provider for OpenAI services
type OpenAIProvider struct{} type OpenAIProvider struct{}
@@ -26,7 +22,8 @@ func (p *OpenAIProvider) IsLocal() bool {
} }
func (p *OpenAIProvider) Models() []Model { 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" docsURL := "https://platform.openai.com/docs/guides/speech-to-text#supported-languages"
@@ -77,28 +74,26 @@ func (p *OpenAIProvider) Models() []Model {
}, },
// LLM models // LLM models
{ {
ID: "gpt-4o-mini", ID: "gpt-4o-mini",
Name: "GPT-4o Mini", Name: "GPT-4o Mini",
Description: "Fast and affordable GPT-4 variant", Description: "Fast and affordable GPT-4 variant",
Type: LLM, Type: LLM,
SupportsBatch: true, SupportsBatch: true,
SupportsStreaming: false, SupportsStreaming: false,
Local: false, Local: false,
AdapterType: AdapterOpenAI, AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs, Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/chat/completions"},
Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/chat/completions"},
}, },
{ {
ID: "gpt-4o", ID: "gpt-4o",
Name: "GPT-4o", Name: "GPT-4o",
Description: "Most capable GPT-4 model", Description: "Most capable GPT-4 model",
Type: LLM, Type: LLM,
SupportsBatch: true, SupportsBatch: true,
SupportsStreaming: false, SupportsStreaming: false,
Local: false, Local: false,
AdapterType: AdapterOpenAI, AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs, Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/chat/completions"},
Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/chat/completions"},
}, },
} }
} }
+5 -6
View File
@@ -1,9 +1,6 @@
package provider package provider
import ( import "github.com/leonardotrapani/hyprvoice/internal/models/whisper"
"github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/models/whisper"
)
// WhisperCppProvider implements Provider for local whisper.cpp transcription // WhisperCppProvider implements Provider for local whisper.cpp transcription
type WhisperCppProvider struct{} type WhisperCppProvider struct{}
@@ -25,8 +22,10 @@ func (p *WhisperCppProvider) IsLocal() bool {
} }
func (p *WhisperCppProvider) Models() []Model { func (p *WhisperCppProvider) Models() []Model {
allLangs := language.AllLanguageCodes() // https://github.com/openai/whisper#available-models-and-languages
englishOnly := []string{"en"} allLangs := whisperTranscriptionLanguages
// https://github.com/openai/whisper#available-models-and-languages
englishOnly := whisperEnglishOnlyLanguages
docsURL := "https://github.com/openai/whisper#available-models-and-languages" docsURL := "https://github.com/openai/whisper#available-models-and-languages"
whisperModels := whisper.ListModels() whisperModels := whisper.ListModels()
+6 -12
View File
@@ -1,10 +1,6 @@
package provider package provider
import ( import "testing"
"testing"
"github.com/leonardotrapani/hyprvoice/internal/language"
)
func TestWhisperCppProvider_GetProvider(t *testing.T) { func TestWhisperCppProvider_GetProvider(t *testing.T) {
p := GetProvider("whisper-cpp") p := GetProvider("whisper-cpp")
@@ -88,20 +84,18 @@ func TestWhisperCppProvider_MultilingualModels(t *testing.T) {
"large-v3": true, "large-v3": true,
} }
allLangs := language.AllLanguageCodes()
for _, m := range models { for _, m := range models {
isMultilingual := multilingualIDs[m.ID] isMultilingual := multilingualIDs[m.ID]
if isMultilingual { if isMultilingual {
if len(m.SupportedLanguages) != len(allLangs) { if len(m.SupportedLanguages) <= 1 {
t.Errorf("model %s: expected %d languages, got %d", m.ID, len(allLangs), len(m.SupportedLanguages)) t.Errorf("model %s: expected multiple languages, got %d", m.ID, len(m.SupportedLanguages))
}
if !m.SupportsAllLanguages() {
t.Errorf("model %s: SupportsAllLanguages() should be true", m.ID)
} }
if !m.SupportsLanguage("es") { if !m.SupportsLanguage("es") {
t.Errorf("model %s: SupportsLanguage('es') should be true", m.ID) 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)
}
} }
} }
} }
+11 -6
View File
@@ -7,11 +7,11 @@ import (
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"sync" "sync"
"time" "time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/provider" "github.com/leonardotrapani/hyprvoice/internal/provider"
) )
@@ -21,6 +21,7 @@ type DeepgramAdapter struct {
apiKey string apiKey string
model string model string
language string language string
keywords []string
conn *websocket.Conn conn *websocket.Conn
resultsCh chan TranscriptionResult resultsCh chan TranscriptionResult
mu sync.Mutex mu sync.Mutex
@@ -82,13 +83,14 @@ type deepgramError struct {
// endpoint: the WebSocket endpoint config (e.g., wss://api.deepgram.com, /v1/listen) // endpoint: the WebSocket endpoint config (e.g., wss://api.deepgram.com, /v1/listen)
// apiKey: Deepgram API key // apiKey: Deepgram API key
// model: model ID (e.g., "nova-3") // model: model ID (e.g., "nova-3")
// lang: canonical language code (will be converted to provider format) // lang: provider language code
func NewDeepgramAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *DeepgramAdapter { func NewDeepgramAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *DeepgramAdapter {
return &DeepgramAdapter{ return &DeepgramAdapter{
endpoint: endpoint, endpoint: endpoint,
apiKey: apiKey, apiKey: apiKey,
model: model, model: model,
language: lang, language: lang,
keywords: keywords,
resultsCh: make(chan TranscriptionResult, 100), resultsCh: make(chan TranscriptionResult, 100),
maxRetries: 3, maxRetries: 3,
retryDelays: defaultRetryDelays, retryDelays: defaultRetryDelays,
@@ -228,9 +230,12 @@ func (a *DeepgramAdapter) buildURL() (string, error) {
q.Set("punctuate", "true") q.Set("punctuate", "true")
// add language if specified // add language if specified
providerLang := language.ToProviderFormat(a.language, "deepgram") if a.language != "" {
if providerLang != "" { q.Set("language", a.language)
q.Set("language", providerLang) }
if len(a.keywords) > 0 {
q.Set("keywords", strings.Join(a.keywords, ","))
} }
u.RawQuery = q.Encode() u.RawQuery = q.Encode()
+10 -5
View File
@@ -8,8 +8,8 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/provider" "github.com/leonardotrapani/hyprvoice/internal/provider"
) )
@@ -19,6 +19,7 @@ type DeepgramBatchAdapter struct {
apiKey string apiKey string
model string model string
language string language string
keywords []string
} }
// deepgramBatchResponse is the response from the pre-recorded API // deepgramBatchResponse is the response from the pre-recorded API
@@ -36,12 +37,13 @@ type deepgramBatchChannel struct {
} }
// NewDeepgramBatchAdapter creates a new batch adapter for Deepgram // 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{ return &DeepgramBatchAdapter{
endpoint: endpoint, endpoint: endpoint,
apiKey: apiKey, apiKey: apiKey,
model: model, model: model,
language: lang, language: lang,
keywords: keywords,
} }
} }
@@ -116,9 +118,12 @@ func (a *DeepgramBatchAdapter) buildURL() (string, error) {
q.Set("punctuate", "true") q.Set("punctuate", "true")
// add language if specified // add language if specified
providerLang := language.ToProviderFormat(a.language, "deepgram") if a.language != "" {
if providerLang != "" { q.Set("language", a.language)
q.Set("language", providerLang) }
if len(a.keywords) > 0 {
q.Set("keywords", strings.Join(a.keywords, ","))
} }
u.RawQuery = q.Encode() u.RawQuery = q.Encode()
@@ -23,7 +23,7 @@ func TestDeepgramAdapter_Creation(t *testing.T) {
BaseURL: "wss://api.deepgram.com", BaseURL: "wss://api.deepgram.com",
Path: "/v1/listen", 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" { if adapter.apiKey != "test-api-key" {
t.Errorf("apiKey = %q, want %q", 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", BaseURL: "wss://api.deepgram.com",
Path: "/v1/listen", 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() url, err := adapter.buildURL()
if err != nil { if err != nil {
@@ -93,7 +93,7 @@ func TestDeepgramAdapter_SendChunkNotStarted(t *testing.T) {
BaseURL: "wss://api.deepgram.com", BaseURL: "wss://api.deepgram.com",
Path: "/v1/listen", 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")) err := adapter.SendChunk([]byte("audio data"))
if err == nil { if err == nil {
@@ -109,7 +109,7 @@ func TestDeepgramAdapter_CloseNotStarted(t *testing.T) {
BaseURL: "wss://api.deepgram.com", BaseURL: "wss://api.deepgram.com",
Path: "/v1/listen", 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 // closing not-started adapter should not error
err := adapter.Close() err := adapter.Close()
@@ -173,7 +173,7 @@ func TestDeepgramAdapter_StartAndClose(t *testing.T) {
Path: "", Path: "",
} }
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en") adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil)
ctx := context.Background() ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil { if err := adapter.Start(ctx, ""); err != nil {
@@ -229,7 +229,7 @@ func TestDeepgramAdapter_ReceivesResults(t *testing.T) {
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""} 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() ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil { if err := adapter.Start(ctx, ""); err != nil {
@@ -312,7 +312,7 @@ func TestDeepgramAdapter_SendsRawBinaryAudio(t *testing.T) {
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""} 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() ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil { if err := adapter.Start(ctx, ""); err != nil {
@@ -357,7 +357,7 @@ func TestDeepgramAdapter_HandlesError(t *testing.T) {
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""} 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() ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil { if err := adapter.Start(ctx, ""); err != nil {
@@ -398,7 +398,7 @@ func TestDeepgramAdapter_ContextCancellation(t *testing.T) {
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""} 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()) ctx, cancel := context.WithCancel(context.Background())
if err := adapter.Start(ctx, ""); err != nil { if err := adapter.Start(ctx, ""); err != nil {
+18 -7
View File
@@ -11,7 +11,6 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/provider" "github.com/leonardotrapani/hyprvoice/internal/provider"
) )
@@ -22,6 +21,7 @@ type ElevenLabsAdapter struct {
apiKey string apiKey string
model string model string
language string language string
keywords []string
} }
// ElevenLabsResponse represents the API response // ElevenLabsResponse represents the API response
@@ -33,14 +33,15 @@ type ElevenLabsResponse struct {
// endpoint: the endpoint config (BaseURL + Path) // endpoint: the endpoint config (BaseURL + Path)
// apiKey: ElevenLabs API key // apiKey: ElevenLabs API key
// model: model ID (e.g., "scribe_v1") // model: model ID (e.g., "scribe_v1")
// lang: canonical language code (will be converted to provider format) // lang: provider language code
func NewElevenLabsAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *ElevenLabsAdapter { func NewElevenLabsAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *ElevenLabsAdapter {
return &ElevenLabsAdapter{ return &ElevenLabsAdapter{
client: &http.Client{Timeout: 30 * time.Second}, client: &http.Client{Timeout: 30 * time.Second},
endpoint: endpoint, endpoint: endpoint,
apiKey: apiKey, apiKey: apiKey,
model: model, model: model,
language: lang, language: lang,
keywords: keywords,
} }
} }
@@ -52,6 +53,7 @@ func NewElevenLabsAdapterFromConfig(config Config) *ElevenLabsAdapter {
config.APIKey, config.APIKey,
config.Model, config.Model,
config.Language, 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) return "", fmt.Errorf("write model_id: %w", err)
} }
// Add language_code if specified (convert to provider format) // Add language_code if specified
providerLang := language.ToProviderFormat(a.language, "elevenlabs") if a.language != "" {
if providerLang != "" { if err := writer.WriteField("language_code", a.language); err != nil {
if err := writer.WriteField("language_code", providerLang); err != nil {
return "", fmt.Errorf("write language_code: %w", err) 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 { if err := writer.Close(); err != nil {
return "", fmt.Errorf("close writer: %w", err) return "", fmt.Errorf("close writer: %w", err)
} }
@@ -8,11 +8,11 @@ import (
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"sync" "sync"
"time" "time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/provider" "github.com/leonardotrapani/hyprvoice/internal/provider"
) )
@@ -25,6 +25,7 @@ type ElevenLabsStreamingAdapter struct {
apiKey string apiKey string
model string model string
language string language string
keywords []string
conn *websocket.Conn conn *websocket.Conn
resultsCh chan TranscriptionResult resultsCh chan TranscriptionResult
mu sync.Mutex mu sync.Mutex
@@ -38,15 +39,17 @@ type ElevenLabsStreamingAdapter struct {
retryDelays []time.Duration retryDelays []time.Duration
// finalization signaling // finalization signaling
commitDone chan struct{} commitDone chan struct{}
contextSent bool
} }
// ElevenLabs WebSocket message types (outgoing) // ElevenLabs WebSocket message types (outgoing)
type elevenLabsInputAudioChunk struct { type elevenLabsInputAudioChunk struct {
MessageType string `json:"message_type"` MessageType string `json:"message_type"`
AudioBase64 string `json:"audio_base_64"` AudioBase64 string `json:"audio_base_64"`
Commit bool `json:"commit"` Commit bool `json:"commit"`
SampleRate int `json:"sample_rate"` SampleRate int `json:"sample_rate"`
PreviousText string `json:"previous_text,omitempty"`
} }
// ElevenLabs WebSocket response types (incoming) // 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) // endpoint: the WebSocket endpoint config (e.g., wss://api.elevenlabs.io, /v1/speech-to-text/realtime)
// apiKey: ElevenLabs API key // apiKey: ElevenLabs API key
// model: model ID (e.g., "scribe_v1") // model: model ID (e.g., "scribe_v1")
// lang: canonical language code (will be converted to provider format) // lang: provider language code
func NewElevenLabsStreamingAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *ElevenLabsStreamingAdapter { func NewElevenLabsStreamingAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *ElevenLabsStreamingAdapter {
return &ElevenLabsStreamingAdapter{ return &ElevenLabsStreamingAdapter{
endpoint: endpoint, endpoint: endpoint,
apiKey: apiKey, apiKey: apiKey,
model: model, model: model,
language: lang, language: lang,
keywords: keywords,
resultsCh: make(chan TranscriptionResult, 100), resultsCh: make(chan TranscriptionResult, 100),
maxRetries: 3, maxRetries: 3,
retryDelays: defaultRetryDelays, retryDelays: defaultRetryDelays,
@@ -126,6 +130,7 @@ func (a *ElevenLabsStreamingAdapter) connectLocked() error {
return fmt.Errorf("websocket dial: %w", err) return fmt.Errorf("websocket dial: %w", err)
} }
a.conn = conn a.conn = conn
a.contextSent = false
return nil return nil
} }
@@ -199,9 +204,8 @@ func (a *ElevenLabsStreamingAdapter) buildURL() (string, error) {
q.Set("audio_format", "pcm_16000") // we use 16kHz PCM q.Set("audio_format", "pcm_16000") // we use 16kHz PCM
// add language if specified // add language if specified
providerLang := language.ToProviderFormat(a.language, "elevenlabs") if a.language != "" {
if providerLang != "" { q.Set("language_code", a.language)
q.Set("language_code", providerLang)
} }
// use VAD for automatic commit (easier for real-time use) // use VAD for automatic commit (easier for real-time use)
@@ -334,6 +338,13 @@ func (a *ElevenLabsStreamingAdapter) SendChunk(audio []byte) error {
SampleRate: 16000, 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 // send as JSON
a.mu.Lock() a.mu.Lock()
err := a.conn.WriteJSON(msg) err := a.conn.WriteJSON(msg)
@@ -72,6 +72,7 @@ func TestElevenLabsStreamingAdapter_Start(t *testing.T) {
"test-api-key", "test-api-key",
"scribe_v1", "scribe_v1",
"en", "en",
nil,
) )
ctx := context.Background() ctx := context.Background()
@@ -126,6 +127,7 @@ func TestElevenLabsStreamingAdapter_SendChunk(t *testing.T) {
"test-api-key", "test-api-key",
"scribe_v1", "scribe_v1",
"en", "en",
nil,
) )
ctx := context.Background() ctx := context.Background()
@@ -188,6 +190,7 @@ func TestElevenLabsStreamingAdapter_Results(t *testing.T) {
"test-api-key", "test-api-key",
"scribe_v1", "scribe_v1",
"en", "en",
nil,
) )
ctx := context.Background() ctx := context.Background()
@@ -262,6 +265,7 @@ func TestElevenLabsStreamingAdapter_ErrorMessages(t *testing.T) {
"test-api-key", "test-api-key",
"scribe_v1", "scribe_v1",
"en", "en",
nil,
) )
ctx := context.Background() ctx := context.Background()
@@ -325,6 +329,7 @@ func TestElevenLabsStreamingAdapter_LanguageConversion(t *testing.T) {
"test-api-key", "test-api-key",
"scribe_v1", "scribe_v1",
"es", // Spanish "es", // Spanish
nil,
) )
ctx := context.Background() ctx := context.Background()
@@ -372,6 +377,7 @@ func TestElevenLabsStreamingAdapter_Close(t *testing.T) {
"test-api-key", "test-api-key",
"scribe_v1", "scribe_v1",
"en", "en",
nil,
) )
ctx := context.Background() ctx := context.Background()
@@ -408,6 +414,7 @@ func TestElevenLabsStreamingAdapter_NotStarted(t *testing.T) {
"test-api-key", "test-api-key",
"scribe_v1", "scribe_v1",
"en", "en",
nil,
) )
// SendChunk should fail when not started // SendChunk should fail when not started
@@ -470,6 +477,7 @@ func TestElevenLabsStreamingAdapter_ReconnectOnReadError(t *testing.T) {
"test-api-key", "test-api-key",
"scribe_v1", "scribe_v1",
"en", "en",
nil,
) )
// use very short delays for testing // use very short delays for testing
adapter.retryDelays = []time.Duration{10 * time.Millisecond, 20 * time.Millisecond, 40 * time.Millisecond} 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", "test-api-key",
"scribe_v1", "scribe_v1",
"en", "en",
nil,
) )
adapter.retryDelays = []time.Duration{10 * time.Millisecond, 20 * time.Millisecond, 40 * time.Millisecond} 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", "test-api-key",
"scribe_v1", "scribe_v1",
"en", "en",
nil,
) )
adapter.retryDelays = []time.Duration{5 * time.Millisecond, 10 * time.Millisecond, 15 * time.Millisecond} adapter.retryDelays = []time.Duration{5 * time.Millisecond, 10 * time.Millisecond, 15 * time.Millisecond}
adapter.maxRetries = 2 adapter.maxRetries = 2
@@ -709,6 +719,7 @@ func TestElevenLabsStreamingAdapter_ReconnectExponentialBackoff(t *testing.T) {
"test-api-key", "test-api-key",
"scribe_v1", "scribe_v1",
"en", "en",
nil,
) )
// use measurable delays // use measurable delays
adapter.retryDelays = []time.Duration{50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond} adapter.retryDelays = []time.Duration{50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond}
@@ -13,7 +13,7 @@ func TestNewElevenLabsAdapter(t *testing.T) {
Path: "/v1/speech-to-text", 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 { if adapter == nil {
t.Fatalf("NewElevenLabsAdapter() returned nil") t.Fatalf("NewElevenLabsAdapter() returned nil")
@@ -74,7 +74,7 @@ func TestElevenLabsAdapter_Transcribe_EmptyAudio(t *testing.T) {
Path: "/v1/speech-to-text", Path: "/v1/speech-to-text",
} }
adapter := NewElevenLabsAdapter(endpoint, "test-key", "scribe_v1", "") adapter := NewElevenLabsAdapter(endpoint, "test-key", "scribe_v1", "", nil)
ctx := context.Background() ctx := context.Background()
result, err := adapter.Transcribe(ctx, []byte{}) result, err := adapter.Transcribe(ctx, []byte{})
@@ -94,7 +94,7 @@ func TestElevenLabsAdapter_Transcribe_ValidAudio(t *testing.T) {
Path: "/v1/speech-to-text", 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 { if adapter == nil {
t.Fatal("NewElevenLabsAdapter() returned nil") t.Fatal("NewElevenLabsAdapter() returned nil")
@@ -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
}
+2 -6
View File
@@ -8,7 +8,6 @@ import (
"strings" "strings"
"time" "time"
"github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/provider" "github.com/leonardotrapani/hyprvoice/internal/provider"
"github.com/sashabaranov/go-openai" "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") // endpoint: the BaseURL for the API (e.g., "https://api.openai.com", "https://api.groq.com/openai")
// apiKey: the API key for authentication // apiKey: the API key for authentication
// model: model ID to use // model: model ID to use
// lang: canonical language code (will be converted to provider format) // lang: provider language code
// keywords: optional spelling hints // keywords: optional spelling hints
// providerName: used for logging and language format conversion // providerName: used for logging and language format conversion
func NewOpenAIAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string, providerName string) *OpenAIAdapter { 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) return "", fmt.Errorf("convert to WAV: %w", err)
} }
// Convert language code to provider format
providerLang := language.ToProviderFormat(a.language, a.providerName)
// Create transcription request // Create transcription request
req := openai.AudioRequest{ req := openai.AudioRequest{
Model: a.model, Model: a.model,
Reader: bytes.NewReader(wavData), Reader: bytes.NewReader(wavData),
FilePath: "audio.wav", FilePath: "audio.wav",
Language: providerLang, Language: a.language,
} }
// Add keywords as initial_prompt to help with spelling hints // Add keywords as initial_prompt to help with spelling hints
@@ -8,6 +8,7 @@ import (
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"sync" "sync"
"time" "time"
@@ -21,6 +22,7 @@ type OpenAIRealtimeAdapter struct {
apiKey string apiKey string
model string model string
language string language string
keywords []string
conn *websocket.Conn conn *websocket.Conn
resultsCh chan TranscriptionResult resultsCh chan TranscriptionResult
mu sync.Mutex mu sync.Mutex
@@ -56,6 +58,7 @@ type openaiRealtimeSessionConfig struct {
type openaiRealtimeTranscription struct { type openaiRealtimeTranscription struct {
Model string `json:"model,omitempty"` Model string `json:"model,omitempty"`
Language string `json:"language,omitempty"` Language string `json:"language,omitempty"`
Prompt string `json:"prompt,omitempty"`
} }
type openaiRealtimeTurnDetection struct { type openaiRealtimeTurnDetection struct {
@@ -104,12 +107,13 @@ type openaiRealtimeError struct {
// apiKey: OpenAI API key // apiKey: OpenAI API key
// model: model ID (e.g., "gpt-4o-realtime-preview") // model: model ID (e.g., "gpt-4o-realtime-preview")
// lang: canonical language code (will be used for transcription config) // 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{ return &OpenAIRealtimeAdapter{
endpoint: endpoint, endpoint: endpoint,
apiKey: apiKey, apiKey: apiKey,
model: model, model: model,
language: lang, language: lang,
keywords: keywords,
resultsCh: make(chan TranscriptionResult, 100), resultsCh: make(chan TranscriptionResult, 100),
maxRetries: 3, maxRetries: 3,
retryDelays: defaultRetryDelays, retryDelays: defaultRetryDelays,
@@ -206,6 +210,10 @@ func (a *OpenAIRealtimeAdapter) configureSession() error {
sessionUpdate.Session.InputAudioTranscription.Language = a.language sessionUpdate.Session.InputAudioTranscription.Language = a.language
} }
if len(a.keywords) > 0 {
sessionUpdate.Session.InputAudioTranscription.Prompt = strings.Join(a.keywords, ", ")
}
return a.conn.WriteJSON(sessionUpdate) return a.conn.WriteJSON(sessionUpdate)
} }
@@ -118,7 +118,7 @@ func TestOpenAIRealtimeAdapter_Start(t *testing.T) {
Path: "", 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) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
@@ -198,7 +198,7 @@ func TestOpenAIRealtimeAdapter_SendChunk(t *testing.T) {
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""} 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) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
@@ -282,7 +282,7 @@ func TestOpenAIRealtimeAdapter_TranscriptionResults(t *testing.T) {
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""} 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) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
@@ -369,7 +369,7 @@ func TestOpenAIRealtimeAdapter_ErrorHandling(t *testing.T) {
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""} 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) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
@@ -433,7 +433,7 @@ func TestOpenAIRealtimeAdapter_Reconnection(t *testing.T) {
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""} 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} adapter.retryDelays = []time.Duration{10 * time.Millisecond, 20 * time.Millisecond, 40 * time.Millisecond}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 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") wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""} 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() ctx := context.Background()
+6 -5
View File
@@ -10,8 +10,6 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"github.com/leonardotrapani/hyprvoice/internal/language"
) )
// WhisperCppAdapter implements BatchAdapter for local whisper-cpp transcription // WhisperCppAdapter implements BatchAdapter for local whisper-cpp transcription
@@ -23,7 +21,7 @@ type WhisperCppAdapter struct {
// NewWhisperCppAdapter creates a new whisper-cpp adapter // 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) // 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) // threads: number of CPU threads (0 for auto)
func NewWhisperCppAdapter(modelPath, lang string, threads int) *WhisperCppAdapter { func NewWhisperCppAdapter(modelPath, lang string, threads int) *WhisperCppAdapter {
return &WhisperCppAdapter{ return &WhisperCppAdapter{
@@ -63,8 +61,11 @@ func (a *WhisperCppAdapter) Transcribe(ctx context.Context, audioData []byte) (s
} }
defer os.Remove(tmpFile) defer os.Remove(tmpFile)
// convert language to whisper-cpp format // use whisper-cpp auto if unspecified
lang := language.ToProviderFormat(a.language, "whisper-cpp") lang := a.language
if lang == "" {
lang = "auto"
}
// build command args // build command args
args := []string{ args := []string{
+6 -17
View File
@@ -8,7 +8,6 @@ import (
"golang.org/x/text/cases" "golang.org/x/text/cases"
"golang.org/x/text/language" "golang.org/x/text/language"
lang "github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/models/whisper" "github.com/leonardotrapani/hyprvoice/internal/models/whisper"
"github.com/leonardotrapani/hyprvoice/internal/provider" "github.com/leonardotrapani/hyprvoice/internal/provider"
"github.com/leonardotrapani/hyprvoice/internal/recording" "github.com/leonardotrapani/hyprvoice/internal/recording"
@@ -43,15 +42,6 @@ func NewTranscriber(config Config) (Transcriber, error) {
return nil, fmt.Errorf("provider is required") 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 // map config provider name to registry provider name
registryProvider := provider.BaseProviderName(config.Provider) registryProvider := provider.BaseProviderName(config.Provider)
@@ -89,8 +79,7 @@ func NewTranscriber(config Config) (Transcriber, error) {
// runtime language-model compatibility check with fallback // runtime language-model compatibility check with fallback
// primary validation happens at config time (hard error), this is a safety net // primary validation happens at config time (hard error), this is a safety net
if config.Language != "" && !model.SupportsLanguage(config.Language) { 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, config.Language)
log.Printf("warning: model %s does not support language %s, falling back to auto-detect", model.ID, langName)
config.Language = "" config.Language = ""
} }
@@ -119,11 +108,11 @@ func NewTranscriber(config Config) (Transcriber, error) {
var streamingAdapter StreamingAdapter var streamingAdapter StreamingAdapter
switch adapterType { switch adapterType {
case provider.AdapterElevenLabsStream: 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: 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: case provider.AdapterOpenAIRealtime:
streamingAdapter = NewOpenAIRealtimeAdapter(endpoint, config.APIKey, model.ID, config.Language) streamingAdapter = NewOpenAIRealtimeAdapter(endpoint, config.APIKey, model.ID, config.Language, config.Keywords)
default: default:
return nil, fmt.Errorf("unsupported streaming adapter type: %s", adapterType) return nil, fmt.Errorf("unsupported streaming adapter type: %s", adapterType)
} }
@@ -136,9 +125,9 @@ func NewTranscriber(config Config) (Transcriber, error) {
case provider.AdapterOpenAI: case provider.AdapterOpenAI:
adapter = NewOpenAIAdapter(model.Endpoint, config.APIKey, model.ID, config.Language, config.Keywords, registryProvider) adapter = NewOpenAIAdapter(model.Endpoint, config.APIKey, model.ID, config.Language, config.Keywords, registryProvider)
case provider.AdapterElevenLabs: 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: 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: case provider.AdapterWhisperCpp:
modelPath := whisper.GetModelPath(config.Model) modelPath := whisper.GetModelPath(config.Model)
if modelPath == "" { if modelPath == "" {
-20
View File
@@ -56,26 +56,6 @@ func TestNewTranscriber(t *testing.T) {
}, },
wantErr: true, 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", name: "valid mistral-transcription config",
config: Config{ config: Config{
+1 -1
View File
@@ -37,7 +37,7 @@ func editLLM(cfg *config.Config, configuredProviders []string) ([]string, error)
enableLLM := cfg.LLM.Enabled 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 { if cfg.LLM.Enabled {
enableDesc = fmt.Sprintf("Currently: enabled (%s/%s). %s", cfg.LLM.Provider, cfg.LLM.Model, enableDesc) enableDesc = fmt.Sprintf("Currently: enabled (%s/%s). %s", cfg.LLM.Provider, cfg.LLM.Model, enableDesc)
} else { } else {
+28 -1
View File
@@ -47,6 +47,7 @@ func editProviders(cfg *config.Config, onboarding bool) error {
for { for {
var options []huh.Option[string] var options []huh.Option[string]
options = append(options, huh.NewOption("Local", "local"))
for _, name := range AllProviders { for _, name := range AllProviders {
options = append(options, huh.NewOption(formatProviderOption(cfg, name), name)) options = append(options, huh.NewOption(formatProviderOption(cfg, name), name))
} }
@@ -75,6 +76,13 @@ func editProviders(cfg *config.Config, onboarding bool) error {
return nil return nil
} }
if selected == "local" {
if err := showLocalProviderInfo(); err != nil {
continue
}
return nil
}
apiKey, err := configureSingleProvider(cfg, selected) apiKey, err := configureSingleProvider(cfg, selected)
if err != nil { if err != nil {
continue 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 // formatProviderOption formats a provider menu option with status
func formatProviderOption(cfg *config.Config, name string) string { func formatProviderOption(cfg *config.Config, name string) string {
var status string var status string
@@ -190,7 +217,7 @@ func inputAPIKey(providerName string) (string, error) {
func ensureProviderConfigured(cfg *config.Config, selectedProvider string, configuredProviders []string) []string { func ensureProviderConfigured(cfg *config.Config, selectedProvider string, configuredProviders []string) []string {
providerName := selectedProvider providerName := selectedProvider
switch selectedProvider { switch selectedProvider {
case "groq-transcription", "groq-translation": case "groq-transcription":
providerName = "groq" providerName = "groq"
case "mistral-transcription": case "mistral-transcription":
providerName = "mistral" providerName = "mistral"
+32 -29
View File
@@ -35,8 +35,7 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri
huh.NewOption("OpenAI Whisper", "openai")) huh.NewOption("OpenAI Whisper", "openai"))
case "groq": case "groq":
transcriptionOptions = append(transcriptionOptions, transcriptionOptions = append(transcriptionOptions,
huh.NewOption("Groq Whisper (transcription)", "groq-transcription"), huh.NewOption("Groq Whisper", "groq-transcription"))
huh.NewOption("Groq Whisper (translate to English)", "groq-translation"))
case "mistral": case "mistral":
transcriptionOptions = append(transcriptionOptions, transcriptionOptions = append(transcriptionOptions,
huh.NewOption("Mistral Voxtral", "mistral-transcription")) huh.NewOption("Mistral Voxtral", "mistral-transcription"))
@@ -210,25 +209,37 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri
return configuredProviders, err return configuredProviders, err
} }
languageOptions := getModelLanguageOptions(model, cfg.Transcription.Language) if cfg.Transcription.Language != "" && !model.SupportsLanguage(cfg.Transcription.Language) {
selectedLanguage := cfg.Transcription.Language 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 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 // set streaming mode based on model capabilities
if model.SupportsBothModes() { if model.SupportsBothModes() {
@@ -271,8 +282,7 @@ func getUnconfiguredTranscriptionOptions(configuredProviders []string) []huh.Opt
} }
if !configured["groq"] { if !configured["groq"] {
options = append(options, options = append(options,
huh.NewOption("Groq Whisper transcription (not configured)", "groq-transcription"), huh.NewOption("Groq Whisper (not configured)", "groq-transcription"))
huh.NewOption("Groq Whisper translation (not configured)", "groq-translation"))
} }
if !configured["mistral"] { if !configured["mistral"] {
options = append(options, huh.NewOption("Mistral Voxtral (not configured)", "mistral-transcription")) 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] { 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 // map config provider name to registry provider name
registryName := mapConfigProviderToRegistry(configProvider) registryName := mapConfigProviderToRegistry(configProvider)
p := provider.GetProvider(registryName) p := provider.GetProvider(registryName)
@@ -319,7 +322,7 @@ func getTranscriptionModelOptions(configProvider string) []huh.Option[string] {
// mapConfigProviderToRegistry maps config provider names to registry provider names // mapConfigProviderToRegistry maps config provider names to registry provider names
func mapConfigProviderToRegistry(configProvider string) string { func mapConfigProviderToRegistry(configProvider string) string {
switch configProvider { switch configProvider {
case "groq-transcription", "groq-translation": case "groq-transcription":
return "groq" return "groq"
case "mistral-transcription": case "mistral-transcription":
return "mistral" return "mistral"
+8 -21
View File
@@ -1,10 +1,7 @@
package tui package tui
import ( import (
"fmt"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/provider" "github.com/leonardotrapani/hyprvoice/internal/provider"
) )
@@ -13,33 +10,23 @@ func getModelLanguageOptions(model *provider.Model, currentLang string) []huh.Op
var options []huh.Option[string] var options []huh.Option[string]
// auto-detect is always first // auto-detect is always first
autoLabel := "Auto-detect" autoLabel := "Auto-detect (recommended)"
if currentLang == "" { if currentLang == "" {
autoLabel += " (current)" autoLabel += " (current)"
} }
options = append(options, huh.NewOption(autoLabel, "")) options = append(options, huh.NewOption(autoLabel, ""))
// only show languages supported by the model if model == nil {
for _, lang := range language.List() { return options
if model != nil && !model.SupportsLanguage(lang.Code) { }
continue
}
label := formatLanguageLabel(lang) for _, code := range model.SupportedLanguages {
if lang.Code == currentLang { label := code
if code == currentLang {
label += " (current)" label += " (current)"
} }
options = append(options, huh.NewOption(label, code))
options = append(options, huh.NewOption(label, lang.Code))
} }
return options 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)
}