feat: fanalize straeming adapters

This commit is contained in:
leonardotrapani
2026-02-01 17:53:48 +01:00
parent 8df3021a9d
commit 0025bf97b6
23 changed files with 489 additions and 644 deletions
+20 -201
View File
@@ -1984,16 +1984,13 @@ func TestConfig_ToTranscriberConfig_Threads(t *testing.T) {
}
}
func TestConfig_EffectiveLanguage(t *testing.T) {
t.Run("only general.language set", func(t *testing.T) {
func TestConfig_TranscriptionLanguage(t *testing.T) {
t.Run("language set in transcription", func(t *testing.T) {
config := &Config{
General: GeneralConfig{
Language: "es",
},
Transcription: TranscriptionConfig{
Provider: "openai",
Model: "whisper-1",
Language: "", // not set
Language: "es",
},
}
@@ -2003,29 +2000,8 @@ func TestConfig_EffectiveLanguage(t *testing.T) {
}
})
t.Run("transcription.language overrides general.language", func(t *testing.T) {
t.Run("empty language results in auto-detect", func(t *testing.T) {
config := &Config{
General: GeneralConfig{
Language: "es",
},
Transcription: TranscriptionConfig{
Provider: "openai",
Model: "whisper-1",
Language: "en", // overrides general
},
}
transcriberConfig := config.ToTranscriberConfig()
if transcriberConfig.Language != "en" {
t.Errorf("Language = %q, want %q", transcriberConfig.Language, "en")
}
})
t.Run("neither set results in auto", func(t *testing.T) {
config := &Config{
General: GeneralConfig{
Language: "",
},
Transcription: TranscriptionConfig{
Provider: "openai",
Model: "whisper-1",
@@ -2040,7 +2016,7 @@ func TestConfig_EffectiveLanguage(t *testing.T) {
})
}
func TestConfig_Validate_GeneralLanguage(t *testing.T) {
func TestConfig_Validate_TranscriptionLanguage(t *testing.T) {
baseConfig := func() *Config {
return &Config{
Recording: RecordingConfig{
@@ -2066,63 +2042,46 @@ func TestConfig_Validate_GeneralLanguage(t *testing.T) {
}
}
t.Run("valid general.language passes validation", func(t *testing.T) {
t.Run("valid transcription.language passes validation", func(t *testing.T) {
config := baseConfig()
config.General.Language = "es"
config.Transcription.Language = "es"
err := config.Validate()
if err != nil {
t.Errorf("Validate() should pass with valid general.language: %v", err)
t.Errorf("Validate() should pass with valid transcription.language: %v", err)
}
})
t.Run("general.language validated against model", func(t *testing.T) {
t.Run("transcription.language validated against model", func(t *testing.T) {
config := baseConfig()
config.General.Language = "es"
config.Transcription.Language = "es" // incompatible
config.Transcription.Provider = "whisper-cpp"
config.Transcription.Model = "base.en" // english-only model
err := config.Validate()
if err == nil {
t.Error("Validate() should fail when general.language incompatible with model")
t.Error("Validate() should fail when transcription.language incompatible with model")
}
if err != nil && !strings.Contains(err.Error(), "does not support Spanish") {
t.Errorf("error should mention Spanish, got: %v", err)
}
})
t.Run("transcription.language override validated against model", func(t *testing.T) {
t.Run("compatible language passes", func(t *testing.T) {
config := baseConfig()
config.General.Language = "en" // compatible
config.Transcription.Language = "es" // override with incompatible
config.Transcription.Provider = "whisper-cpp"
config.Transcription.Model = "base.en" // english-only model
err := config.Validate()
if err == nil {
t.Error("Validate() should fail when transcription.language override is incompatible")
}
if err != nil && !strings.Contains(err.Error(), "does not support Spanish") {
t.Errorf("error should mention Spanish, got: %v", err)
}
})
t.Run("valid override with compatible language", func(t *testing.T) {
config := baseConfig()
config.General.Language = "es" // would be incompatible
config.Transcription.Language = "en" // override with compatible
config.Transcription.Language = "en" // compatible
config.Transcription.Provider = "whisper-cpp"
config.Transcription.Model = "base.en" // english-only model
err := config.Validate()
if err != nil {
t.Errorf("Validate() should pass when transcription.language override is compatible: %v", err)
t.Errorf("Validate() should pass when transcription.language is compatible: %v", err)
}
})
t.Run("auto language always passes", func(t *testing.T) {
config := baseConfig()
config.General.Language = "" // auto
config.Transcription.Language = "" // auto
config.Transcription.Provider = "whisper-cpp"
config.Transcription.Model = "base.en" // english-only model
@@ -2133,8 +2092,8 @@ func TestConfig_Validate_GeneralLanguage(t *testing.T) {
})
}
func TestConfig_MigrateLanguageToGeneral(t *testing.T) {
t.Run("old config with transcription.language migrates to general.language", func(t *testing.T) {
func TestConfig_LoadWithTranscriptionLanguage(t *testing.T) {
t.Run("config with transcription.language loads correctly", func(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "hyprvoice", "config.toml")
@@ -2143,77 +2102,7 @@ func TestConfig_MigrateLanguageToGeneral(t *testing.T) {
t.Fatalf("Failed to create config directory: %v", err)
}
// Old config with language in transcription section
oldConfig := `[recording]
sample_rate = 16000
channels = 1
format = "s16"
buffer_size = 8192
channel_buffer_size = 30
timeout = "5m"
[transcription]
provider = "openai"
api_key = "test-key"
model = "whisper-1"
language = "es"
[injection]
backends = ["clipboard"]
ydotool_timeout = "5s"
wtype_timeout = "5s"
clipboard_timeout = "3s"
[notifications]
type = "log"`
err = os.WriteFile(configPath, []byte(oldConfig), 0644)
if err != nil {
t.Fatalf("Failed to create config file: %v", err)
}
originalConfigDir := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", tempDir)
defer func() {
if originalConfigDir == "" {
os.Unsetenv("XDG_CONFIG_HOME")
} else {
os.Setenv("XDG_CONFIG_HOME", originalConfigDir)
}
}()
config, err := Load()
if err != nil {
t.Errorf("Load() error = %v", err)
return
}
// Should have migrated to general.language
if config.General.Language != "es" {
t.Errorf("Expected general.language='es' after migration, got %q", config.General.Language)
}
// Effective language should be 'es'
transcriberConfig := config.ToTranscriberConfig()
if transcriberConfig.Language != "es" {
t.Errorf("Expected effective language 'es', got %q", transcriberConfig.Language)
}
})
t.Run("migration does not run when general.language already set", func(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "hyprvoice", "config.toml")
err := os.MkdirAll(filepath.Dir(configPath), 0755)
if err != nil {
t.Fatalf("Failed to create config directory: %v", err)
}
// Config with both general.language and transcription.language set
configContent := `[general]
language = "fr"
[recording]
configContent := `[recording]
sample_rate = 16000
channels = 1
format = "s16"
@@ -2257,80 +2146,10 @@ type = "log"`
return
}
// general.language should remain 'fr', not overwritten by migration
if config.General.Language != "fr" {
t.Errorf("Expected general.language='fr' (not migrated), got %q", config.General.Language)
}
// transcription.language should still override
// Effective language should be 'es'
transcriberConfig := config.ToTranscriberConfig()
if transcriberConfig.Language != "es" {
t.Errorf("Expected effective language 'es' (transcription override), got %q", transcriberConfig.Language)
}
})
t.Run("original file not modified until explicit save", func(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "hyprvoice", "config.toml")
err := os.MkdirAll(filepath.Dir(configPath), 0755)
if err != nil {
t.Fatalf("Failed to create config directory: %v", err)
}
oldConfig := `[recording]
sample_rate = 16000
channels = 1
format = "s16"
buffer_size = 8192
channel_buffer_size = 30
timeout = "5m"
[transcription]
provider = "openai"
api_key = "test-key"
model = "whisper-1"
language = "de"
[injection]
backends = ["clipboard"]
ydotool_timeout = "5s"
wtype_timeout = "5s"
clipboard_timeout = "3s"
[notifications]
type = "log"`
err = os.WriteFile(configPath, []byte(oldConfig), 0644)
if err != nil {
t.Fatalf("Failed to create config file: %v", err)
}
originalConfigDir := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", tempDir)
defer func() {
if originalConfigDir == "" {
os.Unsetenv("XDG_CONFIG_HOME")
} else {
os.Setenv("XDG_CONFIG_HOME", originalConfigDir)
}
}()
_, err = Load()
if err != nil {
t.Errorf("Load() error = %v", err)
return
}
// Read the file again - should still have old format
content, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("Failed to read config file: %v", err)
}
// File should NOT have [general] section (migration is in-memory only)
if strings.Contains(string(content), "[general]") {
t.Error("Original file should not be modified by migration - [general] section found")
t.Errorf("Expected effective language 'es', got %q", transcriberConfig.Language)
}
})
}
+2 -6
View File
@@ -36,13 +36,9 @@ func (c *Config) ToTranscriberConfig() transcriber.Config {
return config
}
// resolveEffectiveLanguage returns the effective language for transcription.
// transcription.language overrides general.language if set.
// resolveEffectiveLanguage returns the language for transcription
func (c *Config) resolveEffectiveLanguage() string {
if c.Transcription.Language != "" {
return c.Transcription.Language
}
return c.General.Language
return c.Transcription.Language
}
// resolveAPIKeyForProvider returns the API key for a provider from multiple sources
-9
View File
@@ -78,7 +78,6 @@ func Load() (*Config, error) {
config.applyLLMDefaults()
config.applyThreadsDefault()
config.migrateLanguageToGeneral()
log.Printf("Config: configuration loaded successfully")
return &config, nil
@@ -133,14 +132,6 @@ func (c *Config) applyLLMDefaults() {
}
}
// migrateLanguageToGeneral migrates old transcription.language to general.language
func (c *Config) migrateLanguageToGeneral() {
if c.Transcription.Language != "" && c.General.Language == "" {
c.General.Language = c.Transcription.Language
log.Printf("Config: migrated language setting to [general] section")
}
}
// migrateInjectionMode converts old mode field to new backends array
func (c *Config) migrateInjectionMode(mode string) {
switch mode {
+2 -15
View File
@@ -41,13 +41,6 @@ func Save(cfg *Config) error {
sb.WriteString("]\n\n")
}
// General section
sb.WriteString(`# General Settings
[general]
`)
sb.WriteString(fmt.Sprintf(" language = %q\n", cfg.General.Language))
sb.WriteString("\n")
// Providers section
if len(cfg.Providers) > 0 {
sb.WriteString("# API Keys for providers\n")
@@ -210,13 +203,6 @@ func SaveDefaultConfig() error {
# will be automatically migrated to the new [providers.X] format. Run 'hyprvoice configure'
# to update your config file structure.
# ─────────────────────────────────────────────────────────────────────────────
# General Settings
# ─────────────────────────────────────────────────────────────────────────────
[general]
language = "" # Language for transcription (ISO 639-1 code, e.g., en, es, de). Empty for auto-detect.
# Keywords help both transcription and LLM understand domain-specific terms
# Add names, technical terms, or brand names that might be misheard
keywords = []
@@ -262,8 +248,8 @@ keywords = []
[transcription]
provider = "openai" # "openai", "groq-transcription", "groq-translation", "mistral-transcription", "elevenlabs", "whisper-cpp"
model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1"
language = "" # ISO 639-1 code (e.g., en, es, de). Empty for auto-detect.
threads = 0 # CPU threads for local transcription (0 = auto: uses NumCPU-1)
# language = "" # Override general.language for this provider only
# ─────────────────────────────────────────────────────────────────────────────
# LLM Post-Processing (Recommended)
@@ -353,6 +339,7 @@ keywords = []
# - "clipboard": Copies to clipboard only (most reliable, requires manual paste).
#
# Language codes: "" (auto-detect), "en", "it", "es", "fr", "de", "pt", etc.
# Language is configured per transcription model - only supported languages are shown during setup.
`
if _, err := file.WriteString(configContent); err != nil {
+1 -1
View File
@@ -9,7 +9,7 @@ import (
// GeneralConfig holds global settings that apply across the application
type GeneralConfig struct {
Language string `toml:"language"` // ISO 639-1 code (e.g., en, es, de). Empty for auto-detect.
// reserved for future use
}
type Config struct {
-3
View File
@@ -86,9 +86,6 @@ func (c *Config) Validate() error {
}
// validate language codes - warn if not recognized but don't error
if c.General.Language != "" && !language.IsValidCode(c.General.Language) {
log.Printf("warning: unrecognized language code '%s' in general.language, will be passed as-is to provider", c.General.Language)
}
if c.Transcription.Language != "" && !language.IsValidCode(c.Transcription.Language) {
log.Printf("warning: unrecognized language code '%s' in transcription.language, will be passed as-is to provider", c.Transcription.Language)
}
+68 -7
View File
@@ -32,6 +32,14 @@ type DeepgramAdapter struct {
// reconnection config
maxRetries int
retryDelays []time.Duration
// finalization signaling
finalizeDone chan struct{}
}
// deepgramCloseStream message to signal end of audio
type deepgramCloseStream struct {
Type string `json:"type"`
}
// Deepgram WebSocket response types (incoming)
@@ -77,13 +85,14 @@ type deepgramError struct {
// lang: canonical language code (will be converted to provider format)
func NewDeepgramAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *DeepgramAdapter {
return &DeepgramAdapter{
endpoint: endpoint,
apiKey: apiKey,
model: model,
language: lang,
resultsCh: make(chan TranscriptionResult, 100),
maxRetries: 3,
retryDelays: defaultRetryDelays,
endpoint: endpoint,
apiKey: apiKey,
model: model,
language: lang,
resultsCh: make(chan TranscriptionResult, 100),
maxRetries: 3,
retryDelays: defaultRetryDelays,
finalizeDone: make(chan struct{}, 1),
}
}
@@ -294,6 +303,11 @@ func (a *DeepgramAdapter) readLoop() {
isFinal := resp.IsFinal || resp.SpeechFinal
if isFinal {
log.Printf("deepgram: final: %q", transcript)
// signal finalization (non-blocking)
select {
case a.finalizeDone <- struct{}{}:
default:
}
}
a.resultsCh <- TranscriptionResult{Text: transcript, IsFinal: isFinal}
}
@@ -371,6 +385,53 @@ func (a *DeepgramAdapter) Results() <-chan TranscriptionResult {
return a.resultsCh
}
// Finalize sends a CloseStream message to signal end of audio and waits for final results
func (a *DeepgramAdapter) Finalize(ctx context.Context) error {
a.mu.Lock()
if !a.started {
a.mu.Unlock()
return nil
}
conn := a.conn
a.mu.Unlock()
if conn == nil {
return nil
}
// drain any previous finalize signals
select {
case <-a.finalizeDone:
default:
}
// send CloseStream message
msg := deepgramCloseStream{Type: "CloseStream"}
a.mu.Lock()
err := a.conn.WriteJSON(msg)
a.mu.Unlock()
if err != nil {
log.Printf("deepgram: finalize write error: %v", err)
return fmt.Errorf("finalize write: %w", err)
}
log.Printf("deepgram: sent CloseStream, waiting for final transcript")
// wait for final result or timeout
select {
case <-a.finalizeDone:
log.Printf("deepgram: finalize complete")
return nil
case <-ctx.Done():
log.Printf("deepgram: finalize timeout")
return ctx.Err()
case <-a.ctx.Done():
return a.ctx.Err()
}
}
// Close gracefully closes the WebSocket connection
func (a *DeepgramAdapter) Close() error {
a.mu.Lock()
@@ -36,6 +36,9 @@ type ElevenLabsStreamingAdapter struct {
// reconnection config
maxRetries int
retryDelays []time.Duration
// finalization signaling
commitDone chan struct{}
}
// ElevenLabs WebSocket message types (outgoing)
@@ -69,6 +72,7 @@ func NewElevenLabsStreamingAdapter(endpoint *provider.EndpointConfig, apiKey, mo
resultsCh: make(chan TranscriptionResult, 100),
maxRetries: 3,
retryDelays: defaultRetryDelays,
commitDone: make(chan struct{}, 1),
}
}
@@ -270,10 +274,15 @@ func (a *ElevenLabsStreamingAdapter) readLoop() {
case "committed_transcript", "committed_transcript_with_timestamps":
// final result
log.Printf("elevenlabs-streaming: committed: %q", msg.Text)
if msg.Text != "" {
log.Printf("elevenlabs-streaming: committed: %q", msg.Text)
a.resultsCh <- TranscriptionResult{Text: msg.Text, IsFinal: true}
}
// signal finalization is done (non-blocking)
select {
case a.commitDone <- struct{}{}:
default:
}
case "error", "auth_error", "quota_exceeded", "rate_limited",
"queue_overflow", "resource_exhausted", "session_time_limit_exceeded",
@@ -353,6 +362,59 @@ func (a *ElevenLabsStreamingAdapter) Results() <-chan TranscriptionResult {
return a.resultsCh
}
// Finalize sends a commit message to force ElevenLabs to commit any pending audio
// and waits for the committed_transcript response
func (a *ElevenLabsStreamingAdapter) Finalize(ctx context.Context) error {
a.mu.Lock()
if !a.started {
a.mu.Unlock()
return nil
}
conn := a.conn
a.mu.Unlock()
if conn == nil {
return nil
}
// drain any previous commit signals
select {
case <-a.commitDone:
default:
}
// send empty audio chunk with commit=true to force finalization
msg := elevenLabsInputAudioChunk{
MessageType: "input_audio_chunk",
AudioBase64: "",
Commit: true,
SampleRate: 16000,
}
a.mu.Lock()
err := a.conn.WriteJSON(msg)
a.mu.Unlock()
if err != nil {
log.Printf("elevenlabs-streaming: finalize write error: %v", err)
return fmt.Errorf("finalize write: %w", err)
}
log.Printf("elevenlabs-streaming: sent commit, waiting for final transcript")
// wait for committed_transcript or timeout
select {
case <-a.commitDone:
log.Printf("elevenlabs-streaming: finalize complete")
return nil
case <-ctx.Done():
log.Printf("elevenlabs-streaming: finalize timeout")
return ctx.Err()
case <-a.ctx.Done():
return a.ctx.Err()
}
}
// Close gracefully closes the WebSocket connection
func (a *ElevenLabsStreamingAdapter) Close() error {
a.mu.Lock()
@@ -35,6 +35,9 @@ type OpenAIRealtimeAdapter struct {
// track current item for transcription
currentItemID string
// finalization signaling
transcriptionDone chan struct{}
}
// OpenAI Realtime WebSocket message types (outgoing)
@@ -103,13 +106,14 @@ type openaiRealtimeError struct {
// lang: canonical language code (will be used for transcription config)
func NewOpenAIRealtimeAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string) *OpenAIRealtimeAdapter {
return &OpenAIRealtimeAdapter{
endpoint: endpoint,
apiKey: apiKey,
model: model,
language: lang,
resultsCh: make(chan TranscriptionResult, 100),
maxRetries: 3,
retryDelays: defaultRetryDelays,
endpoint: endpoint,
apiKey: apiKey,
model: model,
language: lang,
resultsCh: make(chan TranscriptionResult, 100),
maxRetries: 3,
retryDelays: defaultRetryDelays,
transcriptionDone: make(chan struct{}, 1),
}
}
@@ -372,10 +376,15 @@ func (a *OpenAIRealtimeAdapter) handleEvent(event openaiRealtimeServerEvent) {
case "conversation.item.input_audio_transcription.completed":
// final transcription result
log.Printf("openai-realtime: transcription completed: %q", event.Transcript)
if event.Transcript != "" {
log.Printf("openai-realtime: transcription completed: %q", event.Transcript)
a.resultsCh <- TranscriptionResult{Text: event.Transcript, IsFinal: true}
}
// signal finalization (non-blocking)
select {
case a.transcriptionDone <- struct{}{}:
default:
}
case "conversation.item.input_audio_transcription.failed":
log.Printf("openai-realtime: transcription failed for item %s", event.ItemID)
@@ -500,6 +509,56 @@ func (a *OpenAIRealtimeAdapter) Results() <-chan TranscriptionResult {
return a.resultsCh
}
// Finalize sends a commit message to force OpenAI to process any pending audio
// and waits for the transcription.completed response
func (a *OpenAIRealtimeAdapter) Finalize(ctx context.Context) error {
a.mu.Lock()
if !a.started {
a.mu.Unlock()
return nil
}
conn := a.conn
a.mu.Unlock()
if conn == nil {
return nil
}
// drain any previous transcription signals
select {
case <-a.transcriptionDone:
default:
}
// send input_audio_buffer.commit to force processing of pending audio
msg := openaiRealtimeInputAudioCommit{
Type: "input_audio_buffer.commit",
}
a.mu.Lock()
err := a.conn.WriteJSON(msg)
a.mu.Unlock()
if err != nil {
log.Printf("openai-realtime: finalize write error: %v", err)
return fmt.Errorf("finalize write: %w", err)
}
log.Printf("openai-realtime: sent commit, waiting for final transcription")
// wait for transcription.completed or timeout
select {
case <-a.transcriptionDone:
log.Printf("openai-realtime: finalize complete")
return nil
case <-ctx.Done():
log.Printf("openai-realtime: finalize timeout")
return ctx.Err()
case <-a.ctx.Done():
return a.ctx.Err()
}
}
// Close gracefully closes the WebSocket connection
func (a *OpenAIRealtimeAdapter) Close() error {
a.mu.Lock()
+5
View File
@@ -20,6 +20,11 @@ type StreamingAdapter interface {
// Results returns a channel that receives transcription results (partial and final)
Results() <-chan TranscriptionResult
// Finalize signals end of audio input and waits for final transcription results.
// This should be called before Close to ensure all pending audio is committed.
// The ctx controls the timeout for waiting on final results.
Finalize(ctx context.Context) error
// Close gracefully closes the streaming connection
Close() error
}
+44 -7
View File
@@ -5,6 +5,7 @@ import (
"log"
"strings"
"sync"
"time"
"github.com/leonardotrapani/hyprvoice/internal/recording"
)
@@ -83,18 +84,45 @@ func (t *StreamingTranscriber) receiveResults(errCh chan<- error) {
for {
select {
case <-t.ctx.Done():
// context cancelled, drain any remaining results before exiting
t.drainRemainingResults(resultsCh)
return
case result, ok := <-resultsCh:
if !ok {
return
}
if result.Error != nil {
select {
case errCh <- result.Error:
default:
}
log.Printf("streaming transcriber: result error: %v", result.Error)
continue
t.processResult(result, errCh)
}
}
}
func (t *StreamingTranscriber) processResult(result TranscriptionResult, errCh chan<- error) {
if result.Error != nil {
select {
case errCh <- result.Error:
default:
}
log.Printf("streaming transcriber: result error: %v", result.Error)
return
}
if result.IsFinal && result.Text != "" {
t.mu.Lock()
if t.finalText.Len() > 0 {
t.finalText.WriteString(" ")
}
t.finalText.WriteString(result.Text)
t.mu.Unlock()
}
}
func (t *StreamingTranscriber) drainRemainingResults(resultsCh <-chan TranscriptionResult) {
// give a short window to collect any final results already in the channel
timeout := time.After(100 * time.Millisecond)
for {
select {
case result, ok := <-resultsCh:
if !ok {
return
}
if result.IsFinal && result.Text != "" {
t.mu.Lock()
@@ -104,11 +132,20 @@ func (t *StreamingTranscriber) receiveResults(errCh chan<- error) {
t.finalText.WriteString(result.Text)
t.mu.Unlock()
}
case <-timeout:
return
}
}
}
func (t *StreamingTranscriber) Stop(ctx context.Context) error {
// finalize adapter first to commit pending audio and wait for final results
// this must happen before canceling context so receiveResults can collect them
if err := t.adapter.Finalize(ctx); err != nil {
log.Printf("streaming transcriber: finalize error (continuing): %v", err)
}
// now cancel context to stop goroutines
if t.cancel != nil {
t.cancel()
}
+8
View File
@@ -688,6 +688,7 @@ type MockStreamingAdapter struct {
StartFunc func(ctx context.Context, language string) error
SendChunkFunc func(audio []byte) error
ResultsFunc func() <-chan TranscriptionResult
FinalizeFunc func(ctx context.Context) error
CloseFunc func() error
resultsCh chan TranscriptionResult
@@ -720,6 +721,13 @@ func (m *MockStreamingAdapter) Results() <-chan TranscriptionResult {
return m.resultsCh
}
func (m *MockStreamingAdapter) Finalize(ctx context.Context) error {
if m.FinalizeFunc != nil {
return m.FinalizeFunc(ctx)
}
return nil
}
func (m *MockStreamingAdapter) Close() error {
if m.CloseFunc != nil {
return m.CloseFunc()
-7
View File
@@ -37,7 +37,6 @@ type ConfigSection string
const (
SectionProviders ConfigSection = "providers"
SectionLanguage ConfigSection = "language"
SectionTranscription ConfigSection = "transcription"
SectionLLM ConfigSection = "llm"
SectionKeywords ConfigSection = "keywords"
@@ -111,11 +110,6 @@ func runEditExisting(cfg *config.Config) (*ConfigureResult, error) {
}
configuredProviders = getConfiguredProviders(cfg)
case SectionLanguage:
if err := editLanguage(cfg); err != nil {
continue
}
case SectionTranscription:
var err error
configuredProviders, err = editTranscription(cfg, configuredProviders)
@@ -160,7 +154,6 @@ func runEditExisting(cfg *config.Config) (*ConfigureResult, error) {
func selectSection(cfg *config.Config) (ConfigSection, error) {
options := []huh.Option[ConfigSection]{
huh.NewOption(formatProvidersLabel(cfg), SectionProviders),
huh.NewOption(formatLanguageMenuLabel(cfg), SectionLanguage),
huh.NewOption(formatTranscriptionLabel(cfg), SectionTranscription),
huh.NewOption(formatLLMLabel(cfg), SectionLLM),
huh.NewOption(formatKeywordsLabel(cfg), SectionKeywords),
+4 -8
View File
@@ -13,11 +13,6 @@ func formatProvidersLabel(cfg *config.Config) string {
return "Providers"
}
// formatLanguageMenuLabel formats the language menu option
func formatLanguageMenuLabel(cfg *config.Config) string {
return "Language"
}
// formatTranscriptionLabel formats the transcription menu option
func formatTranscriptionLabel(cfg *config.Config) string {
return "Transcription"
@@ -54,10 +49,11 @@ func showSummary(cfg *config.Config) (bool, error) {
}
fmt.Printf(" %s %s\n", StyleLabel.Render("Providers:"), strings.Join(providers, ", "))
fmt.Printf(" %s %s (%s)\n", StyleLabel.Render("Transcription:"), cfg.Transcription.Provider, cfg.Transcription.Model)
if cfg.Transcription.Language != "" {
fmt.Printf(" %s %s\n", StyleLabel.Render("Language:"), cfg.Transcription.Language)
lang := cfg.Transcription.Language
if lang == "" {
lang = "auto-detect"
}
fmt.Printf(" %s %s/%s (%s)\n", StyleLabel.Render("Transcription:"), cfg.Transcription.Provider, cfg.Transcription.Model, lang)
if cfg.LLM.Enabled {
fmt.Printf(" %s %s (%s)\n", StyleLabel.Render("LLM:"), cfg.LLM.Provider, cfg.LLM.Model)
-85
View File
@@ -1,85 +0,0 @@
package tui
import (
"fmt"
"github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
// editLanguage allows the user to select the global transcription language
func editLanguage(cfg *config.Config) error {
// no model-specific warnings for global language selection
languageOptions := getLanguageOptions(nil, cfg.General.Language)
selectedLanguage := cfg.General.Language
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Language").
Description("Select language for transcription (applies globally)").
Options(languageOptions...).
Filtering(true).
Value(&selectedLanguage),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return err
}
// check if current transcription model supports the selected language
if selectedLanguage != "" && cfg.Transcription.Provider != "" && cfg.Transcription.Model != "" {
registryName := mapConfigProviderToRegistry(cfg.Transcription.Provider)
model, err := provider.GetModel(registryName, cfg.Transcription.Model)
if err == nil && !model.SupportsLanguage(selectedLanguage) {
langName := language.FromCode(selectedLanguage).Name
if langName == "" {
langName = selectedLanguage
}
fmt.Println()
fmt.Println(StyleWarning.Render("Language-Model Compatibility Warning"))
fmt.Printf("Your current model '%s' does not support %s.\n", model.Name, langName)
fmt.Println()
fmt.Println(StyleMuted.Render("You can:"))
fmt.Println(StyleMuted.Render(" - Keep this language and change the model later"))
fmt.Println(StyleMuted.Render(" - Use 'Auto-detect' for language"))
fmt.Println(StyleMuted.Render(" - Choose a different language"))
fmt.Println()
var action string
actionForm := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("What would you like to do?").
Options(
huh.NewOption("Keep this language (change model later)", "keep"),
huh.NewOption("Use Auto-detect instead", "auto"),
huh.NewOption("Choose a different language", "retry"),
).
Value(&action),
),
).WithTheme(getTheme())
if err := actionForm.Run(); err != nil {
return err
}
switch action {
case "auto":
selectedLanguage = ""
case "retry":
return editLanguage(cfg)
case "keep":
// proceed with incompatible language
}
}
}
cfg.General.Language = selectedLanguage
return nil
}
+50 -87
View File
@@ -7,7 +7,6 @@ import (
"github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/deps"
"github.com/leonardotrapani/hyprvoice/internal/language"
"github.com/leonardotrapani/hyprvoice/internal/models/whisper"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
@@ -114,13 +113,7 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri
}
cfg.Transcription.Provider = selectedProvider
// use effective language for model compatibility display
effectiveLanguage := cfg.General.Language
if cfg.Transcription.Language != "" {
effectiveLanguage = cfg.Transcription.Language
}
modelOptions := getTranscriptionModelOptions(selectedProvider, effectiveLanguage)
modelOptions := getTranscriptionModelOptions(selectedProvider)
selectedModel := cfg.Transcription.Model
if selectedModel == "" && len(modelOptions) > 0 {
// skip header options (empty value) to find first real model
@@ -156,41 +149,7 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri
return editTranscription(cfg, configuredProviders)
}
// validate language-model compatibility before saving
registryName := mapConfigProviderToRegistry(selectedProvider)
if err := provider.ValidateModelLanguage(registryName, selectedModel, effectiveLanguage); err != nil {
// show error dialog - user needs to change language in Language menu
fmt.Println()
fmt.Println(StyleError.Render("Language-Model Incompatibility"))
fmt.Println(StyleMuted.Render(err.Error()))
fmt.Println()
fmt.Println(StyleMuted.Render("You can:"))
fmt.Println(StyleMuted.Render(" - Choose a different model that supports your language"))
fmt.Println(StyleMuted.Render(" - Change language to 'Auto-detect' in the Language menu"))
fmt.Println()
var retry bool
retryForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Try again?").
Description("Choose a different model").
Affirmative("Yes, let me pick another model").
Negative("Cancel").
Value(&retry),
),
).WithTheme(getTheme())
if err := retryForm.Run(); err != nil {
return configuredProviders, err
}
if retry {
// recurse to let user pick another model
return editTranscription(cfg, configuredProviders)
}
return configuredProviders, nil
}
// for whisper-cpp, check if model needs download
if selectedProvider == "whisper-cpp" && !whisper.IsInstalled(selectedModel) {
@@ -245,35 +204,55 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri
cfg.Transcription.Model = selectedModel
// set streaming mode based on model capabilities
// select language for this model
model, err := provider.GetModel(registryName, selectedModel)
if err == nil {
if model.SupportsBothModes() {
// model supports both: ask user
useStreaming := cfg.Transcription.Streaming
streamingForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Enable streaming mode?").
Description("This model supports both batch and streaming modes").
Affirmative("Yes, use streaming (real-time)").
Negative("No, use batch (after recording)").
Value(&useStreaming),
),
).WithTheme(getTheme())
if err != nil {
return configuredProviders, err
}
if err := streamingForm.Run(); err != nil {
return configuredProviders, err
}
cfg.Transcription.Streaming = useStreaming
} else if model.SupportsStreaming {
// streaming-only model
cfg.Transcription.Streaming = true
fmt.Println(StyleSuccess.Render("Streaming mode enabled (this model only supports streaming)"))
} else {
// batch-only model
cfg.Transcription.Streaming = false
languageOptions := getModelLanguageOptions(model, cfg.Transcription.Language)
selectedLanguage := cfg.Transcription.Language
languageForm := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Language").
Description("Select language for transcription").
Options(languageOptions...).
Filtering(true).
Value(&selectedLanguage),
),
).WithTheme(getTheme())
if err := languageForm.Run(); err != nil {
return configuredProviders, err
}
cfg.Transcription.Language = selectedLanguage
// set streaming mode based on model capabilities
if model.SupportsBothModes() {
useStreaming := cfg.Transcription.Streaming
streamingForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Enable streaming mode?").
Description("This model supports both batch and streaming modes").
Affirmative("Yes, use streaming (real-time)").
Negative("No, use batch (after recording)").
Value(&useStreaming),
),
).WithTheme(getTheme())
if err := streamingForm.Run(); err != nil {
return configuredProviders, err
}
cfg.Transcription.Streaming = useStreaming
} else if model.SupportsStreaming {
cfg.Transcription.Streaming = true
fmt.Println(StyleSuccess.Render("Streaming mode enabled (this model only supports streaming)"))
} else {
cfg.Transcription.Streaming = false
}
return configuredProviders, nil
@@ -304,7 +283,7 @@ func getUnconfiguredTranscriptionOptions(configuredProviders []string) []huh.Opt
return options
}
func getTranscriptionModelOptions(configProvider string, currentLang string) []huh.Option[string] {
func getTranscriptionModelOptions(configProvider string) []huh.Option[string] {
// special case: groq-translation only supports whisper-large-v3
if configProvider == "groq-translation" {
return []huh.Option[string]{
@@ -323,7 +302,7 @@ func getTranscriptionModelOptions(configProvider string, currentLang string) []h
var options []huh.Option[string]
for _, m := range models {
label := buildModelLabel(m, currentLang)
label := buildModelLabel(m)
if m.Local && registryName == "whisper-cpp" {
if whisper.IsInstalled(m.ID) {
label = "[x] " + label
@@ -350,7 +329,7 @@ func mapConfigProviderToRegistry(configProvider string) string {
}
// buildModelLabel creates the display label for a model option
func buildModelLabel(m provider.Model, currentLang string) string {
func buildModelLabel(m provider.Model) string {
label := fmt.Sprintf("%s (%s)", m.Name, m.Description)
// append size for local models
@@ -364,22 +343,6 @@ func buildModelLabel(m provider.Model, currentLang string) string {
} else if m.SupportsStreaming {
label += " [streaming]"
}
// batch-only models don't need a tag (it's the default)
// append language warning if model doesn't support current language
if currentLang != "" && !m.SupportsLanguage(currentLang) {
langName := getLangName(currentLang)
label += fmt.Sprintf(" (does not support %s)", langName)
}
return label
}
// getLangName returns a human-readable language name for a code
func getLangName(code string) string {
lang := language.FromCode(code)
if lang.Code == "" {
return code // unknown code, return as-is
}
return lang.Name
}
+5 -5
View File
@@ -9,7 +9,7 @@ import (
func TestGetTranscriptionModelOptions_ShowsCapabilities(t *testing.T) {
// test elevenlabs - has batch-only and streaming-only models
options := getTranscriptionModelOptions("elevenlabs", "")
options := getTranscriptionModelOptions("elevenlabs")
// should have 3 models: scribe_v1, scribe_v2, scribe_v2_realtime
if len(options) != 3 {
@@ -40,7 +40,7 @@ func TestGetTranscriptionModelOptions_ShowsCapabilities(t *testing.T) {
func TestGetTranscriptionModelOptions_NoHeadersAnymore(t *testing.T) {
// we removed batch/streaming section headers
options := getTranscriptionModelOptions("elevenlabs", "")
options := getTranscriptionModelOptions("elevenlabs")
for _, opt := range options {
if opt.Value == "" {
@@ -50,7 +50,7 @@ func TestGetTranscriptionModelOptions_NoHeadersAnymore(t *testing.T) {
}
func TestGetTranscriptionModelOptions_OpenAI_ShowsCapabilities(t *testing.T) {
options := getTranscriptionModelOptions("openai", "")
options := getTranscriptionModelOptions("openai")
// OpenAI has 3 transcription models: whisper-1, gpt-4o-transcribe, gpt-4o-mini-transcribe
if len(options) != 3 {
@@ -68,7 +68,7 @@ func TestGetTranscriptionModelOptions_OpenAI_ShowsCapabilities(t *testing.T) {
}
func TestGetTranscriptionModelOptions_Deepgram_ShowsBothModes(t *testing.T) {
options := getTranscriptionModelOptions("deepgram", "")
options := getTranscriptionModelOptions("deepgram")
// Deepgram has 2 models: nova-3, nova-2 - both support batch+streaming
if len(options) != 2 {
@@ -84,7 +84,7 @@ func TestGetTranscriptionModelOptions_Deepgram_ShowsBothModes(t *testing.T) {
func TestGetTranscriptionModelOptions_Groq_BatchOnly(t *testing.T) {
// test groq - batch only (no streaming models)
options := getTranscriptionModelOptions("groq-transcription", "")
options := getTranscriptionModelOptions("groq-transcription")
// should have 2 models: whisper-large-v3, whisper-large-v3-turbo
if len(options) != 2 {
+1 -6
View File
@@ -39,12 +39,7 @@ func runFreshInstall(cfg *config.Config) (*ConfigureResult, error) {
return &ConfigureResult{Cancelled: true}, nil
}
// 4. Language selection
if err := editLanguage(cfg); err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
// 5. Keywords
// 4. Keywords
keywords, err := inputKeywords(cfg.Keywords)
if err != nil {
return &ConfigureResult{Cancelled: true}, nil
+8 -13
View File
@@ -8,10 +8,8 @@ import (
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
// getLanguageOptions returns language options for the dropdown
// if currentModel is provided, languages unsupported by that model will be marked
// currentLang is the currently selected language code (empty string for auto-detect)
func getLanguageOptions(currentModel *provider.Model, currentLang string) []huh.Option[string] {
// getModelLanguageOptions returns language options supported by the given model
func getModelLanguageOptions(model *provider.Model, currentLang string) []huh.Option[string] {
var options []huh.Option[string]
// auto-detect is always first
@@ -21,18 +19,15 @@ func getLanguageOptions(currentModel *provider.Model, currentLang string) []huh.
}
options = append(options, huh.NewOption(autoLabel, ""))
// add all languages
// only show languages supported by the model
for _, lang := range language.List() {
label := formatLanguageLabel(lang)
// mark current selection
if lang.Code == currentLang {
label += " (current)"
if model != nil && !model.SupportsLanguage(lang.Code) {
continue
}
// add warning if model doesn't support this language
if currentModel != nil && !currentModel.SupportsLanguage(lang.Code) {
label += " (not supported by current model)"
label := formatLanguageLabel(lang)
if lang.Code == currentLang {
label += " (current)"
}
options = append(options, huh.NewOption(label, lang.Code))