From a760198a73a31eb33ec49b7a1374943d7dc57992 Mon Sep 17 00:00:00 2001 From: leonardotrapani Date: Sun, 1 Feb 2026 01:16:46 +0100 Subject: [PATCH] config: add threads auto-detection and whisper-cpp validation --- internal/config/config_test.go | 162 +++++++++++++++++++++++++++++++++ internal/config/load.go | 13 +++ internal/config/validate.go | 16 +++- progress.txt | 9 ++ tasks/prd.jsonc | 2 +- 5 files changed, 200 insertions(+), 2 deletions(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 336a849..617aca7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "runtime" "testing" "time" @@ -1770,3 +1771,164 @@ func TestConfig_LLMDefaultsPreserveExplicit(t *testing.T) { t.Error("AddPunctuation should remain false (explicit)") } } + +func TestConfig_Validate_WhisperCpp(t *testing.T) { + baseConfig := func() *Config { + return &Config{ + Recording: RecordingConfig{ + SampleRate: 16000, + Channels: 1, + Format: "s16", + BufferSize: 8192, + ChannelBufferSize: 30, + Timeout: time.Minute, + }, + Injection: InjectionConfig{ + Backends: []string{"clipboard"}, + YdotoolTimeout: 5 * time.Second, + WtypeTimeout: 5 * time.Second, + ClipboardTimeout: 3 * time.Second, + }, + Notifications: NotificationsConfig{Type: "log"}, + } + } + + t.Run("whisper-cpp valid without API key", func(t *testing.T) { + config := baseConfig() + config.Transcription = TranscriptionConfig{ + Provider: "whisper-cpp", + Model: "base.en", + // No API key required + } + + err := config.Validate() + if err != nil { + t.Errorf("Validate() should pass for whisper-cpp without API key: %v", err) + } + }) + + t.Run("whisper-cpp valid multilingual model", func(t *testing.T) { + config := baseConfig() + config.Transcription = TranscriptionConfig{ + Provider: "whisper-cpp", + Model: "large-v3", + } + + err := config.Validate() + if err != nil { + t.Errorf("Validate() should pass for whisper-cpp with large-v3: %v", err) + } + }) + + t.Run("whisper-cpp invalid model", func(t *testing.T) { + config := baseConfig() + config.Transcription = TranscriptionConfig{ + Provider: "whisper-cpp", + Model: "invalid-model", + } + + err := config.Validate() + if err == nil { + t.Error("Validate() should fail for whisper-cpp with invalid model") + } + }) + + t.Run("whisper-cpp invalid language", func(t *testing.T) { + config := baseConfig() + config.Transcription = TranscriptionConfig{ + Provider: "whisper-cpp", + Model: "base.en", + Language: "invalid-lang", + } + + err := config.Validate() + if err == nil { + t.Error("Validate() should fail for whisper-cpp with invalid language") + } + }) + + t.Run("whisper-cpp valid with language", func(t *testing.T) { + config := baseConfig() + config.Transcription = TranscriptionConfig{ + Provider: "whisper-cpp", + Model: "base", + Language: "en", + } + + err := config.Validate() + if err != nil { + t.Errorf("Validate() should pass for whisper-cpp with valid language: %v", err) + } + }) +} + +func TestConfig_ThreadsDefault(t *testing.T) { + t.Run("threads defaults to NumCPU-1", func(t *testing.T) { + config := &Config{ + Transcription: TranscriptionConfig{ + Provider: "whisper-cpp", + Model: "base.en", + Threads: 0, // Not set + }, + } + + config.applyThreadsDefault() + + expectedThreads := runtime.NumCPU() - 1 + if expectedThreads < 1 { + expectedThreads = 1 + } + + if config.Transcription.Threads != expectedThreads { + t.Errorf("Threads = %d, want %d", config.Transcription.Threads, expectedThreads) + } + }) + + t.Run("explicit threads preserved", func(t *testing.T) { + config := &Config{ + Transcription: TranscriptionConfig{ + Provider: "whisper-cpp", + Model: "base.en", + Threads: 2, // Explicitly set + }, + } + + config.applyThreadsDefault() + + if config.Transcription.Threads != 2 { + t.Errorf("Threads = %d, want 2", config.Transcription.Threads) + } + }) + + t.Run("threads minimum is 1", func(t *testing.T) { + config := &Config{ + Transcription: TranscriptionConfig{ + Provider: "whisper-cpp", + Model: "base.en", + Threads: 0, + }, + } + + config.applyThreadsDefault() + + if config.Transcription.Threads < 1 { + t.Errorf("Threads = %d, should be at least 1", config.Transcription.Threads) + } + }) +} + +func TestConfig_ToTranscriberConfig_Threads(t *testing.T) { + config := &Config{ + Transcription: TranscriptionConfig{ + Provider: "whisper-cpp", + Model: "base.en", + Threads: 4, + }, + } + + transcriberConfig := config.ToTranscriberConfig() + + if transcriberConfig.Threads != 4 { + t.Errorf("Threads = %d, want 4", transcriberConfig.Threads) + } +} diff --git a/internal/config/load.go b/internal/config/load.go index 33d7460..0740a46 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -5,6 +5,7 @@ import ( "log" "os" "path/filepath" + "runtime" "time" "github.com/BurntSushi/toml" @@ -76,11 +77,23 @@ func Load() (*Config, error) { } config.applyLLMDefaults() + config.applyThreadsDefault() log.Printf("Config: configuration loaded successfully") return &config, nil } +// applyThreadsDefault sets default threads for local transcription if not explicitly set +func (c *Config) applyThreadsDefault() { + if c.Transcription.Threads == 0 { + threads := runtime.NumCPU() - 1 + if threads < 1 { + threads = 1 + } + c.Transcription.Threads = threads + } +} + // migrateTranscriptionAPIKey migrates old transcription.api_key to providers map func (c *Config) migrateTranscriptionAPIKey(apiKey string) { if c.Providers == nil { diff --git a/internal/config/validate.go b/internal/config/validate.go index da2eaa9..459e28e 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -93,8 +93,22 @@ func (c *Config) Validate() error { return fmt.Errorf("invalid model for elevenlabs: %s (must be scribe_v1 or scribe_v2)", c.Transcription.Model) } + case "whisper-cpp": + // whisper-cpp is local, no API key required + if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) { + return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language) + } + + validWhisperModels := map[string]bool{ + "tiny.en": true, "base.en": true, "small.en": true, "medium.en": true, + "tiny": true, "base": true, "small": true, "medium": true, "large-v3": true, + } + if c.Transcription.Model != "" && !validWhisperModels[c.Transcription.Model] { + return fmt.Errorf("invalid model for whisper-cpp: %s (must be tiny.en, base.en, small.en, medium.en, tiny, base, small, medium, or large-v3)", c.Transcription.Model) + } + default: - return fmt.Errorf("unsupported transcription.provider: %s (must be openai, groq-transcription, groq-translation, mistral-transcription, or elevenlabs)", c.Transcription.Provider) + return fmt.Errorf("unsupported transcription.provider: %s (must be openai, groq-transcription, groq-translation, mistral-transcription, elevenlabs, or whisper-cpp)", c.Transcription.Provider) } if c.Transcription.Model == "" { diff --git a/progress.txt b/progress.txt index fb52672..869e798 100644 --- a/progress.txt +++ b/progress.txt @@ -225,4 +225,13 @@ Started: Sun Feb 1 12:22:47 AM CET 2026 - Creates `NewWhisperCppAdapter(modelPath, config.Language, config.Threads)` - Returns error if whisper model ID is unknown - Added tests for whisper-cpp factory cases: valid config, no API key required, unknown model error +- All tests passing, typecheck passes + +### Task 23: Update config for local transcription +- Added `applyThreadsDefault()` to config.Load() - sets Threads to max(1, NumCPU-1) when 0 +- Added whisper-cpp case to config validation (no API key required) +- Validates whisper model names: tiny.en, base.en, small.en, medium.en, tiny, base, small, medium, large-v3 +- Validates language codes for whisper-cpp same as other providers +- Note: Threads field, ToTranscriberConfig, and template were already done in Task 16 +- Added comprehensive tests for whisper-cpp validation and threads auto-detection - All tests passing, typecheck passes \ No newline at end of file diff --git a/tasks/prd.jsonc b/tasks/prd.jsonc index c1ea80e..e261e12 100644 --- a/tasks/prd.jsonc +++ b/tasks/prd.jsonc @@ -550,7 +550,7 @@ "Config round-trips correctly with threads field", "Typecheck passes" ], - "passes": false + "passes": true }, // ============================================================================ // PHASE 5: MODEL CLI COMMANDS