diff --git a/internal/transcriber/adapter_whisper_cpp.go b/internal/transcriber/adapter_whisper_cpp.go new file mode 100644 index 0000000..75e2754 --- /dev/null +++ b/internal/transcriber/adapter_whisper_cpp.go @@ -0,0 +1,107 @@ +package transcriber + +import ( + "bytes" + "context" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/leonardotrapani/hyprvoice/internal/language" +) + +// WhisperCppAdapter implements BatchAdapter for local whisper-cpp transcription +type WhisperCppAdapter struct { + modelPath string + language string + threads int +} + +// NewWhisperCppAdapter creates a new whisper-cpp adapter +// modelPath: full path to the model file (e.g., ~/.local/share/hyprvoice/models/whisper/ggml-base.en.bin) +// lang: canonical language code (will be converted to whisper-cpp format) +// threads: number of CPU threads (0 for auto) +func NewWhisperCppAdapter(modelPath, lang string, threads int) *WhisperCppAdapter { + return &WhisperCppAdapter{ + modelPath: modelPath, + language: lang, + threads: threads, + } +} + +func (a *WhisperCppAdapter) Transcribe(ctx context.Context, audioData []byte) (string, error) { + if len(audioData) == 0 { + return "", nil + } + + // check whisper-cli exists + whisperPath, err := exec.LookPath("whisper-cli") + if err != nil { + return "", fmt.Errorf("whisper-cli not found: install whisper.cpp first") + } + + // check model file exists + if _, err := os.Stat(a.modelPath); os.IsNotExist(err) { + return "", fmt.Errorf("model file not found: %s", a.modelPath) + } + + // convert raw PCM to WAV + wavData, err := convertToWAV(audioData) + if err != nil { + return "", fmt.Errorf("convert to WAV: %w", err) + } + + // write to temp file + tmpDir := os.TempDir() + tmpFile := filepath.Join(tmpDir, fmt.Sprintf("hyprvoice-%d.wav", time.Now().UnixNano())) + if err := os.WriteFile(tmpFile, wavData, 0600); err != nil { + return "", fmt.Errorf("write temp file: %w", err) + } + defer os.Remove(tmpFile) + + // convert language to whisper-cpp format + lang := language.ToProviderFormat(a.language, "whisper-cpp") + + // build command args + args := []string{ + "-m", a.modelPath, + "-l", lang, + "-nt", // no timestamps + "-np", // no progress + "-f", tmpFile, + } + + // add threads if specified + if a.threads > 0 { + args = append(args, "-t", fmt.Sprintf("%d", a.threads)) + } + + // execute whisper-cli + cmd := exec.CommandContext(ctx, whisperPath, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + start := time.Now() + err = cmd.Run() + duration := time.Since(start) + + if err != nil { + // check if context was cancelled + if ctx.Err() != nil { + return "", ctx.Err() + } + log.Printf("whisper-cpp: command failed after %v: %v\nstderr: %s", duration, err, stderr.String()) + return "", fmt.Errorf("whisper-cli failed: %w", err) + } + + // parse output - whisper-cli outputs transcription text directly (with -nt flag) + text := strings.TrimSpace(stdout.String()) + + log.Printf("whisper-cpp: transcribed %d bytes in %v: %q", len(audioData), duration, text) + return text, nil +} diff --git a/internal/transcriber/adapter_whisper_cpp_test.go b/internal/transcriber/adapter_whisper_cpp_test.go new file mode 100644 index 0000000..e870d69 --- /dev/null +++ b/internal/transcriber/adapter_whisper_cpp_test.go @@ -0,0 +1,125 @@ +package transcriber + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestWhisperCppAdapter_ImplementsBatchAdapter(t *testing.T) { + // compile-time check that WhisperCppAdapter implements BatchAdapter + var _ BatchAdapter = (*WhisperCppAdapter)(nil) +} + +func TestWhisperCppAdapter_EmptyAudio(t *testing.T) { + adapter := NewWhisperCppAdapter("/nonexistent/model.bin", "en", 4) + text, err := adapter.Transcribe(context.Background(), []byte{}) + if err != nil { + t.Errorf("expected no error for empty audio, got: %v", err) + } + if text != "" { + t.Errorf("expected empty text for empty audio, got: %q", text) + } +} + +func TestWhisperCppAdapter_MissingModel(t *testing.T) { + adapter := NewWhisperCppAdapter("/nonexistent/path/model.bin", "en", 4) + + // create minimal valid PCM data (just zeros) + audioData := make([]byte, 32000) // 1 second at 16kHz 16-bit + + _, err := adapter.Transcribe(context.Background(), audioData) + if err == nil { + t.Error("expected error for missing model file") + } + if err != nil && !contains(err.Error(), "model file not found") { + t.Errorf("expected 'model file not found' error, got: %v", err) + } +} + +func TestWhisperCppAdapter_LanguageConversion(t *testing.T) { + // verify adapter stores language for later conversion + adapter := NewWhisperCppAdapter("/fake/model.bin", "", 4) + if adapter.language != "" { + t.Errorf("expected empty language for auto, got: %q", adapter.language) + } + + adapter = NewWhisperCppAdapter("/fake/model.bin", "en", 4) + if adapter.language != "en" { + t.Errorf("expected 'en' language, got: %q", adapter.language) + } +} + +func TestWhisperCppAdapter_ThreadsConfig(t *testing.T) { + adapter := NewWhisperCppAdapter("/fake/model.bin", "en", 0) + if adapter.threads != 0 { + t.Errorf("expected threads=0 (auto), got: %d", adapter.threads) + } + + adapter = NewWhisperCppAdapter("/fake/model.bin", "en", 8) + if adapter.threads != 8 { + t.Errorf("expected threads=8, got: %d", adapter.threads) + } +} + +func TestWhisperCppAdapter_TempFileCleanup(t *testing.T) { + // this test requires whisper-cli and a model to be installed + // skip if not available + modelPath := os.Getenv("WHISPER_TEST_MODEL") + if modelPath == "" { + t.Skip("WHISPER_TEST_MODEL not set, skipping temp file cleanup test") + } + + adapter := NewWhisperCppAdapter(modelPath, "en", 4) + + // create minimal audio data + audioData := make([]byte, 32000) + + // run transcription + _, _ = adapter.Transcribe(context.Background(), audioData) + + // check that temp file was cleaned up + // (we can't easily verify this without modifying the adapter to expose temp path) + // this is more of a visual/log verification +} + +func TestWhisperCppAdapter_ContextCancellation(t *testing.T) { + // skip if whisper-cli not installed + if _, err := os.Stat("/usr/local/bin/whisper-cli"); os.IsNotExist(err) { + t.Skip("whisper-cli not installed") + } + + // create a fake model file for this test + tmpDir := t.TempDir() + fakeModel := filepath.Join(tmpDir, "fake.bin") + if err := os.WriteFile(fakeModel, []byte("fake"), 0600); err != nil { + t.Fatalf("failed to create fake model: %v", err) + } + + adapter := NewWhisperCppAdapter(fakeModel, "en", 4) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + // create minimal audio data + audioData := make([]byte, 32000) + + _, err := adapter.Transcribe(ctx, audioData) + if err == nil { + t.Error("expected error for cancelled context") + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/progress.txt b/progress.txt index 1fa9f73..be337ee 100644 --- a/progress.txt +++ b/progress.txt @@ -184,4 +184,24 @@ Started: Sun Feb 1 12:22:47 AM CET 2026 - Remove(modelID) deletes model file - GetInstalledPath(modelID) returns path or error if not installed - Download uses temp file + rename for atomicity, respects context cancellation +- All tests passing, typecheck passes + +### Task 20: Create WhisperCppAdapter implementing BatchAdapter +- Created `internal/transcriber/adapter_whisper_cpp.go` +- WhisperCppAdapter struct with modelPath, language, threads fields +- Constructor: `NewWhisperCppAdapter(modelPath, lang string, threads int)` +- Transcribe() implementation: + - Returns empty string for empty audio (no error) + - Checks whisper-cli exists via exec.LookPath + - Checks model file exists via os.Stat + - Converts raw PCM to WAV using existing convertToWAV helper + - Writes to temp file in os.TempDir() with unique timestamp + - Uses defer os.Remove(tmpFile) for cleanup + - Converts language via language.ToProviderFormat(lang, "whisper-cpp") + - Executes: whisper-cli -m {modelPath} -l {lang} -nt -np -f {tempfile} + - Adds -t {threads} flag if threads > 0 + - Respects context cancellation + - 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 \ No newline at end of file diff --git a/tasks/prd.jsonc b/tasks/prd.jsonc index b6b1065..f49804e 100644 --- a/tasks/prd.jsonc +++ b/tasks/prd.jsonc @@ -480,7 +480,7 @@ "Cleans up temp files", "Typecheck passes" ], - "passes": false + "passes": true }, { "title": "Create whisper-cpp Provider",