feat: models test and fixes

This commit is contained in:
leonardotrapani
2026-02-02 01:13:33 +01:00
parent 2729882b9d
commit 195f9f5115
34 changed files with 507 additions and 154 deletions
+2 -1
View File
@@ -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:
+61
View File
@@ -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
+10 -7
View File
@@ -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
+12 -2
View File
@@ -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
+21 -15
View File
@@ -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]
+15 -17
View File
@@ -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:
+3 -1
View File
@@ -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"
+1 -1
View File
@@ -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)
+3
View File
@@ -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
+5 -5
View File
@@ -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 {
+6 -3
View File
@@ -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,
+1 -4
View File
@@ -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)
}
+15 -5
View File
@@ -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,
},
}
+8 -15
View File
@@ -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,
+2
View File
@@ -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",
+6 -17
View File
@@ -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,
+3 -3
View File
@@ -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 {
+10 -6
View File
@@ -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,
+1
View File
@@ -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
+13 -5
View File
@@ -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
+36 -6
View File
@@ -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 {
+11 -8
View File
@@ -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 {
+25 -1
View File
@@ -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()
+15 -4
View File
@@ -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, ","))
}
+6 -7
View File
@@ -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)
}
}
}
@@ -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()
+34
View File
@@ -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)
}
+60 -1
View File
@@ -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
}
+7 -5
View File
@@ -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
+11
View File
@@ -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{
+4 -8
View File
@@ -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)
+10 -4
View File
@@ -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 {
+8
View File
@@ -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 "***"
+4
View File
@@ -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)