diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 4f65f4a..0ee80fb 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -28,6 +28,7 @@ func init() { Register(&GroqProvider{}) Register(&MistralProvider{}) Register(&ElevenLabsProvider{}) + Register(&WhisperCppProvider{}) } // Register adds a provider to the registry diff --git a/internal/provider/whisper_cpp.go b/internal/provider/whisper_cpp.go new file mode 100644 index 0000000..566419e --- /dev/null +++ b/internal/provider/whisper_cpp.go @@ -0,0 +1,76 @@ +package provider + +import ( + "github.com/leonardotrapani/hyprvoice/internal/language" + "github.com/leonardotrapani/hyprvoice/internal/models/whisper" +) + +// WhisperCppProvider implements Provider for local whisper.cpp transcription +type WhisperCppProvider struct{} + +func (p *WhisperCppProvider) Name() string { + return "whisper-cpp" +} + +func (p *WhisperCppProvider) RequiresAPIKey() bool { + return false +} + +func (p *WhisperCppProvider) ValidateAPIKey(key string) bool { + return true // no API key needed +} + +func (p *WhisperCppProvider) IsLocal() bool { + return true +} + +func (p *WhisperCppProvider) Models() []Model { + allLangs := language.AllLanguageCodes() + englishOnly := []string{"en"} + + whisperModels := whisper.ListModels() + result := make([]Model, 0, len(whisperModels)) + + for _, wm := range whisperModels { + var langs []string + if wm.Multilingual { + langs = allLangs + } else { + langs = englishOnly + } + + result = append(result, Model{ + ID: wm.ID, + Name: wm.Name, + Description: modelDescription(wm), + Type: Transcription, + Streaming: false, + Local: true, + AdapterType: "whisper-cpp", + SupportedLanguages: langs, + Endpoint: nil, // local CLI, no HTTP endpoint + LocalInfo: &LocalModelInfo{ + Filename: wm.Filename, + Size: wm.Size, + DownloadURL: whisper.GetDownloadURL(wm.ID), + }, + }) + } + + return result +} + +func modelDescription(m whisper.ModelInfo) string { + if m.Multilingual { + return "Multilingual local transcription" + } + return "English-only local transcription (faster)" +} + +func (p *WhisperCppProvider) DefaultModel(t ModelType) string { + switch t { + case Transcription: + return "base.en" + } + return "" +} diff --git a/internal/provider/whisper_cpp_test.go b/internal/provider/whisper_cpp_test.go new file mode 100644 index 0000000..189a96d --- /dev/null +++ b/internal/provider/whisper_cpp_test.go @@ -0,0 +1,151 @@ +package provider + +import ( + "testing" + + "github.com/leonardotrapani/hyprvoice/internal/language" +) + +func TestWhisperCppProvider_GetProvider(t *testing.T) { + p := GetProvider("whisper-cpp") + if p == nil { + t.Fatal("GetProvider('whisper-cpp') returned nil") + } + if p.Name() != "whisper-cpp" { + t.Errorf("expected name 'whisper-cpp', got '%s'", p.Name()) + } +} + +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 all models have required fields + for _, m := range models { + if !m.Local { + t.Errorf("model %s: expected Local=true", m.ID) + } + if m.LocalInfo == nil { + t.Errorf("model %s: expected LocalInfo to be set", m.ID) + } + if m.AdapterType != "whisper-cpp" { + t.Errorf("model %s: expected AdapterType='whisper-cpp', got '%s'", m.ID, m.AdapterType) + } + if m.Type != Transcription { + t.Errorf("model %s: expected Type=Transcription", m.ID) + } + if m.Endpoint != nil { + t.Errorf("model %s: expected Endpoint=nil for local model", m.ID) + } + } +} + +func TestWhisperCppProvider_EnglishOnlyModels(t *testing.T) { + p := &WhisperCppProvider{} + models := p.Models() + + englishOnlyIDs := map[string]bool{ + "tiny.en": true, + "base.en": true, + "small.en": true, + "medium.en": true, + } + + for _, m := range models { + isEnglishOnly := englishOnlyIDs[m.ID] + if isEnglishOnly { + // english-only models should only support 'en' + if len(m.SupportedLanguages) != 1 || m.SupportedLanguages[0] != "en" { + t.Errorf("model %s: expected SupportedLanguages=['en'], got %v", m.ID, m.SupportedLanguages) + } + if m.SupportsLanguage("es") { + t.Errorf("model %s: SupportsLanguage('es') should be false", m.ID) + } + if !m.SupportsLanguage("en") { + t.Errorf("model %s: SupportsLanguage('en') should be true", m.ID) + } + if !m.SupportsLanguage("") { + t.Errorf("model %s: SupportsLanguage('') should be true (auto always supported)", m.ID) + } + } + } +} + +func TestWhisperCppProvider_MultilingualModels(t *testing.T) { + p := &WhisperCppProvider{} + models := p.Models() + + multilingualIDs := map[string]bool{ + "tiny": true, + "base": true, + "small": true, + "medium": true, + "large-v3": true, + } + + allLangs := language.AllLanguageCodes() + + for _, m := range models { + isMultilingual := multilingualIDs[m.ID] + if isMultilingual { + if len(m.SupportedLanguages) != len(allLangs) { + t.Errorf("model %s: expected %d languages, got %d", m.ID, len(allLangs), len(m.SupportedLanguages)) + } + if !m.SupportsAllLanguages() { + t.Errorf("model %s: SupportsAllLanguages() should be true", m.ID) + } + if !m.SupportsLanguage("es") { + t.Errorf("model %s: SupportsLanguage('es') should be true", m.ID) + } + } + } +} + +func TestWhisperCppProvider_RequiresAPIKey(t *testing.T) { + p := &WhisperCppProvider{} + if p.RequiresAPIKey() { + t.Error("RequiresAPIKey() should return false") + } +} + +func TestWhisperCppProvider_IsLocal(t *testing.T) { + p := &WhisperCppProvider{} + if !p.IsLocal() { + t.Error("IsLocal() should return true") + } +} + +func TestWhisperCppProvider_DefaultModel(t *testing.T) { + p := &WhisperCppProvider{} + if p.DefaultModel(Transcription) != "base.en" { + t.Errorf("expected DefaultModel(Transcription)='base.en', got '%s'", p.DefaultModel(Transcription)) + } + if p.DefaultModel(LLM) != "" { + t.Errorf("expected DefaultModel(LLM)='', got '%s'", p.DefaultModel(LLM)) + } +} + +func TestWhisperCppProvider_LocalInfo(t *testing.T) { + p := &WhisperCppProvider{} + models := p.Models() + + for _, m := range models { + if m.LocalInfo.Filename == "" { + t.Errorf("model %s: LocalInfo.Filename should not be empty", m.ID) + } + if m.LocalInfo.Size == "" { + t.Errorf("model %s: LocalInfo.Size should not be empty", m.ID) + } + if m.LocalInfo.DownloadURL == "" { + t.Errorf("model %s: LocalInfo.DownloadURL should not be empty", m.ID) + } + if !m.NeedsDownload() { + t.Errorf("model %s: NeedsDownload() should be true for local model", m.ID) + } + } +} diff --git a/progress.txt b/progress.txt index be337ee..4356281 100644 --- a/progress.txt +++ b/progress.txt @@ -204,4 +204,17 @@ Started: Sun Feb 1 12:22:47 AM CET 2026 - Parses stdout for transcription text - Created comprehensive test file adapter_whisper_cpp_test.go - Tests: interface implementation, empty audio, missing model, language, threads, context cancellation +- All tests passing, typecheck passes + +### Task 21: Create whisper-cpp Provider +- Created `internal/provider/whisper_cpp.go` implementing Provider interface +- Name() returns 'whisper-cpp', RequiresAPIKey() returns false, IsLocal() returns true +- Models() returns 9 whisper models from whisper.ListModels() +- English-only models (*.en) have SupportedLanguages=['en'] +- Multilingual models have SupportedLanguages with all 57 language codes +- Each model has: Type=Transcription, AdapterType='whisper-cpp', Local=true, LocalInfo with Filename/Size/DownloadURL +- No Endpoint (local CLI, not HTTP) +- DefaultModel(Transcription) returns 'base.en' +- Registered in provider.init() +- Comprehensive test file created: whisper_cpp_test.go - All tests passing, typecheck passes \ No newline at end of file diff --git a/tasks/prd.jsonc b/tasks/prd.jsonc index f49804e..1bb3b00 100644 --- a/tasks/prd.jsonc +++ b/tasks/prd.jsonc @@ -511,7 +511,7 @@ "RequiresAPIKey() returns false", "Typecheck passes" ], - "passes": false + "passes": true }, { "title": "Wire whisper-cpp into transcriber factory",