diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bca16f3..638f26b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,8 @@ on: branches: [main, develop] pull_request: branches: [main, develop] - workflow_call: {} # <-- makes this workflow reusable + workflow_dispatch: {} # manual trigger + workflow_call: {} # reusable workflow jobs: test: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..fa1b24a --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,61 @@ +# .github/workflows/e2e.yml +name: E2E Tests + +on: + workflow_dispatch: + inputs: + timeout: + description: 'Per-model timeout (e.g. 60s)' + required: false + default: '60s' + +jobs: + test-models: + name: Test All Models + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + pkg-config \ + libasound2-dev \ + libpulse-dev \ + libpipewire-0.3-dev + + - name: Download dependencies + run: go mod download + + - name: Build binary + env: + CGO_ENABLED: 1 + run: go build -o hyprvoice ./cmd/hyprvoice + + - name: Run test-models + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }} + GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} + ELEVENLABS_API_KEY: ${{ secrets.ELEVENLABS_API_KEY }} + MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} + run: | + ./hyprvoice test-models \ + --timeout=${{ inputs.timeout }} \ + --output=test-models-report.json + + - name: Upload report + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-models-report + path: test-models-report.json + retention-days: 30 diff --git a/README.md b/README.md index 84d6a65..dec91fb 100644 --- a/README.md +++ b/README.md @@ -29,31 +29,27 @@ All supported speech-to-text providers and models: - `whisper-large-v3` - `whisper-large-v3-turbo` -- `distil-whisper-large-v3-en` (English only) ### Mistral (cloud) - `voxtral-mini-latest` -- `voxtral-mini-2507` ### ElevenLabs (cloud) - `scribe_v1` (batch) - `scribe_v2` (batch) -- `scribe_v1-streaming` -- `scribe_v2-streaming` +- `scribe_v2_realtime` (streaming) ### whisper-cpp (local) - English-only: `tiny.en`, `base.en`, `small.en`, `medium.en` -- Multilingual: `tiny`, `base`, `small`, `medium`, `large-v3` +- Multilingual: `tiny`, `base`, `small`, `medium`, `large-v1`, `large-v2`, `large-v3`, `large-v3-turbo` ### Deepgram (cloud) +- `flux-general-en` - `nova-3` -- `nova-3-general` - `nova-2` -- `nova-2-general` Language coverage: 57 languages overall; Deepgram models cover a subset; English-only models are labeled above. @@ -120,6 +116,13 @@ hyprvoice model download base.en hyprvoice model remove base.en ``` +### Model testing (E2E) + +```bash +hyprvoice test-models +hyprvoice test-models --audio /path/to/sample.wav --output test-models.json +``` + ### Service management ```bash diff --git a/cmd/hyprvoice/main.go b/cmd/hyprvoice/main.go index 8ffdad0..0582535 100644 --- a/cmd/hyprvoice/main.go +++ b/cmd/hyprvoice/main.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "io" + "log" "os/exec" "path/filepath" "sort" @@ -38,6 +40,7 @@ func init() { onboardingCmd(), configureCmd(), modelCmd(), + testModelsCmd(), ) } @@ -166,7 +169,7 @@ func runConfigure(onboarding bool) error { var cfg *config.Config var err error if onboarding { - cfg, err = config.Load() + cfg, err = loadConfigQuiet() if err != nil { if errors.Is(err, config.ErrConfigNotFound) { cfg = config.DefaultConfig() @@ -175,7 +178,7 @@ func runConfigure(onboarding bool) error { } } } else { - cfg, err = config.Load() + cfg, err = loadConfigQuiet() if err != nil { return fmt.Errorf("failed to load config: %w", err) } @@ -213,6 +216,13 @@ func runConfigure(onboarding bool) error { return nil } +func loadConfigQuiet() (*config.Config, error) { + prev := log.Writer() + log.SetOutput(io.Discard) + defer log.SetOutput(prev) + return config.Load() +} + func showNextSteps(cfg *config.Config, onboarding bool) { // Check if service is running serviceRunning := false diff --git a/docs/config.md b/docs/config.md index dc2834c..8d6998d 100644 --- a/docs/config.md +++ b/docs/config.md @@ -108,10 +108,12 @@ language = "" # Empty for auto-detect, or "en", "es", "fr", et Transcription using Mistral's Voxtral API, excellent for European languages: +Note: Mistral's API supports streaming responses, but it is not real-time audio streaming. Hyprvoice treats Voxtral as batch-only. + ```toml [transcription] provider = "mistral-transcription" -model = "voxtral-mini-latest" # Or "voxtral-mini-2507" +model = "voxtral-mini-latest" language = "" # Empty for auto-detect ``` @@ -122,7 +124,7 @@ Transcription using ElevenLabs' Scribe API with 57+ language support: ```toml [transcription] provider = "elevenlabs" -model = "scribe_v1" # Or "scribe_v2" for lower latency +model = "scribe_v1" # Or "scribe_v2" for lower latency (batch) language = "" # Empty for auto-detect ``` @@ -148,9 +150,9 @@ language = "" # Empty for auto-detect **Features:** -- All models are streaming-only -- Nova-3: 42 languages, best accuracy -- Nova-2: 33 languages, faster with filler word detection +- Flux: streaming-only, English with turn detection +- Nova-3: 42 languages, best accuracy (batch+streaming) +- Nova-2: 33 languages, faster with filler word detection (batch+streaming) - Excellent for real-time transcription and live captions ### Local Transcription (whisper-cpp) @@ -182,7 +184,10 @@ threads = 0 # 0 = auto (uses NumCPU - 1) | `base` | 142MB | 57 languages | Daily multilingual use | | `small` | 466MB | 57 languages | Better multilingual | | `medium` | 1.5GB | 57 languages | Great accuracy | +| `large-v1` | 2.9GB | 57 languages | Best accuracy | +| `large-v2` | 2.9GB | 57 languages | Best accuracy | | `large-v3` | 3GB | 57 languages | Best accuracy | +| `large-v3-turbo` | 1.6GB | 57 languages | Faster large-v3 | **Threads configuration:** @@ -195,12 +200,13 @@ threads = 0 # 0 = auto (uses NumCPU - 1) For real-time transcription, use streaming models: ```toml -# ElevenLabs streaming +# ElevenLabs streaming (realtime only) [transcription] provider = "elevenlabs" -model = "scribe_v1-streaming" # Or "scribe_v2-streaming" for <150ms latency +model = "scribe_v2_realtime" +streaming = true -# Deepgram streaming (all models are streaming) +# Deepgram streaming (all models support streaming) [transcription] provider = "deepgram" model = "nova-3" @@ -215,8 +221,8 @@ model = "gpt-4o-realtime-preview" | Provider | Model | Latency | Languages | |----------|-------|---------|-----------| -| ElevenLabs | `scribe_v1-streaming` | Low | 57+ | -| ElevenLabs | `scribe_v2-streaming` | <150ms | 57+ | +| ElevenLabs | `scribe_v2_realtime` | <150ms | 57+ | +| Deepgram | `flux-general-en` | Very Low | en | | Deepgram | `nova-3` | Low | 42 | | Deepgram | `nova-2` | Very Low | 33 | | OpenAI | `gpt-4o-realtime-preview` | Low | 57 | @@ -257,7 +263,6 @@ Some models only support English. When configuring via `hyprvoice configure`, on | Provider | Model | |----------|-------| -| Groq | `distil-whisper-large-v3-en` | | whisper-cpp | `tiny.en`, `base.en`, `small.en`, `medium.en` | **Deepgram models** support fewer languages than the full 57 - see [providers.md](./providers.md#deepgram-language-support). @@ -270,8 +275,8 @@ Some models only support English. When configuring via `hyprvoice configure`, on ```toml # This combination will be rejected at validation: [transcription] -provider = "groq-transcription" -model = "distil-whisper-large-v3-en" # English only! +provider = "whisper-cpp" +model = "base.en" # English only! language = "es" # Error: model does not support Spanish ``` @@ -622,8 +627,9 @@ You can customize notification text via the `[notifications.messages]` section: api_key = "..." [transcription] - provider = "elevenlabs" - model = "scribe_v2-streaming" # <150ms latency +provider = "elevenlabs" +model = "scribe_v2_realtime" # <150ms latency +streaming = true language = "" # Auto-detect [llm] diff --git a/docs/providers.md b/docs/providers.md index 863cf62..2aa5e22 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -11,7 +11,7 @@ This guide helps you choose the right transcription provider for your use case. | **Mistral** | Cloud | 2 | 57 | No | Fast | Good | Pay per use | | **ElevenLabs** | Cloud | 4 | 57+ | Yes | Fast | Excellent | Pay per use | | **Deepgram** | Cloud | 4 | 33-42 | Yes | Very Fast | Excellent | Pay per use | -| **whisper-cpp** | Local | 9 | 57 (4 EN-only) | No | Varies | Excellent | Free | +| **whisper-cpp** | Local | 12 | 57 (4 EN-only) | No | Varies | Excellent | Free | ### OpenAI @@ -32,7 +32,6 @@ Extremely fast inference using specialized hardware. OpenAI-compatible API. **Models:** - `whisper-large-v3` - Full Whisper v3, best accuracy - `whisper-large-v3-turbo` - Faster with slightly lower accuracy -- `distil-whisper-large-v3-en` - **English only**, fastest option **Best for:** Speed-critical applications, English-only use cases, budget-conscious users @@ -42,7 +41,8 @@ European provider with Voxtral transcription models. **Models:** - `voxtral-mini-latest` - Latest Voxtral, recommended -- `voxtral-mini-2507` - Stable version from July 2025 + +**Notes:** Mistral's streaming responses are not real-time audio streaming; hyprvoice treats Voxtral as batch-only. **Best for:** European data residency requirements, Mistral ecosystem users @@ -52,9 +52,8 @@ Known for voice synthesis, also offers excellent transcription via Scribe. **Models:** - `scribe_v1` - 90+ languages, best accuracy (batch) -- `scribe_v2` - Lower latency, real-time optimized (batch) -- `scribe_v1-streaming` - Real-time transcription -- `scribe_v2-streaming` - Real-time with <150ms latency +- `scribe_v2` - Lower latency (batch) +- `scribe_v2_realtime` - Streaming-only realtime endpoint **Best for:** Applications needing both TTS and STT, ultra-low latency streaming @@ -63,10 +62,11 @@ Known for voice synthesis, also offers excellent transcription via Scribe. Streaming-first provider with Nova models. Excellent for real-time applications. **Models:** +- `flux-general-en` - Streaming with turn detection (English) - `nova-3` - Best accuracy, 42 languages -- `nova-3-general` - Same as nova-3 - `nova-2` - Fast, 33 languages, filler word detection -- `nova-2-general` - Same as nova-2 + +**Notes:** Flux is English-only. **Language Support:** Nova-3 supports 42 languages, Nova-2 supports 33 languages. Not all 57 languages from the master list are available. @@ -93,7 +93,10 @@ Run Whisper models locally on your machine. No API keys, no network latency, com | `base` | 142MB | Fast | Good | | `small` | 466MB | Medium | Better | | `medium` | 1.5GB | Slow | Great | +| `large-v1` | 2.9GB | Slowest | Best | +| `large-v2` | 2.9GB | Slowest | Best | | `large-v3` | 3GB | Slowest | Best | +| `large-v3-turbo` | 1.6GB | Slower | Great | **Best for:** Privacy-sensitive applications, offline use, avoiding API costs @@ -121,14 +124,11 @@ Need complete privacy? └─ Need real-time streaming? ├─ Yes │ └─ Latency critical (<150ms)? - │ ├─ Yes → ElevenLabs scribe_v2-streaming + │ ├─ Yes → ElevenLabs scribe_v2_realtime (streaming) │ └─ No → Deepgram nova-3 or OpenAI realtime └─ No (batch) └─ Need fastest response? - ├─ Yes - │ └─ English only? - │ ├─ Yes → Groq distil-whisper-large-v3-en - │ └─ No → Groq whisper-large-v3-turbo + ├─ Yes → Groq whisper-large-v3-turbo └─ No └─ Need highest accuracy? ├─ Yes → OpenAI gpt-4o-transcribe or Groq whisper-large-v3 @@ -140,10 +140,9 @@ Need complete privacy? | Use Case | Recommended Provider | Model | |----------|---------------------|-------| | General dictation | OpenAI | whisper-1 | -| Fast English | Groq | distil-whisper-large-v3-en | | Fast multilingual | Groq | whisper-large-v3-turbo | | Live captions | Deepgram | nova-3 | -| Ultra-low latency | ElevenLabs | scribe_v2-streaming | +| Ultra-low latency | ElevenLabs | scribe_v2_realtime (streaming) | | Offline/privacy | whisper-cpp | base.en or base | | High accuracy | OpenAI | gpt-4o-transcribe | @@ -155,7 +154,7 @@ All providers support **auto-detect mode** (recommended for most users) which au ### Full Language Support (57 languages) -OpenAI, Groq (except distil model), Mistral, ElevenLabs, and whisper-cpp multilingual models support all 57 languages: +OpenAI, Groq, Mistral, ElevenLabs, and whisper-cpp multilingual models support all 57 languages: Afrikaans, Arabic, Armenian, Azerbaijani, Belarusian, Bosnian, Bulgarian, Catalan, Chinese, Croatian, Czech, Danish, Dutch, English, Estonian, Finnish, French, Galician, German, Greek, Hebrew, Hindi, Hungarian, Icelandic, Indonesian, Italian, Japanese, Kannada, Kazakh, Korean, Latvian, Lithuanian, Macedonian, Malay, Marathi, Maori, Nepali, Norwegian, Persian, Polish, Portuguese, Romanian, Russian, Serbian, Slovak, Slovenian, Spanish, Swahili, Swedish, Tagalog, Tamil, Thai, Turkish, Ukrainian, Urdu, Vietnamese, Welsh @@ -165,7 +164,6 @@ These models only support English but are faster: | Provider | Model | |----------|-------| -| Groq | `distil-whisper-large-v3-en` | | whisper-cpp | `tiny.en`, `base.en`, `small.en`, `medium.en` | If you select an English-only model with a non-English language, hyprvoice will: diff --git a/internal/config/config_test.go b/internal/config/config_test.go index dce412b..fcbb3b5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1966,10 +1966,12 @@ timeout = "5m" [transcription] provider = "openai" -api_key = "test-key" model = "whisper-1" language = "es" +[providers.openai] +api_key = "test-key" + [injection] backends = ["clipboard"] ydotool_timeout = "5s" diff --git a/internal/config/save.go b/internal/config/save.go index 84481fe..82af52a 100644 --- a/internal/config/save.go +++ b/internal/config/save.go @@ -321,7 +321,7 @@ keywords = [] # - "openai": OpenAI Whisper API (cloud-based, excellent accuracy) # - "groq-transcription": Groq Whisper API (very fast, models: whisper-large-v3, whisper-large-v3-turbo) # - "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, scribe_v2_realtime) # # LLM providers (for post-processing): # - "openai": GPT models (gpt-4o-mini recommended for cost/quality balance) diff --git a/internal/models/whisper/models.go b/internal/models/whisper/models.go index 4596781..d7493c6 100644 --- a/internal/models/whisper/models.go +++ b/internal/models/whisper/models.go @@ -28,7 +28,10 @@ var models = []ModelInfo{ {ID: "base", Name: "Base", Filename: "ggml-base.bin", Size: "142MB", SizeBytes: 142_000_000, Multilingual: true}, {ID: "small", Name: "Small", Filename: "ggml-small.bin", Size: "466MB", SizeBytes: 466_000_000, Multilingual: true}, {ID: "medium", Name: "Medium", Filename: "ggml-medium.bin", Size: "1.5GB", SizeBytes: 1_500_000_000, Multilingual: true}, + {ID: "large-v1", Name: "Large V1", Filename: "ggml-large-v1.bin", Size: "2.9GB", SizeBytes: 2_900_000_000, Multilingual: true}, + {ID: "large-v2", Name: "Large V2", Filename: "ggml-large-v2.bin", Size: "2.9GB", SizeBytes: 2_900_000_000, Multilingual: true}, {ID: "large-v3", Name: "Large V3", Filename: "ggml-large-v3.bin", Size: "3GB", SizeBytes: 3_000_000_000, Multilingual: true}, + {ID: "large-v3-turbo", Name: "Large V3 Turbo", Filename: "ggml-large-v3-turbo.bin", Size: "1.6GB", SizeBytes: 1_600_000_000, Multilingual: true}, } // modelByID maps model ID to ModelInfo for quick lookup diff --git a/internal/models/whisper/whisper_test.go b/internal/models/whisper/whisper_test.go index 4e00c6c..8451d78 100644 --- a/internal/models/whisper/whisper_test.go +++ b/internal/models/whisper/whisper_test.go @@ -108,8 +108,8 @@ func TestGetModel(t *testing.T) { func TestListModels(t *testing.T) { models := ListModels() - if len(models) != 9 { - t.Errorf("ListModels() returned %d models, want 9", len(models)) + if len(models) != 12 { + t.Errorf("ListModels() returned %d models, want 12", len(models)) } // verify known models exist @@ -118,7 +118,7 @@ func TestListModels(t *testing.T) { ids[m.ID] = true } - expected := []string{"tiny.en", "base.en", "small.en", "medium.en", "tiny", "base", "small", "medium", "large-v3"} + expected := []string{"tiny.en", "base.en", "small.en", "medium.en", "tiny", "base", "small", "medium", "large-v1", "large-v2", "large-v3", "large-v3-turbo"} for _, id := range expected { if !ids[id] { t.Errorf("ListModels() missing model %s", id) @@ -128,8 +128,8 @@ func TestListModels(t *testing.T) { func TestListMultilingualModels(t *testing.T) { models := ListMultilingualModels() - if len(models) != 5 { - t.Errorf("ListMultilingualModels() returned %d models, want 5", len(models)) + if len(models) != 8 { + t.Errorf("ListMultilingualModels() returned %d models, want 8", len(models)) } for _, m := range models { diff --git a/internal/provider/deepgram.go b/internal/provider/deepgram.go index 509912d..c08e4ed 100644 --- a/internal/provider/deepgram.go +++ b/internal/provider/deepgram.go @@ -16,6 +16,10 @@ func (p *DeepgramProvider) ValidateAPIKey(key string) bool { return len(key) > 0 } +func (p *DeepgramProvider) APIKeyURL() string { + return "https://console.deepgram.com/project/keys" +} + func (p *DeepgramProvider) IsLocal() bool { return false } @@ -23,7 +27,6 @@ func (p *DeepgramProvider) IsLocal() bool { func (p *DeepgramProvider) Models() []Model { // 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" @@ -32,7 +35,7 @@ func (p *DeepgramProvider) Models() []Model { { ID: "nova-3", Name: "Nova-3", - Description: "Best accuracy, 40+ languages", + Description: "Best accuracy; streaming available for faster response", Type: Transcription, SupportsBatch: true, SupportsStreaming: true, @@ -46,7 +49,7 @@ func (p *DeepgramProvider) Models() []Model { { ID: "nova-2", Name: "Nova-2", - Description: "Fast, 30+ languages, filler words", + Description: "Cheaper legacy model; still solid accuracy", Type: Transcription, SupportsBatch: true, SupportsStreaming: true, diff --git a/internal/provider/deepgram_test.go b/internal/provider/deepgram_test.go index 4ddb234..7e91c7f 100644 --- a/internal/provider/deepgram_test.go +++ b/internal/provider/deepgram_test.go @@ -29,11 +29,8 @@ func TestDeepgramProvider_Models(t *testing.T) { t.Errorf("Models() returned %d models, want 2", len(models)) } - // all models should support both batch and streaming + // all models should support both streaming and batch for _, m := range models { - if !m.SupportsBatch { - t.Errorf("model %s should support batch", m.ID) - } if !m.SupportsStreaming { t.Errorf("model %s should support streaming", m.ID) } diff --git a/internal/provider/elevenlabs.go b/internal/provider/elevenlabs.go index c2a432b..210ebfd 100644 --- a/internal/provider/elevenlabs.go +++ b/internal/provider/elevenlabs.go @@ -16,6 +16,10 @@ func (p *ElevenLabsProvider) ValidateAPIKey(key string) bool { return len(key) > 0 } +func (p *ElevenLabsProvider) APIKeyURL() string { + return "https://elevenlabs.io/app/settings/api-keys" +} + func (p *ElevenLabsProvider) IsLocal() bool { return false } @@ -29,40 +33,46 @@ func (p *ElevenLabsProvider) Models() []Model { { ID: "scribe_v1", Name: "Scribe v1", - Description: "90+ languages, best accuracy", + Description: "Most accurate; best for precision-critical work", Type: Transcription, SupportsBatch: true, SupportsStreaming: false, Local: false, AdapterType: AdapterElevenLabs, + StreamingAdapter: AdapterElevenLabsStream, SupportedLanguages: allLangs, Endpoint: &EndpointConfig{BaseURL: "https://api.elevenlabs.io", Path: "/v1/speech-to-text"}, + StreamingEndpoint: &EndpointConfig{BaseURL: "wss://api.elevenlabs.io", Path: "/v1/speech-to-text/realtime"}, DocsURL: docsURL, }, { ID: "scribe_v2", Name: "Scribe v2", - Description: "Lower latency batch transcription", + Description: "Faster processing with good accuracy", Type: Transcription, SupportsBatch: true, SupportsStreaming: false, Local: false, AdapterType: AdapterElevenLabs, + StreamingAdapter: AdapterElevenLabsStream, SupportedLanguages: allLangs, Endpoint: &EndpointConfig{BaseURL: "https://api.elevenlabs.io", Path: "/v1/speech-to-text"}, + StreamingEndpoint: &EndpointConfig{BaseURL: "wss://api.elevenlabs.io", Path: "/v1/speech-to-text/realtime"}, DocsURL: docsURL, }, { ID: "scribe_v2_realtime", Name: "Scribe v2 Realtime", - Description: "Real-time streaming, <150ms latency", + Description: "Instant words as you speak; faster but costs more", Type: Transcription, SupportsBatch: false, SupportsStreaming: true, Local: false, - AdapterType: AdapterElevenLabsStream, + AdapterType: AdapterElevenLabs, + StreamingAdapter: AdapterElevenLabsStream, SupportedLanguages: allLangs, - Endpoint: &EndpointConfig{BaseURL: "wss://api.elevenlabs.io", Path: "/v1/speech-to-text/realtime"}, + Endpoint: &EndpointConfig{BaseURL: "https://api.elevenlabs.io", Path: "/v1/speech-to-text"}, + StreamingEndpoint: &EndpointConfig{BaseURL: "wss://api.elevenlabs.io", Path: "/v1/speech-to-text/realtime"}, DocsURL: docsURL, }, } diff --git a/internal/provider/groq.go b/internal/provider/groq.go index f2d4f6c..82cb55b 100644 --- a/internal/provider/groq.go +++ b/internal/provider/groq.go @@ -17,6 +17,10 @@ func (p *GroqProvider) ValidateAPIKey(key string) bool { return strings.HasPrefix(key, "gsk_") } +func (p *GroqProvider) APIKeyURL() string { + return "https://console.groq.com/keys" +} + func (p *GroqProvider) IsLocal() bool { return false } @@ -31,7 +35,7 @@ func (p *GroqProvider) Models() []Model { { ID: "whisper-large-v3", Name: "Whisper Large v3", - Description: "Full Whisper v3 model, best accuracy", + Description: "Best accuracy; generous free tier makes this great default", Type: Transcription, SupportsBatch: true, SupportsStreaming: false, @@ -44,7 +48,7 @@ func (p *GroqProvider) Models() []Model { { ID: "whisper-large-v3-turbo", Name: "Whisper Large v3 Turbo", - Description: "Faster Whisper v3 with slightly lower accuracy", + Description: "Faster with slight accuracy tradeoff; still very good", Type: Transcription, SupportsBatch: true, SupportsStreaming: false, @@ -58,7 +62,7 @@ func (p *GroqProvider) Models() []Model { { ID: "llama-3.3-70b-versatile", Name: "Llama 3.3 70B Versatile", - Description: "Most capable Llama model", + Description: "Best quality cleanup; smart rewrites, free tier available", Type: LLM, SupportsBatch: true, SupportsStreaming: false, @@ -69,18 +73,7 @@ func (p *GroqProvider) Models() []Model { { ID: "llama-3.1-8b-instant", Name: "Llama 3.1 8B Instant", - Description: "Fast and efficient", - Type: LLM, - SupportsBatch: true, - SupportsStreaming: false, - Local: false, - AdapterType: AdapterOpenAI, - Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"}, - }, - { - ID: "mixtral-8x7b-32768", - Name: "Mixtral 8x7B", - Description: "Mixture of experts model", + Description: "Very fast; good for simple cleanup tasks", Type: LLM, SupportsBatch: true, SupportsStreaming: false, diff --git a/internal/provider/languages.go b/internal/provider/languages.go index 3064f8f..f076517 100644 --- a/internal/provider/languages.go +++ b/internal/provider/languages.go @@ -34,6 +34,8 @@ var deepgramNova2Languages = []string{ "ru", "sk", "es", "es-419", "sv", "sv-SE", "th", "th-TH", "tr", "uk", "vi", } +var deepgramFluxLanguages = []string{"en"} + 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", diff --git a/internal/provider/mistral.go b/internal/provider/mistral.go index d6a0373..55b6cc1 100644 --- a/internal/provider/mistral.go +++ b/internal/provider/mistral.go @@ -16,6 +16,10 @@ func (p *MistralProvider) ValidateAPIKey(key string) bool { return len(key) > 0 } +func (p *MistralProvider) APIKeyURL() string { + return "https://admin.mistral.ai/organization/api-keys" +} + func (p *MistralProvider) IsLocal() bool { return false } @@ -29,27 +33,12 @@ func (p *MistralProvider) Models() []Model { { ID: "voxtral-mini-latest", Name: "Voxtral Mini Latest", - Description: "Latest Voxtral model, best for most uses", + Description: "EU-hosted; good for data residency or Mistral ecosystem", Type: Transcription, SupportsBatch: true, - SupportsStreaming: true, + SupportsStreaming: false, Local: false, AdapterType: AdapterOpenAI, - StreamingAdapter: "mistral-streaming", // not yet implemented - SupportedLanguages: allLangs, - Endpoint: &EndpointConfig{BaseURL: "https://api.mistral.ai", Path: "/v1/audio/transcriptions"}, - DocsURL: docsURL, - }, - { - ID: "voxtral-mini-2507", - Name: "Voxtral Mini 2507", - Description: "Stable Voxtral version from July 2025", - Type: Transcription, - SupportsBatch: true, - SupportsStreaming: true, - Local: false, - AdapterType: AdapterOpenAI, - StreamingAdapter: "mistral-streaming", // not yet implemented SupportedLanguages: allLangs, Endpoint: &EndpointConfig{BaseURL: "https://api.mistral.ai", Path: "/v1/audio/transcriptions"}, DocsURL: docsURL, diff --git a/internal/provider/model_test.go b/internal/provider/model_test.go index cf0ca06..1928066 100644 --- a/internal/provider/model_test.go +++ b/internal/provider/model_test.go @@ -57,7 +57,7 @@ func TestModel_IsStreaming(t *testing.T) { }{ { name: "streaming-only model", - model: Model{ID: "scribe_v2_realtime", SupportsBatch: false, SupportsStreaming: true}, + model: Model{ID: "flux-general-en", SupportsBatch: false, SupportsStreaming: true}, expected: true, }, { @@ -89,7 +89,7 @@ func TestModel_SupportsBothModes(t *testing.T) { }{ { name: "streaming-only model", - model: Model{ID: "scribe_v2_realtime", SupportsBatch: false, SupportsStreaming: true}, + model: Model{ID: "flux-general-en", SupportsBatch: false, SupportsStreaming: true}, expected: false, }, { @@ -325,7 +325,7 @@ func TestAllTranscriptionModels_HaveDocsURL(t *testing.T) { "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", + "whisper-cpp": "https://github.com/ggml-org/whisper.cpp#models", } for _, pName := range providers { diff --git a/internal/provider/openai.go b/internal/provider/openai.go index 66c2fc6..a620cb9 100644 --- a/internal/provider/openai.go +++ b/internal/provider/openai.go @@ -17,6 +17,10 @@ func (p *OpenAIProvider) ValidateAPIKey(key string) bool { return strings.HasPrefix(key, "sk-") } +func (p *OpenAIProvider) APIKeyURL() string { + return "https://platform.openai.com/api-keys" +} + func (p *OpenAIProvider) IsLocal() bool { return false } @@ -32,7 +36,7 @@ func (p *OpenAIProvider) Models() []Model { { ID: "whisper-1", Name: "Whisper 1", - Description: "OpenAI's production speech-to-text model", + Description: "Reliable and cost-effective; good default for most use cases", Type: Transcription, SupportsBatch: true, SupportsStreaming: false, @@ -45,7 +49,7 @@ func (p *OpenAIProvider) Models() []Model { { ID: "gpt-4o-transcribe", Name: "GPT-4o Transcribe", - Description: "High quality transcription with GPT-4o", + Description: "Top accuracy; slower and pricier but best quality", Type: Transcription, SupportsBatch: true, SupportsStreaming: false, @@ -58,7 +62,7 @@ func (p *OpenAIProvider) Models() []Model { { ID: "gpt-4o-mini-transcribe", Name: "GPT-4o Mini Transcribe", - Description: "Fast transcription with GPT-4o Mini", + Description: "Good balance of speed, cost, and quality", Type: Transcription, SupportsBatch: true, SupportsStreaming: false, @@ -71,7 +75,7 @@ func (p *OpenAIProvider) Models() []Model { { ID: "gpt-4o-realtime-preview", Name: "GPT-4o Realtime Preview", - Description: "Real-time streaming transcription with GPT-4o", + Description: "Instant words as you speak; fastest but most expensive", Type: Transcription, SupportsBatch: false, SupportsStreaming: true, @@ -85,7 +89,7 @@ func (p *OpenAIProvider) Models() []Model { { ID: "gpt-4o-mini", Name: "GPT-4o Mini", - Description: "Fast and affordable GPT-4 variant", + Description: "Fast and cheap; good default for text cleanup", Type: LLM, SupportsBatch: true, SupportsStreaming: false, @@ -96,7 +100,7 @@ func (p *OpenAIProvider) Models() []Model { { ID: "gpt-4o", Name: "GPT-4o", - Description: "Most capable GPT-4 model", + Description: "Best quality cleanup; pricier but smarter rewrites", Type: LLM, SupportsBatch: true, SupportsStreaming: false, diff --git a/internal/provider/provider.go b/internal/provider/provider.go index d555f63..fb1013d 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -11,6 +11,7 @@ type Provider interface { Name() string RequiresAPIKey() bool ValidateAPIKey(key string) bool + APIKeyURL() string IsLocal() bool Models() []Model DefaultModel(t ModelType) string diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 6ddae43..1e7a878 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -279,7 +279,7 @@ func TestValidateModelLanguage_ErrorFormat(t *testing.T) { } // should contain docs URL - if !strings.Contains(errMsg, "https://github.com/openai/whisper") { + if !strings.Contains(errMsg, "https://github.com/ggml-org/whisper.cpp") { t.Errorf("error should contain docs URL, got: %s", errMsg) } @@ -343,7 +343,7 @@ func TestElevenLabsProvider(t *testing.T) { t.Errorf("ElevenLabsProvider.Models() = %d models, want 3", len(models)) } - // Check batch-only models + // Check batch + streaming models scribeV1, err := GetModel("elevenlabs", "scribe_v1") if err != nil { t.Fatalf("GetModel('elevenlabs', 'scribe_v1') error: %v", err) @@ -357,6 +357,9 @@ func TestElevenLabsProvider(t *testing.T) { if scribeV1.AdapterType != "elevenlabs" { t.Errorf("scribe_v1 AdapterType=%q, want 'elevenlabs'", scribeV1.AdapterType) } + if scribeV1.StreamingAdapter != "elevenlabs-streaming" { + t.Errorf("scribe_v1 StreamingAdapter=%q, want 'elevenlabs-streaming'", scribeV1.StreamingAdapter) + } scribeV2, err := GetModel("elevenlabs", "scribe_v2") if err != nil { @@ -371,8 +374,10 @@ func TestElevenLabsProvider(t *testing.T) { if scribeV2.AdapterType != "elevenlabs" { t.Errorf("scribe_v2 AdapterType=%q, want 'elevenlabs'", scribeV2.AdapterType) } + if scribeV2.StreamingAdapter != "elevenlabs-streaming" { + t.Errorf("scribe_v2 StreamingAdapter=%q, want 'elevenlabs-streaming'", scribeV2.StreamingAdapter) + } - // Check streaming-only model scribeV2Realtime, err := GetModel("elevenlabs", "scribe_v2_realtime") if err != nil { t.Fatalf("GetModel('elevenlabs', 'scribe_v2_realtime') error: %v", err) @@ -383,8 +388,11 @@ func TestElevenLabsProvider(t *testing.T) { if !scribeV2Realtime.SupportsStreaming { t.Error("scribe_v2_realtime should have SupportsStreaming=true") } - if scribeV2Realtime.AdapterType != "elevenlabs-streaming" { - t.Errorf("scribe_v2_realtime AdapterType=%q, want 'elevenlabs-streaming'", scribeV2Realtime.AdapterType) + if scribeV2Realtime.AdapterType != "elevenlabs" { + t.Errorf("scribe_v2_realtime AdapterType=%q, want 'elevenlabs'", scribeV2Realtime.AdapterType) + } + if scribeV2Realtime.StreamingAdapter != "elevenlabs-streaming" { + t.Errorf("scribe_v2_realtime StreamingAdapter=%q, want 'elevenlabs-streaming'", scribeV2Realtime.StreamingAdapter) } // All models should share the same supported language list diff --git a/internal/provider/whisper_cpp.go b/internal/provider/whisper_cpp.go index e919d8e..b733bd3 100644 --- a/internal/provider/whisper_cpp.go +++ b/internal/provider/whisper_cpp.go @@ -17,16 +17,20 @@ func (p *WhisperCppProvider) ValidateAPIKey(key string) bool { return true // no API key needed } +func (p *WhisperCppProvider) APIKeyURL() string { + return "" +} + func (p *WhisperCppProvider) IsLocal() bool { return true } func (p *WhisperCppProvider) Models() []Model { - // https://github.com/openai/whisper#available-models-and-languages + // https://github.com/ggml-org/whisper.cpp#models allLangs := whisperTranscriptionLanguages - // https://github.com/openai/whisper#available-models-and-languages + // https://github.com/ggml-org/whisper.cpp#models englishOnly := whisperEnglishOnlyLanguages - docsURL := "https://github.com/openai/whisper#available-models-and-languages" + docsURL := "https://github.com/ggml-org/whisper.cpp#models" whisperModels := whisper.ListModels() result := make([]Model, 0, len(whisperModels)) @@ -63,10 +67,36 @@ func (p *WhisperCppProvider) Models() []Model { } func modelDescription(m whisper.ModelInfo) string { - if m.Multilingual { - return "Multilingual local transcription" + switch m.ID { + case "tiny.en": + return "Free/offline; fastest but low accuracy, good for weak hardware" + case "base.en": + return "Free/offline; balanced speed and accuracy, recommended start" + case "small.en": + return "Free/offline; better accuracy, needs decent CPU" + case "medium.en": + return "Free/offline; best .en accuracy, needs good CPU/RAM" + case "tiny": + return "Free/offline multilingual; fastest but low accuracy" + case "base": + return "Free/offline multilingual; balanced, recommended start" + case "small": + return "Free/offline multilingual; better accuracy, needs decent CPU" + case "medium": + return "Free/offline multilingual; great accuracy, needs good CPU/RAM" + case "large-v1": + return "Free/offline; high accuracy, needs strong CPU/GPU" + case "large-v2": + return "Free/offline; high accuracy, needs strong CPU/GPU" + case "large-v3": + return "Free/offline; best accuracy available, needs strong hardware" + case "large-v3-turbo": + return "Free/offline; near-best accuracy with better speed" } - return "English-only local transcription (faster)" + if m.Multilingual { + return "Free/offline multilingual model" + } + return "Free/offline English model" } func (p *WhisperCppProvider) DefaultModel(t ModelType) string { diff --git a/internal/provider/whisper_cpp_test.go b/internal/provider/whisper_cpp_test.go index 0995ed7..f8aa646 100644 --- a/internal/provider/whisper_cpp_test.go +++ b/internal/provider/whisper_cpp_test.go @@ -16,9 +16,9 @@ func TestWhisperCppProvider_Models(t *testing.T) { p := &WhisperCppProvider{} models := p.Models() - // verify we have 9 models - if len(models) != 9 { - t.Errorf("expected 9 models, got %d", len(models)) + // verify we have 12 models + if len(models) != 12 { + t.Errorf("expected 12 models, got %d", len(models)) } // verify all models have required fields @@ -77,11 +77,14 @@ func TestWhisperCppProvider_MultilingualModels(t *testing.T) { models := p.Models() multilingualIDs := map[string]bool{ - "tiny": true, - "base": true, - "small": true, - "medium": true, - "large-v3": true, + "tiny": true, + "base": true, + "small": true, + "medium": true, + "large-v1": true, + "large-v2": true, + "large-v3": true, + "large-v3-turbo": true, } for _, m := range models { diff --git a/internal/transcriber/adapter_deepgram.go b/internal/transcriber/adapter_deepgram.go index a17d1e9..e414b7c 100644 --- a/internal/transcriber/adapter_deepgram.go +++ b/internal/transcriber/adapter_deepgram.go @@ -36,6 +36,7 @@ type DeepgramAdapter struct { // finalization signaling finalizeDone chan struct{} + finalizing bool // true when Finalize() has been called } // deepgramCloseStream message to signal end of audio @@ -235,7 +236,8 @@ func (a *DeepgramAdapter) buildURL() (string, error) { q.Set("language", lang) } - if len(a.keywords) > 0 { + // nova-3 uses "keyterm" (singular), others use "keywords" (plural) + if len(a.keywords) > 0 && !strings.HasPrefix(a.model, "nova-3") && !strings.HasPrefix(a.model, "flux") { q.Set("keywords", strings.Join(a.keywords, ",")) } @@ -277,6 +279,20 @@ func (a *DeepgramAdapter) readLoop() { default: } + // check if we're finalizing - normal close after finalize is expected + a.mu.Lock() + finalizing := a.finalizing + a.mu.Unlock() + + if finalizing { + // expected close after finalization, signal done and exit gracefully + select { + case a.finalizeDone <- struct{}{}: + default: + } + return + } + // attempt reconnection log.Printf("deepgram: read error: %v, attempting reconnection", err) if !a.reconnect() { @@ -411,6 +427,11 @@ func (a *DeepgramAdapter) Finalize(ctx context.Context) error { default: } + // mark as finalizing to prevent reconnection attempts on normal close + a.mu.Lock() + a.finalizing = true + a.mu.Unlock() + // send CloseStream message msg := deepgramCloseStream{Type: "CloseStream"} @@ -447,6 +468,9 @@ func (a *DeepgramAdapter) Close() error { return nil } + // mark as finalizing to prevent reconnection attempts + a.finalizing = true + // cancel context first to signal reader to stop if a.cancel != nil { a.cancel() diff --git a/internal/transcriber/adapter_deepgram_batch.go b/internal/transcriber/adapter_deepgram_batch.go index e8d497d..84198dc 100644 --- a/internal/transcriber/adapter_deepgram_batch.go +++ b/internal/transcriber/adapter_deepgram_batch.go @@ -49,21 +49,31 @@ func NewDeepgramBatchAdapter(endpoint *provider.EndpointConfig, apiKey, model, l // Transcribe sends audio data to Deepgram's pre-recorded API func (a *DeepgramBatchAdapter) 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) + } + // build URL with query parameters apiURL, err := a.buildURL() if err != nil { return "", fmt.Errorf("build url: %w", err) } - // create request with audio data as body - req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(audioData)) + // create request with WAV data as body + req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(wavData)) if err != nil { return "", fmt.Errorf("create request: %w", err) } // set headers req.Header.Set("Authorization", "Token "+a.apiKey) - req.Header.Set("Content-Type", "audio/wav") // we send raw PCM wrapped as WAV + req.Header.Set("Content-Type", "audio/wav") // send request resp, err := http.DefaultClient.Do(req) @@ -123,7 +133,8 @@ func (a *DeepgramBatchAdapter) buildURL() (string, error) { q.Set("language", lang) } - if len(a.keywords) > 0 { + // nova-3 uses "keyterm" (singular), others use "keywords" (plural) + if len(a.keywords) > 0 && !strings.HasPrefix(a.model, "nova-3") && !strings.HasPrefix(a.model, "flux") { q.Set("keywords", strings.Join(a.keywords, ",")) } diff --git a/internal/transcriber/adapter_elevenlabs.go b/internal/transcriber/adapter_elevenlabs.go index 5abb4d3..acce159 100644 --- a/internal/transcriber/adapter_elevenlabs.go +++ b/internal/transcriber/adapter_elevenlabs.go @@ -82,13 +82,12 @@ func (a *ElevenLabsAdapter) Transcribe(ctx context.Context, audioData []byte) (s } } - 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) + // keyterms only supported on scribe_v2, not scribe_v1 + if a.model != "scribe_v1" { + for _, keyword := range a.keywords { + if err := writer.WriteField("keyterms", keyword); err != nil { + return "", fmt.Errorf("write keyterms: %w", err) + } } } diff --git a/internal/transcriber/adapter_elevenlabs_streaming.go b/internal/transcriber/adapter_elevenlabs_streaming.go index 641245b..5e6fa3d 100644 --- a/internal/transcriber/adapter_elevenlabs_streaming.go +++ b/internal/transcriber/adapter_elevenlabs_streaming.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "log" "net/http" @@ -242,6 +243,9 @@ func (a *ElevenLabsStreamingAdapter) readLoop() { _, message, err := conn.ReadMessage() if err != nil { + if a.handleFatalClose(err) { + return + } // check if context was cancelled (normal shutdown) select { case <-a.ctx.Done(): @@ -291,21 +295,92 @@ func (a *ElevenLabsStreamingAdapter) readLoop() { case "error", "auth_error", "quota_exceeded", "rate_limited", "queue_overflow", "resource_exhausted", "session_time_limit_exceeded", "input_error", "chunk_size_exceeded", "insufficient_audio_activity", - "transcriber_error", "commit_throttled", "unaccepted_terms": + "transcriber_error", "commit_throttled", "unaccepted_terms", "invalid_request": // error message errMsg := msg.Error if errMsg == "" { errMsg = msg.MessageType } log.Printf("elevenlabs-streaming: error: %s", errMsg) - a.resultsCh <- TranscriptionResult{Error: fmt.Errorf("elevenlabs: %s", errMsg)} + err := fmt.Errorf("elevenlabs: %s", errMsg) + if isElevenLabsFatalMessageType(msg.MessageType) { + a.handleFatalError(err) + return + } + a.emitResultError(err) default: - log.Printf("elevenlabs-streaming: unknown message type: %s", msg.MessageType) + log.Printf("elevenlabs-streaming: unknown message type: %s payload=%s", msg.MessageType, strings.TrimSpace(string(message))) } } } +func (a *ElevenLabsStreamingAdapter) emitResultError(err error) { + select { + case a.resultsCh <- TranscriptionResult{Error: err}: + default: + } +} + +func (a *ElevenLabsStreamingAdapter) handleFatalError(err error) { + fatalErr := NewFatalTranscriptionError(err) + log.Printf("elevenlabs-streaming: fatal error: %v", err) + a.emitResultError(fatalErr) + a.closeConn() + if a.cancel != nil { + a.cancel() + } +} + +func (a *ElevenLabsStreamingAdapter) handleFatalClose(err error) bool { + var closeErr *websocket.CloseError + if !errors.As(err, &closeErr) { + return false + } + if !isElevenLabsFatalCloseCode(closeErr.Code) { + return false + } + reason := strings.TrimSpace(closeErr.Text) + if reason == "" { + reason = "no reason provided" + } + a.handleFatalError(fmt.Errorf("elevenlabs websocket closed (%d): %s", closeErr.Code, reason)) + return true +} + +func (a *ElevenLabsStreamingAdapter) closeConn() { + a.mu.Lock() + conn := a.conn + a.conn = nil + a.mu.Unlock() + if conn != nil { + _ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + _ = conn.Close() + } +} + +func isElevenLabsFatalCloseCode(code int) bool { + switch code { + case websocket.ClosePolicyViolation, + websocket.CloseUnsupportedData, + websocket.CloseInvalidFramePayloadData, + websocket.CloseMessageTooBig, + websocket.CloseProtocolError: + return true + default: + return false + } +} + +func isElevenLabsFatalMessageType(messageType string) bool { + switch messageType { + case "auth_error", "unaccepted_terms", "invalid_request", "input_error", "chunk_size_exceeded": + return true + default: + return false + } +} + // SendChunk sends audio data to the WebSocket func (a *ElevenLabsStreamingAdapter) SendChunk(audio []byte) error { a.mu.Lock() diff --git a/internal/transcriber/errors.go b/internal/transcriber/errors.go new file mode 100644 index 0000000..74db931 --- /dev/null +++ b/internal/transcriber/errors.go @@ -0,0 +1,34 @@ +package transcriber + +import "errors" + +// FatalTranscriptionError marks an error as non-recoverable for the current session. +type FatalTranscriptionError struct { + Err error +} + +func (e *FatalTranscriptionError) Error() string { + if e == nil || e.Err == nil { + return "fatal transcription error" + } + return e.Err.Error() +} + +func (e *FatalTranscriptionError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +func NewFatalTranscriptionError(err error) error { + if err == nil { + return nil + } + return &FatalTranscriptionError{Err: err} +} + +func IsFatalTranscriptionError(err error) bool { + var fatal *FatalTranscriptionError + return errors.As(err, &fatal) +} diff --git a/internal/transcriber/streaming_transcriber.go b/internal/transcriber/streaming_transcriber.go index 2e69837..7a37bcf 100644 --- a/internal/transcriber/streaming_transcriber.go +++ b/internal/transcriber/streaming_transcriber.go @@ -2,6 +2,7 @@ package transcriber import ( "context" + "errors" "log" "strings" "sync" @@ -19,6 +20,7 @@ type StreamingTranscriber struct { // accumulated final text finalText strings.Builder mu sync.Mutex + fatalErr error // coordination ctx context.Context @@ -66,6 +68,24 @@ func (t *StreamingTranscriber) sendAudio(frameCh <-chan recording.AudioFrame, er return } if err := t.adapter.SendChunk(frame.Data); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + if t.ctx.Err() == nil && t.cancel != nil { + t.cancel() + } + return + } + if IsFatalTranscriptionError(err) { + if t.setFatalErr(err) { + select { + case errCh <- err: + default: + } + } + if t.cancel != nil { + t.cancel() + } + return + } select { case errCh <- err: default: @@ -98,6 +118,19 @@ func (t *StreamingTranscriber) receiveResults(errCh chan<- error) { func (t *StreamingTranscriber) processResult(result TranscriptionResult, errCh chan<- error) { if result.Error != nil { + if IsFatalTranscriptionError(result.Error) { + if t.setFatalErr(result.Error) { + select { + case errCh <- result.Error: + default: + } + } + log.Printf("streaming transcriber: result error: %v", result.Error) + if t.cancel != nil { + t.cancel() + } + return + } select { case errCh <- result.Error: default: @@ -154,11 +187,37 @@ func (t *StreamingTranscriber) Stop(ctx context.Context) error { t.wg.Wait() // close the adapter - return t.adapter.Close() + closeErr := t.adapter.Close() + if fatalErr := t.getFatalErr(); fatalErr != nil { + return fatalErr + } + return closeErr } func (t *StreamingTranscriber) GetFinalTranscription() (string, error) { t.mu.Lock() defer t.mu.Unlock() + if t.fatalErr != nil { + return "", t.fatalErr + } return t.finalText.String(), nil } + +func (t *StreamingTranscriber) setFatalErr(err error) bool { + if err == nil { + return false + } + t.mu.Lock() + defer t.mu.Unlock() + if t.fatalErr != nil { + return false + } + t.fatalErr = err + return true +} + +func (t *StreamingTranscriber) getFatalErr() error { + t.mu.Lock() + defer t.mu.Unlock() + return t.fatalErr +} diff --git a/internal/transcriber/transcriber.go b/internal/transcriber/transcriber.go index cc194af..48ca0a2 100644 --- a/internal/transcriber/transcriber.go +++ b/internal/transcriber/transcriber.go @@ -83,14 +83,16 @@ func NewTranscriber(config Config) (Transcriber, error) { config.Language = "" } - // determine if we should use streaming mode - useStreaming := config.Streaming && model.SupportsStreaming - - // fail if streaming-only model is used without streaming enabled - if !useStreaming && !model.SupportsBatch { + // validate streaming/batch mode compatibility + if config.Streaming && !model.SupportsStreaming { + return nil, fmt.Errorf("model %s does not support streaming mode", model.ID) + } + if !config.Streaming && !model.SupportsBatch { return nil, fmt.Errorf("model %s requires streaming mode (set streaming = true in config)", model.ID) } + useStreaming := config.Streaming + // streaming mode: use StreamingTranscriber if useStreaming { // pick the right adapter type for streaming diff --git a/internal/transcriber/transcriber_test.go b/internal/transcriber/transcriber_test.go index 19f5330..a91bea3 100644 --- a/internal/transcriber/transcriber_test.go +++ b/internal/transcriber/transcriber_test.go @@ -145,6 +145,17 @@ func TestNewTranscriber(t *testing.T) { }, wantErr: false, }, + { + name: "elevenlabs batch model with streaming enabled fails", + config: Config{ + Provider: "elevenlabs", + APIKey: "test-key", + Language: "en", + Model: "scribe_v2", + Streaming: true, + }, + wantErr: true, + }, { name: "deepgram streaming model creates StreamingTranscriber", config: Config{ diff --git a/internal/tui/configure_transcription_test.go b/internal/tui/configure_transcription_test.go index f23e791..3bbb8a1 100644 --- a/internal/tui/configure_transcription_test.go +++ b/internal/tui/configure_transcription_test.go @@ -8,7 +8,7 @@ import ( ) func TestGetTranscriptionModelOptions_ShowsCapabilities(t *testing.T) { - // test elevenlabs - has batch-only and streaming-only models + // test elevenlabs - includes batch+streaming and streaming-only models options := getTranscriptionModelOptions("elevenlabs") // should have 3 models: scribe_v1, scribe_v2, scribe_v2_realtime @@ -23,12 +23,7 @@ func TestGetTranscriptionModelOptions_ShowsCapabilities(t *testing.T) { continue } - if model.SupportsStreaming && !model.SupportsBatch { - // streaming-only should mention streaming - if !strings.Contains(opt.Desc, "streaming") { - t.Errorf("streaming-only model %s should mention streaming in desc: %s", opt.ID, opt.Desc) - } - } else if model.SupportsBothModes() { + if model.SupportsBothModes() { // both modes should mention batch+streaming if !strings.Contains(opt.Desc, "batch+streaming") { t.Errorf("both-modes model %s should mention batch+streaming in desc: %s", opt.ID, opt.Desc) @@ -75,11 +70,12 @@ func TestGetTranscriptionModelOptions_OpenAI_ShowsCapabilities(t *testing.T) { func TestGetTranscriptionModelOptions_Deepgram_ShowsBothModes(t *testing.T) { options := getTranscriptionModelOptions("deepgram") - // Deepgram has 2 models: nova-3, nova-2 - both support batch+streaming + // Deepgram has 2 models: nova-3, nova-2 if len(options) != 2 { t.Errorf("expected 2 options for deepgram, got %d", len(options)) } + // all deepgram models support both modes for _, opt := range options { if !strings.Contains(opt.Desc, "batch+streaming") { t.Errorf("deepgram model %s should mention batch+streaming: %s", opt.ID, opt.Desc) diff --git a/internal/tui/flows.go b/internal/tui/flows.go index 8c6105d..f827f65 100644 --- a/internal/tui/flows.go +++ b/internal/tui/flows.go @@ -66,13 +66,13 @@ func onboardingSummaryScreen(state *wizardState, onBack func() screen) screen { func newMenuScreen(state *wizardState) screen { items := []optionItem{ - {title: formatProvidersLabel(state.cfg), desc: "Manage API keys for cloud providers.", value: menuProviders}, + {title: "Save & Exit", desc: "Write config changes to disk.", value: menuSave}, {title: formatVoiceModelLabel(state.cfg), desc: "Pick the transcription provider, model, and language.", value: menuVoiceModel}, {title: formatLLMLabel(state.cfg), desc: "Configure post-processing and custom prompts.", value: menuLLM}, {title: formatKeywordsLabel(state.cfg), desc: "Words to preserve spelling and phrasing.", value: menuKeywords}, + {title: formatProvidersLabel(state.cfg), desc: "Manage API keys for cloud providers.", value: menuProviders}, {title: formatNotificationsLabel(state.cfg), desc: "Notification type and message text.", value: menuNotifications}, {title: "Advanced Settings", desc: "Recording, injection, and timeout settings.", value: menuAdvanced}, - {title: "Save & Exit", desc: "Write config changes to disk.", value: menuSave}, {title: "Discard & Exit", desc: "Exit without saving changes.", value: menuDiscard}, } @@ -178,6 +178,9 @@ func newAPIKeyInputScreen(state *wizardState, providerName string, onContinue fu } } desc := []string{fmt.Sprintf("Enter your %s API key", displayName)} + if url := getProviderKeyURL(providerName); url != "" { + desc = append(desc, fmt.Sprintf("Get key: %s", url)) + } validate := func(s string) error { if s == "" { return fmt.Errorf("API key is required") @@ -354,8 +357,11 @@ func newLanguageScreen(state *wizardState, model *provider.Model, onBack func() func applyStreamingSelection(state *wizardState, model *provider.Model, onBack func() screen, next func() screen) screen { if model.SupportsBothModes() { - desc := []string{"This model supports both batch and streaming modes."} - return newConfirmScreen(state, "Enable Streaming Mode?", desc, "Yes, streaming", "Lower latency, higher resource use.", "No, batch", "Wait for full transcription.", func() screen { + desc := []string{ + "This model supports both batch and streaming modes.", + "Streaming is quicker but more expensive.", + } + return newConfirmScreen(state, "Enable Streaming Mode?", desc, "Yes, streaming", "Quicker response, higher cost.", "No, batch", "Wait for full transcription (cheaper).", func() screen { state.cfg.Transcription.Streaming = true return next() }, func() screen { diff --git a/internal/tui/helpers.go b/internal/tui/helpers.go index aa28bba..fa54411 100644 --- a/internal/tui/helpers.go +++ b/internal/tui/helpers.go @@ -33,6 +33,14 @@ func getProviderDisplayName(providerName string) string { return providerName } +func getProviderKeyURL(providerName string) string { + p := provider.GetProvider(providerName) + if p == nil { + return "" + } + return p.APIKeyURL() +} + func maskAPIKey(key string) string { if len(key) <= 8 { return "***" diff --git a/internal/tui/wizard_test.go b/internal/tui/wizard_test.go index 92c1b70..7c71c7c 100644 --- a/internal/tui/wizard_test.go +++ b/internal/tui/wizard_test.go @@ -15,6 +15,10 @@ func TestWizardMenuTransitionAppliesSize(t *testing.T) { updated, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) model = updated.(wizardModel) + // move down to "Voice Model" item (index 1) which leads to a listScreen + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown}) + model = updated.(wizardModel) + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(wizardModel)