add WhisperCppAdapter for local whisper-cpp transcription
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -185,3 +185,23 @@ Started: Sun Feb 1 12:22:47 AM CET 2026
|
|||||||
- GetInstalledPath(modelID) returns path or error if not installed
|
- GetInstalledPath(modelID) returns path or error if not installed
|
||||||
- Download uses temp file + rename for atomicity, respects context cancellation
|
- Download uses temp file + rename for atomicity, respects context cancellation
|
||||||
- All tests passing, typecheck passes
|
- 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
|
||||||
+1
-1
@@ -480,7 +480,7 @@
|
|||||||
"Cleans up temp files",
|
"Cleans up temp files",
|
||||||
"Typecheck passes"
|
"Typecheck passes"
|
||||||
],
|
],
|
||||||
"passes": false
|
"passes": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Create whisper-cpp Provider",
|
"title": "Create whisper-cpp Provider",
|
||||||
|
|||||||
Reference in New Issue
Block a user