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
+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)