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 run ./cmd/hyprvoice
## Where to look
- docs/structure.md: architecture and code map
## Main structure (short)
- cmd/hyprvoice: CLI entrypoint and commands
- internal/daemon: daemon lifecycle + IPC command handling
- internal/pipeline: recording -> transcription -> processing -> injection state machine
- internal/recording: PipeWire capture
- internal/transcriber: batch/streaming adapters
- internal/llm: post-processing adapters and prompts
- internal/injection: wtype/ydotool/clipboard backends
- internal/provider: provider registry + model metadata
- internal/config: config load/validate + hot reload
## Runtime quick facts
- IPC: unix socket at ~/.cache/hyprvoice/control.sock, single-character commands
- Config: ~/.config/hyprvoice/config.toml (hot reloaded by daemon)
## Docs
- docs/structure.md: code map and entry points
- docs/architecture.md: deeper architecture + adapters/interfaces
- docs/config.md: config reference and paths
- docs/providers.md: provider and model details
- packaging/RELEASE.md: release and AUR workflow
+10 -6
View File
@@ -183,12 +183,12 @@ func runConfigure(onboarding bool) error {
fmt.Println()
// Show next steps
showNextSteps(result.Config)
showNextSteps(result.Config, onboarding)
return nil
}
func showNextSteps(cfg *config.Config) {
func showNextSteps(cfg *config.Config, onboarding bool) {
// Check if service is running
serviceRunning := false
if _, err := exec.Command("systemctl", "--user", "is-active", "--quiet", "hyprvoice.service").CombinedOutput(); err == nil {
@@ -210,12 +210,16 @@ func showNextSteps(cfg *config.Config) {
fmt.Printf("%d. Ensure ydotoold is running\n", step)
step++
}
if !serviceRunning {
fmt.Printf("%d. Start the service: systemctl --user start hyprvoice.service\n", step)
} else {
if serviceRunning {
fmt.Printf("%d. Restart the service to apply changes: systemctl --user restart hyprvoice.service\n", step)
}
step++
} else if onboarding {
fmt.Printf("%d. Start the service: systemctl --user start hyprvoice.service\n", step)
step++
} else {
fmt.Printf("%d. Start the service if it is not running\n", step)
step++
}
fmt.Printf("%d. Test voice input: hyprvoice toggle\n", step)
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
- Free tier available with generous limits
### Groq Translation API
Fast translation of audio to English using Groq's Whisper API:
```toml
[transcription]
provider = "groq-translation"
language = "es" # Optional: hint source language for better accuracy
model = "whisper-large-v3"
```
**Features:**
- Translates any language audio → English text
- Ultra-fast processing
- Language field hints at source language (improves accuracy)
- Always outputs English regardless of input language
### Mistral Voxtral
Transcription using Mistral's Voxtral API, excellent for European languages:
@@ -384,7 +366,7 @@ keywords = ["Hyprland", "Wayland", "PipeWire", "Claude", "TypeScript"]
**How keywords work:**
- **Transcription**: Passed as initial_prompt to Whisper, improving recognition of these terms
- **Transcription**: Passed as provider-specific hints (prompt/keyterms/keywords) when supported to improve recognition
- **LLM**: Included in the system prompt to ensure correct spelling
**When to use keywords:**
+1 -1
View File
@@ -11,6 +11,7 @@ require (
github.com/muesli/termenv v0.16.0
github.com/sashabaranov/go-openai v1.41.1
github.com/spf13/cobra v1.9.1
golang.org/x/text v0.23.0
)
require (
@@ -38,5 +39,4 @@ require (
github.com/spf13/pflag v1.0.6 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.23.0 // indirect
)
-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) {
config := &Config{
Recording: RecordingConfig{
@@ -1248,41 +1216,6 @@ func TestConfig_ToTranscriberConfig_GroqWithEnvVar(t *testing.T) {
}
}
func TestConfig_Validate_GroqTranslation_RejectsTurbo(t *testing.T) {
config := &Config{
Recording: RecordingConfig{
SampleRate: 16000,
Channels: 1,
Format: "s16",
BufferSize: 8192,
ChannelBufferSize: 30,
Timeout: time.Minute,
},
Transcription: TranscriptionConfig{
Provider: "groq-translation",
APIKey: "gsk-test-key",
Language: "es",
Model: "whisper-large-v3-turbo", // Turbo not supported for translation
},
Injection: InjectionConfig{
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
WtypeTimeout: time.Second,
ClipboardTimeout: time.Second,
},
Notifications: NotificationsConfig{
Type: "log",
},
}
err := config.Validate()
if err == nil {
t.Error("Validate() should have rejected whisper-large-v3-turbo for groq-translation")
}
if err != nil && err.Error() != "invalid model for groq-translation: whisper-large-v3-turbo (must be whisper-large-v3, turbo version not supported for translation)" {
t.Errorf("Unexpected error message: %v", err)
}
}
func TestMessagesConfig_Resolve_Defaults(t *testing.T) {
cfg := createTestConfig()
msgs := cfg.Notifications.Messages.Resolve()
+1 -1
View File
@@ -104,7 +104,7 @@ func (c *Config) migrateTranscriptionAPIKey(apiKey string) {
switch providerName {
case "openai":
c.Providers["openai"] = ProviderConfig{APIKey: apiKey}
case "groq-transcription", "groq-translation":
case "groq-transcription":
c.Providers["groq"] = ProviderConfig{APIKey: apiKey}
case "mistral-transcription":
c.Providers["mistral"] = ProviderConfig{APIKey: apiKey}
+1 -2
View File
@@ -246,7 +246,7 @@ keywords = []
# ─────────────────────────────────────────────────────────────────────────────
[transcription]
provider = "openai" # "openai", "groq-transcription", "groq-translation", "mistral-transcription", "elevenlabs", "whisper-cpp"
provider = "openai" # "openai", "groq-transcription", "mistral-transcription", "elevenlabs", "whisper-cpp"
model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1"
language = "" # ISO 639-1 code (e.g., en, es, de). Empty for auto-detect.
threads = 0 # CPU threads for local transcription (0 = auto: uses NumCPU-1)
@@ -325,7 +325,6 @@ keywords = []
# Transcription providers:
# - "openai": OpenAI Whisper API (cloud-based, excellent accuracy)
# - "groq-transcription": Groq Whisper API (very fast, models: whisper-large-v3, whisper-large-v3-turbo)
# - "groq-translation": Groq translation to English (always outputs English text, model: whisper-large-v3)
# - "mistral-transcription": Mistral Voxtral API (excellent for European languages, model: voxtral-mini-latest)
# - "elevenlabs": ElevenLabs Scribe API (99 languages, models: scribe_v1, scribe_v2)
#
+3 -21
View File
@@ -2,19 +2,17 @@ package config
import (
"fmt"
"log"
"strings"
"github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
// mapConfigProviderToRegistryName maps config provider names to provider registry names
// Config uses names like "groq-transcription", "groq-translation", "mistral-transcription"
// Config uses names like "groq-transcription", "mistral-transcription"
// Registry uses base names like "groq", "mistral"
func mapConfigProviderToRegistryName(configProvider string) string {
switch configProvider {
case "groq-transcription", "groq-translation":
case "groq-transcription":
return "groq"
case "mistral-transcription":
return "mistral"
@@ -85,21 +83,11 @@ func (c *Config) Validate() error {
}
}
// validate language codes - warn if not recognized but don't error
if c.Transcription.Language != "" && !language.IsValidCode(c.Transcription.Language) {
log.Printf("warning: unrecognized language code '%s' in transcription.language, will be passed as-is to provider", c.Transcription.Language)
}
// validate model exists
if c.Transcription.Model == "" {
return fmt.Errorf("invalid transcription.model: empty")
}
// groq-translation is a special case - only supports whisper-large-v3
if c.Transcription.Provider == "groq-translation" && c.Transcription.Model != "whisper-large-v3" {
return fmt.Errorf("invalid model for groq-translation: %s (must be whisper-large-v3, turbo version not supported for translation)", c.Transcription.Model)
}
// validate model exists in provider
_, err := provider.GetModel(registryName, c.Transcription.Model)
if err != nil {
@@ -205,11 +193,6 @@ func ValidateModelLanguageCompatibility(registryProvider, modelID, langCode stri
}
// language not supported - build helpful error message
langName := language.FromCode(langCode).Name
if langName == "Auto-detect" {
langName = langCode // use code if not found
}
// truncate supported languages for error message
supported := model.SupportedLanguages
suffix := ""
@@ -225,9 +208,8 @@ func ValidateModelLanguageCompatibility(registryProvider, modelID, langCode stri
}
return fmt.Errorf(
"model %s does not support %s (%s).%s Supported: %s%s",
"model %s does not support language '%s'.%s Supported: %s%s",
model.Name,
langName,
langCode,
docsHint,
strings.Join(supported, ", "),
-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 {
// Nova-3 language support - maps to our 57 language list
// from https://developers.deepgram.com/docs/models-languages-overview
nova3Langs := []string{
"ar", "be", "bs", "bg", "ca", "hr", "cs", "da", "nl", "en", "et", "fi",
"fr", "de", "el", "hi", "hu", "id", "it", "ja", "kn", "ko", "lv", "lt",
"mk", "ms", "mr", "no", "pl", "pt", "ro", "ru", "sr", "sk", "sl", "es",
"sv", "tl", "ta", "tr", "uk", "vi",
}
// Nova-2 language support - subset of nova-3
nova2Langs := []string{
"bg", "ca", "zh", "cs", "da", "nl", "en", "et", "fi", "fr", "de", "el",
"hi", "hu", "id", "it", "ja", "ko", "lv", "lt", "ms", "no", "pl", "pt",
"ro", "ru", "sk", "es", "sv", "th", "tr", "uk", "vi",
}
// https://developers.deepgram.com/docs/models-languages-overview
nova3Langs := deepgramNova3Languages
// https://developers.deepgram.com/docs/models-languages-overview
nova2Langs := deepgramNova2Languages
docsURL := "https://developers.deepgram.com/docs/language"
+3 -6
View File
@@ -1,7 +1,5 @@
package provider
import "github.com/leonardotrapani/hyprvoice/internal/language"
// ElevenLabsProvider implements Provider for ElevenLabs services (transcription only)
type ElevenLabsProvider struct{}
@@ -23,10 +21,9 @@ func (p *ElevenLabsProvider) IsLocal() bool {
}
func (p *ElevenLabsProvider) Models() []Model {
// ElevenLabs Scribe supports 90+ languages, including all 57 from our master list
// See: https://elevenlabs.io/speech-to-text
allLangs := language.AllLanguageCodes()
docsURL := "https://elevenlabs.io/docs/capabilities/speech-to-text#supported-languages"
// https://elevenlabs.io/speech-to-text
allLangs := elevenLabsTranscriptionLanguages
docsURL := "https://elevenlabs.io/speech-to-text"
return []Model{
{
+3 -9
View File
@@ -1,10 +1,6 @@
package provider
import (
"strings"
"github.com/leonardotrapani/hyprvoice/internal/language"
)
import "strings"
// GroqProvider implements Provider for Groq services
type GroqProvider struct{}
@@ -26,7 +22,8 @@ func (p *GroqProvider) IsLocal() bool {
}
func (p *GroqProvider) Models() []Model {
allLangs := language.AllLanguageCodes()
// https://console.groq.com/docs/speech-to-text#supported-languages
allLangs := groqTranscriptionLanguages
docsURL := "https://console.groq.com/docs/speech-to-text#supported-languages"
return []Model{
@@ -67,7 +64,6 @@ func (p *GroqProvider) Models() []Model {
SupportsStreaming: false,
Local: false,
AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"},
},
{
@@ -79,7 +75,6 @@ func (p *GroqProvider) Models() []Model {
SupportsStreaming: false,
Local: false,
AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"},
},
{
@@ -91,7 +86,6 @@ func (p *GroqProvider) Models() []Model {
SupportsStreaming: false,
Local: false,
AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs,
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
import "github.com/leonardotrapani/hyprvoice/internal/language"
// MistralProvider implements Provider for Mistral services (transcription only)
type MistralProvider struct{}
@@ -23,8 +21,9 @@ func (p *MistralProvider) IsLocal() bool {
}
func (p *MistralProvider) Models() []Model {
allLangs := language.AllLanguageCodes()
docsURL := "https://docs.mistral.ai/capabilities/speech/"
// https://docs.mistral.ai/capabilities/audio/
allLangs := mistralTranscriptionLanguages
docsURL := "https://docs.mistral.ai/capabilities/audio/"
return []Model{
{
+1 -9
View File
@@ -1,7 +1,5 @@
package provider
import "github.com/leonardotrapani/hyprvoice/internal/language"
// ModelType represents the type of a model
type ModelType int
@@ -22,7 +20,7 @@ type Model struct {
AdapterType string // which adapter to use (e.g., "openai", "elevenlabs", "whisper-cpp")
StreamingAdapter string // adapter for streaming mode (if different from AdapterType)
StreamingEndpoint *EndpointConfig // endpoint for streaming mode (if different from Endpoint)
SupportedLanguages []string // explicit list of supported language codes
SupportedLanguages []string // explicit list of provider language codes
Endpoint *EndpointConfig // nil for local models
LocalInfo *LocalModelInfo // nil for cloud models
DocsURL string // URL to provider's language support documentation
@@ -69,9 +67,3 @@ func (m *Model) SupportsLanguage(code string) bool {
}
return false
}
// SupportsAllLanguages returns true if the model supports all 57 languages
func (m *Model) SupportsAllLanguages() bool {
allCodes := language.AllLanguageCodes()
return len(m.SupportedLanguages) == len(allCodes)
}
+4 -69
View File
@@ -1,10 +1,6 @@
package provider
import (
"testing"
"github.com/leonardotrapani/hyprvoice/internal/language"
)
import "testing"
func TestModel_NeedsDownload(t *testing.T) {
tests := []struct {
@@ -118,11 +114,9 @@ func TestModel_SupportsBothModes(t *testing.T) {
}
func TestModel_SupportsLanguage(t *testing.T) {
allCodes := language.AllLanguageCodes()
multilingualModel := Model{
ID: "whisper-large-v3",
SupportedLanguages: allCodes,
SupportedLanguages: []string{"en", "es", "zh"},
}
englishOnlyModel := Model{
@@ -207,65 +201,6 @@ func TestModel_SupportsLanguage(t *testing.T) {
}
}
func TestModel_SupportsAllLanguages(t *testing.T) {
allCodes := language.AllLanguageCodes()
tests := []struct {
name string
model Model
expected bool
}{
{
name: "model with all 57 languages",
model: Model{
ID: "whisper-large-v3",
SupportedLanguages: allCodes,
},
expected: true,
},
{
name: "english-only model",
model: Model{
ID: "base.en",
SupportedLanguages: []string{"en"},
},
expected: false,
},
{
name: "model with some languages",
model: Model{
ID: "partial",
SupportedLanguages: []string{"en", "es", "fr", "de"},
},
expected: false,
},
{
name: "model with empty languages",
model: Model{
ID: "empty",
SupportedLanguages: []string{},
},
expected: false,
},
{
name: "model with nil languages",
model: Model{
ID: "nil",
SupportedLanguages: nil,
},
expected: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := tc.model.SupportsAllLanguages(); got != tc.expected {
t.Errorf("SupportsAllLanguages() = %v, want %v", got, tc.expected)
}
})
}
}
func TestModelType_Constants(t *testing.T) {
// verify ModelType constants exist and are distinct
if Transcription == LLM {
@@ -387,8 +322,8 @@ func TestAllTranscriptionModels_HaveDocsURL(t *testing.T) {
expectedDocsURLs := map[string]string{
"openai": "https://platform.openai.com/docs/guides/speech-to-text#supported-languages",
"groq": "https://console.groq.com/docs/speech-to-text#supported-languages",
"mistral": "https://docs.mistral.ai/capabilities/speech/",
"elevenlabs": "https://elevenlabs.io/docs/capabilities/speech-to-text#supported-languages",
"mistral": "https://docs.mistral.ai/capabilities/audio/",
"elevenlabs": "https://elevenlabs.io/speech-to-text",
"deepgram": "https://developers.deepgram.com/docs/language",
"whisper-cpp": "https://github.com/openai/whisper#available-models-and-languages",
}
+1 -2
View File
@@ -14,7 +14,6 @@ const (
const (
ConfigProviderOpenAI = "openai"
ConfigProviderGroqTranscription = "groq-transcription"
ConfigProviderGroqTranslation = "groq-translation"
ConfigProviderMistralTranscription = "mistral-transcription"
ConfigProviderElevenLabs = "elevenlabs"
ConfigProviderDeepgram = "deepgram"
@@ -44,7 +43,7 @@ const (
// e.g. "groq-transcription" -> "groq", "mistral-transcription" -> "mistral"
func BaseProviderName(configProvider string) string {
switch configProvider {
case ConfigProviderGroqTranscription, ConfigProviderGroqTranslation:
case ConfigProviderGroqTranscription:
return ProviderGroq
case ConfigProviderMistralTranscription:
return ProviderMistral
+3 -8
View File
@@ -1,10 +1,6 @@
package provider
import (
"strings"
"github.com/leonardotrapani/hyprvoice/internal/language"
)
import "strings"
// OpenAIProvider implements Provider for OpenAI services
type OpenAIProvider struct{}
@@ -26,7 +22,8 @@ func (p *OpenAIProvider) IsLocal() bool {
}
func (p *OpenAIProvider) Models() []Model {
allLangs := language.AllLanguageCodes()
// https://platform.openai.com/docs/guides/speech-to-text#supported-languages
allLangs := openaiTranscriptionLanguages
docsURL := "https://platform.openai.com/docs/guides/speech-to-text#supported-languages"
@@ -85,7 +82,6 @@ func (p *OpenAIProvider) Models() []Model {
SupportsStreaming: false,
Local: false,
AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/chat/completions"},
},
{
@@ -97,7 +93,6 @@ func (p *OpenAIProvider) Models() []Model {
SupportsStreaming: false,
Local: false,
AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/chat/completions"},
},
}
+5 -6
View File
@@ -1,9 +1,6 @@
package provider
import (
"github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/models/whisper"
)
import "github.com/leonardotrapani/hyprvoice/internal/models/whisper"
// WhisperCppProvider implements Provider for local whisper.cpp transcription
type WhisperCppProvider struct{}
@@ -25,8 +22,10 @@ func (p *WhisperCppProvider) IsLocal() bool {
}
func (p *WhisperCppProvider) Models() []Model {
allLangs := language.AllLanguageCodes()
englishOnly := []string{"en"}
// https://github.com/openai/whisper#available-models-and-languages
allLangs := whisperTranscriptionLanguages
// https://github.com/openai/whisper#available-models-and-languages
englishOnly := whisperEnglishOnlyLanguages
docsURL := "https://github.com/openai/whisper#available-models-and-languages"
whisperModels := whisper.ListModels()
+6 -12
View File
@@ -1,10 +1,6 @@
package provider
import (
"testing"
"github.com/leonardotrapani/hyprvoice/internal/language"
)
import "testing"
func TestWhisperCppProvider_GetProvider(t *testing.T) {
p := GetProvider("whisper-cpp")
@@ -88,20 +84,18 @@ func TestWhisperCppProvider_MultilingualModels(t *testing.T) {
"large-v3": true,
}
allLangs := language.AllLanguageCodes()
for _, m := range models {
isMultilingual := multilingualIDs[m.ID]
if isMultilingual {
if len(m.SupportedLanguages) != len(allLangs) {
t.Errorf("model %s: expected %d languages, got %d", m.ID, len(allLangs), len(m.SupportedLanguages))
}
if !m.SupportsAllLanguages() {
t.Errorf("model %s: SupportsAllLanguages() should be true", m.ID)
if len(m.SupportedLanguages) <= 1 {
t.Errorf("model %s: expected multiple languages, got %d", m.ID, len(m.SupportedLanguages))
}
if !m.SupportsLanguage("es") {
t.Errorf("model %s: SupportsLanguage('es') should be true", m.ID)
}
if !m.SupportsLanguage("en") {
t.Errorf("model %s: SupportsLanguage('en') should be true", m.ID)
}
}
}
}
+11 -6
View File
@@ -7,11 +7,11 @@ import (
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
@@ -21,6 +21,7 @@ type DeepgramAdapter struct {
apiKey string
model string
language string
keywords []string
conn *websocket.Conn
resultsCh chan TranscriptionResult
mu sync.Mutex
@@ -82,13 +83,14 @@ type deepgramError struct {
// endpoint: the WebSocket endpoint config (e.g., wss://api.deepgram.com, /v1/listen)
// apiKey: Deepgram API key
// model: model ID (e.g., "nova-3")
// lang: canonical language code (will be converted to provider format)
func NewDeepgramAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *DeepgramAdapter {
// lang: provider language code
func NewDeepgramAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *DeepgramAdapter {
return &DeepgramAdapter{
endpoint: endpoint,
apiKey: apiKey,
model: model,
language: lang,
keywords: keywords,
resultsCh: make(chan TranscriptionResult, 100),
maxRetries: 3,
retryDelays: defaultRetryDelays,
@@ -228,9 +230,12 @@ func (a *DeepgramAdapter) buildURL() (string, error) {
q.Set("punctuate", "true")
// add language if specified
providerLang := language.ToProviderFormat(a.language, "deepgram")
if providerLang != "" {
q.Set("language", providerLang)
if a.language != "" {
q.Set("language", a.language)
}
if len(a.keywords) > 0 {
q.Set("keywords", strings.Join(a.keywords, ","))
}
u.RawQuery = q.Encode()
+10 -5
View File
@@ -8,8 +8,8 @@ import (
"io"
"net/http"
"net/url"
"strings"
"github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
@@ -19,6 +19,7 @@ type DeepgramBatchAdapter struct {
apiKey string
model string
language string
keywords []string
}
// deepgramBatchResponse is the response from the pre-recorded API
@@ -36,12 +37,13 @@ type deepgramBatchChannel struct {
}
// NewDeepgramBatchAdapter creates a new batch adapter for Deepgram
func NewDeepgramBatchAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *DeepgramBatchAdapter {
func NewDeepgramBatchAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *DeepgramBatchAdapter {
return &DeepgramBatchAdapter{
endpoint: endpoint,
apiKey: apiKey,
model: model,
language: lang,
keywords: keywords,
}
}
@@ -116,9 +118,12 @@ func (a *DeepgramBatchAdapter) buildURL() (string, error) {
q.Set("punctuate", "true")
// add language if specified
providerLang := language.ToProviderFormat(a.language, "deepgram")
if providerLang != "" {
q.Set("language", providerLang)
if a.language != "" {
q.Set("language", a.language)
}
if len(a.keywords) > 0 {
q.Set("keywords", strings.Join(a.keywords, ","))
}
u.RawQuery = q.Encode()
@@ -23,7 +23,7 @@ func TestDeepgramAdapter_Creation(t *testing.T) {
BaseURL: "wss://api.deepgram.com",
Path: "/v1/listen",
}
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en")
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil)
if adapter.apiKey != "test-api-key" {
t.Errorf("apiKey = %q, want %q", adapter.apiKey, "test-api-key")
@@ -72,7 +72,7 @@ func TestDeepgramAdapter_BuildURL(t *testing.T) {
BaseURL: "wss://api.deepgram.com",
Path: "/v1/listen",
}
adapter := NewDeepgramAdapter(endpoint, "test-key", tt.model, tt.language)
adapter := NewDeepgramAdapter(endpoint, "test-key", tt.model, tt.language, nil)
url, err := adapter.buildURL()
if err != nil {
@@ -93,7 +93,7 @@ func TestDeepgramAdapter_SendChunkNotStarted(t *testing.T) {
BaseURL: "wss://api.deepgram.com",
Path: "/v1/listen",
}
adapter := NewDeepgramAdapter(endpoint, "test-key", "nova-3", "en")
adapter := NewDeepgramAdapter(endpoint, "test-key", "nova-3", "en", nil)
err := adapter.SendChunk([]byte("audio data"))
if err == nil {
@@ -109,7 +109,7 @@ func TestDeepgramAdapter_CloseNotStarted(t *testing.T) {
BaseURL: "wss://api.deepgram.com",
Path: "/v1/listen",
}
adapter := NewDeepgramAdapter(endpoint, "test-key", "nova-3", "en")
adapter := NewDeepgramAdapter(endpoint, "test-key", "nova-3", "en", nil)
// closing not-started adapter should not error
err := adapter.Close()
@@ -173,7 +173,7 @@ func TestDeepgramAdapter_StartAndClose(t *testing.T) {
Path: "",
}
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en")
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil)
ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil {
@@ -229,7 +229,7 @@ func TestDeepgramAdapter_ReceivesResults(t *testing.T) {
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en")
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil)
ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil {
@@ -312,7 +312,7 @@ func TestDeepgramAdapter_SendsRawBinaryAudio(t *testing.T) {
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en")
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil)
ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil {
@@ -357,7 +357,7 @@ func TestDeepgramAdapter_HandlesError(t *testing.T) {
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en")
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil)
ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil {
@@ -398,7 +398,7 @@ func TestDeepgramAdapter_ContextCancellation(t *testing.T) {
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en")
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil)
ctx, cancel := context.WithCancel(context.Background())
if err := adapter.Start(ctx, ""); err != nil {
+18 -7
View File
@@ -11,7 +11,6 @@ import (
"net/http"
"time"
"github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
@@ -22,6 +21,7 @@ type ElevenLabsAdapter struct {
apiKey string
model string
language string
keywords []string
}
// ElevenLabsResponse represents the API response
@@ -33,14 +33,15 @@ type ElevenLabsResponse struct {
// endpoint: the endpoint config (BaseURL + Path)
// apiKey: ElevenLabs API key
// model: model ID (e.g., "scribe_v1")
// lang: canonical language code (will be converted to provider format)
func NewElevenLabsAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *ElevenLabsAdapter {
// lang: provider language code
func NewElevenLabsAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *ElevenLabsAdapter {
return &ElevenLabsAdapter{
client: &http.Client{Timeout: 30 * time.Second},
endpoint: endpoint,
apiKey: apiKey,
model: model,
language: lang,
keywords: keywords,
}
}
@@ -52,6 +53,7 @@ func NewElevenLabsAdapterFromConfig(config Config) *ElevenLabsAdapter {
config.APIKey,
config.Model,
config.Language,
config.Keywords,
)
}
@@ -85,14 +87,23 @@ func (a *ElevenLabsAdapter) Transcribe(ctx context.Context, audioData []byte) (s
return "", fmt.Errorf("write model_id: %w", err)
}
// Add language_code if specified (convert to provider format)
providerLang := language.ToProviderFormat(a.language, "elevenlabs")
if providerLang != "" {
if err := writer.WriteField("language_code", providerLang); err != nil {
// Add language_code if specified
if a.language != "" {
if err := writer.WriteField("language_code", a.language); err != nil {
return "", fmt.Errorf("write language_code: %w", err)
}
}
if len(a.keywords) > 0 {
keytermsJSON, err := json.Marshal(a.keywords)
if err != nil {
return "", fmt.Errorf("marshal keyterms: %w", err)
}
if err := writer.WriteField("keyterms", string(keytermsJSON)); err != nil {
return "", fmt.Errorf("write keyterms: %w", err)
}
}
if err := writer.Close(); err != nil {
return "", fmt.Errorf("close writer: %w", err)
}
@@ -8,11 +8,11 @@ import (
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
@@ -25,6 +25,7 @@ type ElevenLabsStreamingAdapter struct {
apiKey string
model string
language string
keywords []string
conn *websocket.Conn
resultsCh chan TranscriptionResult
mu sync.Mutex
@@ -39,6 +40,7 @@ type ElevenLabsStreamingAdapter struct {
// finalization signaling
commitDone chan struct{}
contextSent bool
}
// ElevenLabs WebSocket message types (outgoing)
@@ -47,6 +49,7 @@ type elevenLabsInputAudioChunk struct {
AudioBase64 string `json:"audio_base_64"`
Commit bool `json:"commit"`
SampleRate int `json:"sample_rate"`
PreviousText string `json:"previous_text,omitempty"`
}
// ElevenLabs WebSocket response types (incoming)
@@ -62,13 +65,14 @@ type elevenLabsWSMessage struct {
// endpoint: the WebSocket endpoint config (e.g., wss://api.elevenlabs.io, /v1/speech-to-text/realtime)
// apiKey: ElevenLabs API key
// model: model ID (e.g., "scribe_v1")
// lang: canonical language code (will be converted to provider format)
func NewElevenLabsStreamingAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *ElevenLabsStreamingAdapter {
// lang: provider language code
func NewElevenLabsStreamingAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *ElevenLabsStreamingAdapter {
return &ElevenLabsStreamingAdapter{
endpoint: endpoint,
apiKey: apiKey,
model: model,
language: lang,
keywords: keywords,
resultsCh: make(chan TranscriptionResult, 100),
maxRetries: 3,
retryDelays: defaultRetryDelays,
@@ -126,6 +130,7 @@ func (a *ElevenLabsStreamingAdapter) connectLocked() error {
return fmt.Errorf("websocket dial: %w", err)
}
a.conn = conn
a.contextSent = false
return nil
}
@@ -199,9 +204,8 @@ func (a *ElevenLabsStreamingAdapter) buildURL() (string, error) {
q.Set("audio_format", "pcm_16000") // we use 16kHz PCM
// add language if specified
providerLang := language.ToProviderFormat(a.language, "elevenlabs")
if providerLang != "" {
q.Set("language_code", providerLang)
if a.language != "" {
q.Set("language_code", a.language)
}
// use VAD for automatic commit (easier for real-time use)
@@ -334,6 +338,13 @@ func (a *ElevenLabsStreamingAdapter) SendChunk(audio []byte) error {
SampleRate: 16000,
}
a.mu.Lock()
if !a.contextSent && len(a.keywords) > 0 {
msg.PreviousText = strings.Join(a.keywords, ", ")
a.contextSent = true
}
a.mu.Unlock()
// send as JSON
a.mu.Lock()
err := a.conn.WriteJSON(msg)
@@ -72,6 +72,7 @@ func TestElevenLabsStreamingAdapter_Start(t *testing.T) {
"test-api-key",
"scribe_v1",
"en",
nil,
)
ctx := context.Background()
@@ -126,6 +127,7 @@ func TestElevenLabsStreamingAdapter_SendChunk(t *testing.T) {
"test-api-key",
"scribe_v1",
"en",
nil,
)
ctx := context.Background()
@@ -188,6 +190,7 @@ func TestElevenLabsStreamingAdapter_Results(t *testing.T) {
"test-api-key",
"scribe_v1",
"en",
nil,
)
ctx := context.Background()
@@ -262,6 +265,7 @@ func TestElevenLabsStreamingAdapter_ErrorMessages(t *testing.T) {
"test-api-key",
"scribe_v1",
"en",
nil,
)
ctx := context.Background()
@@ -325,6 +329,7 @@ func TestElevenLabsStreamingAdapter_LanguageConversion(t *testing.T) {
"test-api-key",
"scribe_v1",
"es", // Spanish
nil,
)
ctx := context.Background()
@@ -372,6 +377,7 @@ func TestElevenLabsStreamingAdapter_Close(t *testing.T) {
"test-api-key",
"scribe_v1",
"en",
nil,
)
ctx := context.Background()
@@ -408,6 +414,7 @@ func TestElevenLabsStreamingAdapter_NotStarted(t *testing.T) {
"test-api-key",
"scribe_v1",
"en",
nil,
)
// SendChunk should fail when not started
@@ -470,6 +477,7 @@ func TestElevenLabsStreamingAdapter_ReconnectOnReadError(t *testing.T) {
"test-api-key",
"scribe_v1",
"en",
nil,
)
// use very short delays for testing
adapter.retryDelays = []time.Duration{10 * time.Millisecond, 20 * time.Millisecond, 40 * time.Millisecond}
@@ -547,6 +555,7 @@ func TestElevenLabsStreamingAdapter_ReconnectNotifiesClient(t *testing.T) {
"test-api-key",
"scribe_v1",
"en",
nil,
)
adapter.retryDelays = []time.Duration{10 * time.Millisecond, 20 * time.Millisecond, 40 * time.Millisecond}
@@ -633,6 +642,7 @@ func TestElevenLabsStreamingAdapter_MaxRetriesExhausted(t *testing.T) {
"test-api-key",
"scribe_v1",
"en",
nil,
)
adapter.retryDelays = []time.Duration{5 * time.Millisecond, 10 * time.Millisecond, 15 * time.Millisecond}
adapter.maxRetries = 2
@@ -709,6 +719,7 @@ func TestElevenLabsStreamingAdapter_ReconnectExponentialBackoff(t *testing.T) {
"test-api-key",
"scribe_v1",
"en",
nil,
)
// use measurable delays
adapter.retryDelays = []time.Duration{50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond}
@@ -13,7 +13,7 @@ func TestNewElevenLabsAdapter(t *testing.T) {
Path: "/v1/speech-to-text",
}
adapter := NewElevenLabsAdapter(endpoint, "test-api-key", "scribe_v1", "en")
adapter := NewElevenLabsAdapter(endpoint, "test-api-key", "scribe_v1", "en", nil)
if adapter == nil {
t.Fatalf("NewElevenLabsAdapter() returned nil")
@@ -74,7 +74,7 @@ func TestElevenLabsAdapter_Transcribe_EmptyAudio(t *testing.T) {
Path: "/v1/speech-to-text",
}
adapter := NewElevenLabsAdapter(endpoint, "test-key", "scribe_v1", "")
adapter := NewElevenLabsAdapter(endpoint, "test-key", "scribe_v1", "", nil)
ctx := context.Background()
result, err := adapter.Transcribe(ctx, []byte{})
@@ -94,7 +94,7 @@ func TestElevenLabsAdapter_Transcribe_ValidAudio(t *testing.T) {
Path: "/v1/speech-to-text",
}
adapter := NewElevenLabsAdapter(endpoint, "test-key", "scribe_v1", "en")
adapter := NewElevenLabsAdapter(endpoint, "test-key", "scribe_v1", "en", nil)
if adapter == nil {
t.Fatal("NewElevenLabsAdapter() returned nil")
@@ -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"
"time"
"github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/provider"
"github.com/sashabaranov/go-openai"
)
@@ -27,7 +26,7 @@ type OpenAIAdapter struct {
// endpoint: the BaseURL for the API (e.g., "https://api.openai.com", "https://api.groq.com/openai")
// apiKey: the API key for authentication
// model: model ID to use
// lang: canonical language code (will be converted to provider format)
// lang: provider language code
// keywords: optional spelling hints
// providerName: used for logging and language format conversion
func NewOpenAIAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string, providerName string) *OpenAIAdapter {
@@ -69,15 +68,12 @@ func (a *OpenAIAdapter) Transcribe(ctx context.Context, audioData []byte) (strin
return "", fmt.Errorf("convert to WAV: %w", err)
}
// Convert language code to provider format
providerLang := language.ToProviderFormat(a.language, a.providerName)
// Create transcription request
req := openai.AudioRequest{
Model: a.model,
Reader: bytes.NewReader(wavData),
FilePath: "audio.wav",
Language: providerLang,
Language: a.language,
}
// Add keywords as initial_prompt to help with spelling hints
@@ -8,6 +8,7 @@ import (
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
@@ -21,6 +22,7 @@ type OpenAIRealtimeAdapter struct {
apiKey string
model string
language string
keywords []string
conn *websocket.Conn
resultsCh chan TranscriptionResult
mu sync.Mutex
@@ -56,6 +58,7 @@ type openaiRealtimeSessionConfig struct {
type openaiRealtimeTranscription struct {
Model string `json:"model,omitempty"`
Language string `json:"language,omitempty"`
Prompt string `json:"prompt,omitempty"`
}
type openaiRealtimeTurnDetection struct {
@@ -104,12 +107,13 @@ type openaiRealtimeError struct {
// apiKey: OpenAI API key
// model: model ID (e.g., "gpt-4o-realtime-preview")
// lang: canonical language code (will be used for transcription config)
func NewOpenAIRealtimeAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *OpenAIRealtimeAdapter {
func NewOpenAIRealtimeAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *OpenAIRealtimeAdapter {
return &OpenAIRealtimeAdapter{
endpoint: endpoint,
apiKey: apiKey,
model: model,
language: lang,
keywords: keywords,
resultsCh: make(chan TranscriptionResult, 100),
maxRetries: 3,
retryDelays: defaultRetryDelays,
@@ -206,6 +210,10 @@ func (a *OpenAIRealtimeAdapter) configureSession() error {
sessionUpdate.Session.InputAudioTranscription.Language = a.language
}
if len(a.keywords) > 0 {
sessionUpdate.Session.InputAudioTranscription.Prompt = strings.Join(a.keywords, ", ")
}
return a.conn.WriteJSON(sessionUpdate)
}
@@ -118,7 +118,7 @@ func TestOpenAIRealtimeAdapter_Start(t *testing.T) {
Path: "",
}
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test-key", "gpt-4o-realtime-preview", "en")
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test-key", "gpt-4o-realtime-preview", "en", nil)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
@@ -198,7 +198,7 @@ func TestOpenAIRealtimeAdapter_SendChunk(t *testing.T) {
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "")
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "", nil)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
@@ -282,7 +282,7 @@ func TestOpenAIRealtimeAdapter_TranscriptionResults(t *testing.T) {
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "en")
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "en", nil)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
@@ -369,7 +369,7 @@ func TestOpenAIRealtimeAdapter_ErrorHandling(t *testing.T) {
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "")
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "", nil)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
@@ -433,7 +433,7 @@ func TestOpenAIRealtimeAdapter_Reconnection(t *testing.T) {
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "")
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "", nil)
adapter.retryDelays = []time.Duration{10 * time.Millisecond, 20 * time.Millisecond, 40 * time.Millisecond}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -473,7 +473,7 @@ func TestOpenAIRealtimeAdapter_Close(t *testing.T) {
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "")
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "", nil)
ctx := context.Background()
+6 -5
View File
@@ -10,8 +10,6 @@ import (
"path/filepath"
"strings"
"time"
"github.com/leonardotrapani/hyprvoice/internal/language"
)
// WhisperCppAdapter implements BatchAdapter for local whisper-cpp transcription
@@ -23,7 +21,7 @@ type WhisperCppAdapter struct {
// NewWhisperCppAdapter creates a new whisper-cpp adapter
// modelPath: full path to the model file (e.g., ~/.local/share/hyprvoice/models/whisper/ggml-base.en.bin)
// lang: canonical language code (will be converted to whisper-cpp format)
// lang: whisper-cpp language code
// threads: number of CPU threads (0 for auto)
func NewWhisperCppAdapter(modelPath, lang string, threads int) *WhisperCppAdapter {
return &WhisperCppAdapter{
@@ -63,8 +61,11 @@ func (a *WhisperCppAdapter) Transcribe(ctx context.Context, audioData []byte) (s
}
defer os.Remove(tmpFile)
// convert language to whisper-cpp format
lang := language.ToProviderFormat(a.language, "whisper-cpp")
// use whisper-cpp auto if unspecified
lang := a.language
if lang == "" {
lang = "auto"
}
// build command args
args := []string{
+6 -17
View File
@@ -8,7 +8,6 @@ import (
"golang.org/x/text/cases"
"golang.org/x/text/language"
lang "github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/models/whisper"
"github.com/leonardotrapani/hyprvoice/internal/provider"
"github.com/leonardotrapani/hyprvoice/internal/recording"
@@ -43,15 +42,6 @@ func NewTranscriber(config Config) (Transcriber, error) {
return nil, fmt.Errorf("provider is required")
}
// special case: groq-translation uses CreateTranslation API (different from transcription)
if config.Provider == provider.ConfigProviderGroqTranslation {
if config.APIKey == "" {
return nil, fmt.Errorf("Groq API key required")
}
adapter := NewGroqTranslationAdapter(config)
return NewSimpleTranscriber(config, adapter), nil
}
// map config provider name to registry provider name
registryProvider := provider.BaseProviderName(config.Provider)
@@ -89,8 +79,7 @@ func NewTranscriber(config Config) (Transcriber, error) {
// runtime language-model compatibility check with fallback
// primary validation happens at config time (hard error), this is a safety net
if config.Language != "" && !model.SupportsLanguage(config.Language) {
langName := lang.FromCode(config.Language).Name
log.Printf("warning: model %s does not support language %s, falling back to auto-detect", model.ID, langName)
log.Printf("warning: model %s does not support language %s, falling back to auto-detect", model.ID, config.Language)
config.Language = ""
}
@@ -119,11 +108,11 @@ func NewTranscriber(config Config) (Transcriber, error) {
var streamingAdapter StreamingAdapter
switch adapterType {
case provider.AdapterElevenLabsStream:
streamingAdapter = NewElevenLabsStreamingAdapter(endpoint, config.APIKey, model.ID, config.Language)
streamingAdapter = NewElevenLabsStreamingAdapter(endpoint, config.APIKey, model.ID, config.Language, config.Keywords)
case provider.AdapterDeepgram:
streamingAdapter = NewDeepgramAdapter(endpoint, config.APIKey, model.ID, config.Language)
streamingAdapter = NewDeepgramAdapter(endpoint, config.APIKey, model.ID, config.Language, config.Keywords)
case provider.AdapterOpenAIRealtime:
streamingAdapter = NewOpenAIRealtimeAdapter(endpoint, config.APIKey, model.ID, config.Language)
streamingAdapter = NewOpenAIRealtimeAdapter(endpoint, config.APIKey, model.ID, config.Language, config.Keywords)
default:
return nil, fmt.Errorf("unsupported streaming adapter type: %s", adapterType)
}
@@ -136,9 +125,9 @@ func NewTranscriber(config Config) (Transcriber, error) {
case provider.AdapterOpenAI:
adapter = NewOpenAIAdapter(model.Endpoint, config.APIKey, model.ID, config.Language, config.Keywords, registryProvider)
case provider.AdapterElevenLabs:
adapter = NewElevenLabsAdapter(model.Endpoint, config.APIKey, model.ID, config.Language)
adapter = NewElevenLabsAdapter(model.Endpoint, config.APIKey, model.ID, config.Language, config.Keywords)
case provider.AdapterDeepgram:
adapter = NewDeepgramBatchAdapter(model.Endpoint, config.APIKey, model.ID, config.Language)
adapter = NewDeepgramBatchAdapter(model.Endpoint, config.APIKey, model.ID, config.Language, config.Keywords)
case provider.AdapterWhisperCpp:
modelPath := whisper.GetModelPath(config.Model)
if modelPath == "" {
-20
View File
@@ -56,26 +56,6 @@ func TestNewTranscriber(t *testing.T) {
},
wantErr: true,
},
{
name: "valid groq-translation config",
config: Config{
Provider: "groq-translation",
APIKey: "gsk-test-key",
Language: "es",
Model: "whisper-large-v3-turbo",
},
wantErr: false,
},
{
name: "groq-translation config without api key",
config: Config{
Provider: "groq-translation",
APIKey: "",
Language: "es",
Model: "whisper-large-v3-turbo",
},
wantErr: true,
},
{
name: "valid mistral-transcription config",
config: Config{
+1 -1
View File
@@ -37,7 +37,7 @@ func editLLM(cfg *config.Config, configuredProviders []string) ([]string, error)
enableLLM := cfg.LLM.Enabled
enableDesc := "LLM improves transcription by fixing grammar, removing stutters, and cleaning up text"
enableDesc := "LLM improves transcription by fixing grammar, removing stutters, and cleaning up text. Recommended for weak voice models"
if cfg.LLM.Enabled {
enableDesc = fmt.Sprintf("Currently: enabled (%s/%s). %s", cfg.LLM.Provider, cfg.LLM.Model, enableDesc)
} else {
+28 -1
View File
@@ -47,6 +47,7 @@ func editProviders(cfg *config.Config, onboarding bool) error {
for {
var options []huh.Option[string]
options = append(options, huh.NewOption("Local", "local"))
for _, name := range AllProviders {
options = append(options, huh.NewOption(formatProviderOption(cfg, name), name))
}
@@ -75,6 +76,13 @@ func editProviders(cfg *config.Config, onboarding bool) error {
return nil
}
if selected == "local" {
if err := showLocalProviderInfo(); err != nil {
continue
}
return nil
}
apiKey, err := configureSingleProvider(cfg, selected)
if err != nil {
continue
@@ -90,6 +98,25 @@ func editProviders(cfg *config.Config, onboarding bool) error {
}
}
func showLocalProviderInfo() error {
selected := "done"
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Local Models").
Description("No need to configure any API keys for local models, go to the next step.").
Options(huh.NewOption("Done", "done")).
Value(&selected),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return err
}
return nil
}
// formatProviderOption formats a provider menu option with status
func formatProviderOption(cfg *config.Config, name string) string {
var status string
@@ -190,7 +217,7 @@ func inputAPIKey(providerName string) (string, error) {
func ensureProviderConfigured(cfg *config.Config, selectedProvider string, configuredProviders []string) []string {
providerName := selectedProvider
switch selectedProvider {
case "groq-transcription", "groq-translation":
case "groq-transcription":
providerName = "groq"
case "mistral-transcription":
providerName = "mistral"
+15 -12
View File
@@ -35,8 +35,7 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri
huh.NewOption("OpenAI Whisper", "openai"))
case "groq":
transcriptionOptions = append(transcriptionOptions,
huh.NewOption("Groq Whisper (transcription)", "groq-transcription"),
huh.NewOption("Groq Whisper (translate to English)", "groq-translation"))
huh.NewOption("Groq Whisper", "groq-transcription"))
case "mistral":
transcriptionOptions = append(transcriptionOptions,
huh.NewOption("Mistral Voxtral", "mistral-transcription"))
@@ -210,6 +209,17 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri
return configuredProviders, err
}
if cfg.Transcription.Language != "" && !model.SupportsLanguage(cfg.Transcription.Language) {
cfg.Transcription.Language = ""
}
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
@@ -229,6 +239,7 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri
}
cfg.Transcription.Language = selectedLanguage
}
// set streaming mode based on model capabilities
if model.SupportsBothModes() {
@@ -271,8 +282,7 @@ func getUnconfiguredTranscriptionOptions(configuredProviders []string) []huh.Opt
}
if !configured["groq"] {
options = append(options,
huh.NewOption("Groq Whisper transcription (not configured)", "groq-transcription"),
huh.NewOption("Groq Whisper translation (not configured)", "groq-translation"))
huh.NewOption("Groq Whisper (not configured)", "groq-transcription"))
}
if !configured["mistral"] {
options = append(options, huh.NewOption("Mistral Voxtral (not configured)", "mistral-transcription"))
@@ -284,13 +294,6 @@ func getUnconfiguredTranscriptionOptions(configuredProviders []string) []huh.Opt
}
func getTranscriptionModelOptions(configProvider string) []huh.Option[string] {
// special case: groq-translation only supports whisper-large-v3
if configProvider == "groq-translation" {
return []huh.Option[string]{
huh.NewOption("whisper-large-v3 (only option)", "whisper-large-v3"),
}
}
// map config provider name to registry provider name
registryName := mapConfigProviderToRegistry(configProvider)
p := provider.GetProvider(registryName)
@@ -319,7 +322,7 @@ func getTranscriptionModelOptions(configProvider string) []huh.Option[string] {
// mapConfigProviderToRegistry maps config provider names to registry provider names
func mapConfigProviderToRegistry(configProvider string) string {
switch configProvider {
case "groq-transcription", "groq-translation":
case "groq-transcription":
return "groq"
case "mistral-transcription":
return "mistral"
+7 -20
View File
@@ -1,10 +1,7 @@
package tui
import (
"fmt"
"github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
@@ -13,33 +10,23 @@ func getModelLanguageOptions(model *provider.Model, currentLang string) []huh.Op
var options []huh.Option[string]
// auto-detect is always first
autoLabel := "Auto-detect"
autoLabel := "Auto-detect (recommended)"
if currentLang == "" {
autoLabel += " (current)"
}
options = append(options, huh.NewOption(autoLabel, ""))
// only show languages supported by the model
for _, lang := range language.List() {
if model != nil && !model.SupportsLanguage(lang.Code) {
continue
if model == nil {
return options
}
label := formatLanguageLabel(lang)
if lang.Code == currentLang {
for _, code := range model.SupportedLanguages {
label := code
if code == currentLang {
label += " (current)"
}
options = append(options, huh.NewOption(label, lang.Code))
options = append(options, huh.NewOption(label, code))
}
return options
}
// formatLanguageLabel formats a language for display
func formatLanguageLabel(lang language.Language) string {
if lang.Name == lang.NativeName || lang.NativeName == "" {
return fmt.Sprintf("%s (%s)", lang.Name, lang.Code)
}
return fmt.Sprintf("%s - %s (%s)", lang.Name, lang.NativeName, lang.Code)
}