diff --git a/README.md b/README.md index d61e3cf..edeb3b3 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Press a toggle key, speak, and get instant text input. Built natively for Waylan - **Toggle workflow**: Press once to start recording, press again to stop and inject text - **Wayland native**: Purpose-built for Wayland compositors - no legacy X11 dependencies or hacky workarounds - **Real-time feedback**: Desktop notifications for recording states and transcription status -- **Multiple transcription backends**: OpenAI Whisper and Groq (planned: whisper.cpp for local processing, gemini, and more) +- **Multiple transcription backends**: OpenAI Whisper, Groq, Mistral Voxtral, and Eleven Labs Scribe (99 languages, excellent accuracy) - **Smart text injection**: Clipboard save/restore with direct typing fallback - **Daemon architecture**: Lightweight control plane with efficient pipeline management @@ -62,7 +62,7 @@ export PATH="$HOME/.local/bin:$PATH" - **Wayland desktop** (Hyprland, Niri, GNOME, KDE, etc.) - **PipeWire audio system** with tools -- **API key for transcription**: OpenAI API key or Groq API key (Groq offers faster processing and free tier) +- **API key for transcription**: OpenAI, Groq, Mistral, or Eleven Labs API key (check each provider's pricing) **System packages** (automatically installed with AUR package): @@ -96,7 +96,7 @@ After installing via AUR: ```bash hyprvoice configure ``` -This wizard will guide you through setting up your OpenAI API key, audio preferences, and other settings. +This wizard will guide you through setting up your transcription provider, API key, audio preferences, and other settings. 2. **Enable and start the service:** ```bash diff --git a/cmd/hyprvoice/main.go b/cmd/hyprvoice/main.go index 1f6117d..d24857e 100644 --- a/cmd/hyprvoice/main.go +++ b/cmd/hyprvoice/main.go @@ -166,7 +166,8 @@ func runInteractiveConfig() error { fmt.Println(" 2. groq-transcription - Groq Whisper API (fast transcription)") fmt.Println(" 3. groq-translation - Groq Whisper API (translate to English)") fmt.Println(" 4. mistral-transcription - Mistral Voxtral API (excellent for European languages)") - fmt.Printf("Provider [1-4] (current: %s): ", cfg.Transcription.Provider) + fmt.Println(" 5. elevenlabs - ElevenLabs Scribe API (99 languages, excellent accuracy)") + fmt.Printf("Provider [1-5] (current: %s): ", cfg.Transcription.Provider) if !scanner.Scan() { break } @@ -183,10 +184,12 @@ func runInteractiveConfig() error { cfg.Transcription.Provider = "groq-translation" case "4": cfg.Transcription.Provider = "mistral-transcription" - case "openai", "groq-transcription", "groq-translation", "mistral-transcription": + case "5": + cfg.Transcription.Provider = "elevenlabs" + case "openai", "groq-transcription", "groq-translation", "mistral-transcription", "elevenlabs": cfg.Transcription.Provider = input default: - fmt.Println("❌ Error: invalid provider. Please enter 1-4 or provider name.") + fmt.Println("❌ Error: invalid provider. Please enter 1-5 or provider name.") fmt.Println() continue } @@ -275,6 +278,38 @@ func runInteractiveConfig() error { } break } + case "elevenlabs": + for { + fmt.Println("\nElevenLabs Scribe Model:") + fmt.Println(" Language Support:") + fmt.Println(" scribe_v1: 99 languages (96.7% accuracy for English, ≤5% WER for Portuguese)") + fmt.Println(" scribe_v2: 90 languages (real-time optimized, lower latency)") + fmt.Println() + fmt.Println(" Available Models:") + fmt.Println(" 1. scribe_v1 - Best accuracy, full timestamps (recommended)") + fmt.Println(" 2. scribe_v2 - Real-time streaming, lower latency") + fmt.Printf("Model [1-2] (current: %s): ", cfg.Transcription.Model) + if !scanner.Scan() { + break + } + input := strings.TrimSpace(scanner.Text()) + switch input { + case "1": + cfg.Transcription.Model = "scribe_v1" + case "2": + cfg.Transcription.Model = "scribe_v2" + case "scribe_v1", "scribe_v2": + cfg.Transcription.Model = input + case "": + if cfg.Transcription.Model == "" { + cfg.Transcription.Model = "scribe_v1" + } + default: + fmt.Println("❌ Error: invalid model. Please enter 1, 2 or model name.") + continue + } + break + } } // API Key (provider-aware) @@ -284,6 +319,8 @@ func runInteractiveConfig() error { envVarName = "OPENAI_API_KEY" case "mistral-transcription": envVarName = "MISTRAL_API_KEY" + case "elevenlabs": + envVarName = "ELEVENLABS_API_KEY" default: envVarName = "GROQ_API_KEY" } @@ -299,6 +336,13 @@ func runInteractiveConfig() error { if cfg.Transcription.Provider == "groq-translation" { fmt.Printf("\nSource language hint (empty for auto-detect, current: %s): ", cfg.Transcription.Language) fmt.Println("\n Note: Translation always outputs English. Language hints at source audio language.") + } else if cfg.Transcription.Provider == "elevenlabs" { + fmt.Println("\nLanguage Performance:") + fmt.Println(" Excellent (≤5% WER): English, Portuguese, +25 languages") + fmt.Println(" High (5-10% WER): French, German, Spanish, Italian, etc.") + fmt.Println(" Good (10-20% WER): Most supported languages") + fmt.Println(" Leave empty for auto-detection (recommended)") + fmt.Printf("Language (current: %s): ", cfg.Transcription.Language) } else { fmt.Printf("\nLanguage (empty for auto-detect, current: %s): ", cfg.Transcription.Language) } @@ -608,10 +652,10 @@ func saveConfig(cfg *config.Config) error { # Speech Transcription Configuration [transcription] - provider = "%s" # Transcription service: "openai", "groq-transcription", "groq-translation", or "mistral-transcription" - api_key = "%s" # API key (or set OPENAI_API_KEY/GROQ_API_KEY/MISTRAL_API_KEY environment variable) + provider = "%s" # Transcription service: "openai", "groq-transcription", "groq-translation", "mistral-transcription", or "elevenlabs" + api_key = "%s" # API key (or set OPENAI_API_KEY/GROQ_API_KEY/MISTRAL_API_KEY/ELEVENLABS_API_KEY environment variable) language = "%s" # Language code (empty for auto-detect, "en", "it", "es", "fr", etc.) - model = "%s" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest" + model = "%s" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1" or "scribe_v2" # Text Injection Configuration [injection] @@ -635,6 +679,8 @@ func saveConfig(cfg *config.Config) error { # Models: whisper-large-v3 only (turbo not supported for translation) # - "mistral-transcription": Mistral Voxtral API (excellent for European languages, requires MISTRAL_API_KEY) # Models: voxtral-mini-latest or voxtral-mini-2507 +# - "elevenlabs": ElevenLabs Scribe API (excellent accuracy, 99 languages, requires ELEVENLABS_API_KEY) +# Models: scribe_v1 (99 languages, best accuracy) or scribe_v2 (90 languages, real-time) # # Language codes: Use empty string ("") for automatic detection, or specific codes like: # "en" (English), "it" (Italian), "es" (Spanish), "fr" (French), "de" (German), etc. diff --git a/internal/config/config.go b/internal/config/config.go index 9ad9e1c..21cf11f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -127,6 +127,8 @@ func (c *Config) ToTranscriberConfig() transcriber.Config { config.APIKey = os.Getenv("GROQ_API_KEY") case "mistral-transcription": config.APIKey = os.Getenv("MISTRAL_API_KEY") + case "elevenlabs": + config.APIKey = os.Getenv("ELEVENLABS_API_KEY") } } @@ -243,8 +245,28 @@ func (c *Config) Validate() error { return fmt.Errorf("invalid model for mistral-transcription: %s (must be voxtral-mini-latest or voxtral-mini-2507)", c.Transcription.Model) } + case "elevenlabs": + apiKey := c.Transcription.APIKey + if apiKey == "" { + apiKey = os.Getenv("ELEVENLABS_API_KEY") + } + if apiKey == "" { + return fmt.Errorf("ElevenLabs API key required: not found in config (transcription.api_key) or environment variable (ELEVENLABS_API_KEY)") + } + + // Validate language code if provided (empty string means auto-detect) + 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', 'pt', 'es')", c.Transcription.Language) + } + + // Validate Eleven Labs model + validModels := map[string]bool{"scribe_v1": true, "scribe_v2": true} + if c.Transcription.Model != "" && !validModels[c.Transcription.Model] { + return fmt.Errorf("invalid model for elevenlabs: %s (must be scribe_v1 or scribe_v2)", c.Transcription.Model) + } + default: - return fmt.Errorf("unsupported transcription.provider: %s (must be openai, groq-transcription, groq-translation, or mistral-transcription)", c.Transcription.Provider) + return fmt.Errorf("unsupported transcription.provider: %s (must be openai, groq-transcription, groq-translation, mistral-transcription, or elevenlabs)", c.Transcription.Provider) } if c.Transcription.Model == "" { @@ -412,10 +434,10 @@ func SaveDefaultConfig() error { # Speech Transcription Configuration [transcription] - provider = "openai" # Transcription service: "openai", "groq-transcription", "groq-translation", or "mistral-transcription" - api_key = "" # API key (or set OPENAI_API_KEY/GROQ_API_KEY/MISTRAL_API_KEY environment variable) + provider = "openai" # Transcription service: "openai", "groq-transcription", "groq-translation", "mistral-transcription", or "elevenlabs" + api_key = "" # API key (or set OPENAI_API_KEY/GROQ_API_KEY/MISTRAL_API_KEY/ELEVENLABS_API_KEY environment variable) language = "" # Language code (empty for auto-detect, "en", "it", "es", "fr", etc.) - model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest" + model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1" # Text Injection Configuration [injection] @@ -479,6 +501,8 @@ func SaveDefaultConfig() error { # Models: whisper-large-v3 only (turbo not supported for translation) # - "mistral-transcription": Mistral Voxtral API (excellent for European languages, requires MISTRAL_API_KEY) # Models: voxtral-mini-latest or voxtral-mini-2507 +# - "elevenlabs": ElevenLabs Scribe API (excellent accuracy, 99 languages, requires ELEVENLABS_API_KEY) +# Models: scribe_v1 (99 languages, best accuracy) or scribe_v2 (90 languages, real-time) # # Language codes: Use empty string ("") for automatic detection, or specific codes like: # "en" (English), "it" (Italian), "es" (Spanish), "fr" (French), "de" (German), etc. diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 51da06a..5a93d83 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -25,7 +25,7 @@ func TestNew(t *testing.T) { Model: "whisper-1", }, Injection: config.InjectionConfig{ - Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second, ClipboardTimeout: 3 * time.Second, }, @@ -63,7 +63,7 @@ func TestPipeline_Status(t *testing.T) { Model: "whisper-1", }, Injection: config.InjectionConfig{ - Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second, ClipboardTimeout: 3 * time.Second, }, @@ -109,7 +109,7 @@ func TestPipeline_GetActionCh(t *testing.T) { Model: "whisper-1", }, Injection: config.InjectionConfig{ - Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second, ClipboardTimeout: 3 * time.Second, }, @@ -153,7 +153,7 @@ func TestPipeline_GetErrorCh(t *testing.T) { Model: "whisper-1", }, Injection: config.InjectionConfig{ - Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second, ClipboardTimeout: 3 * time.Second, }, @@ -197,7 +197,7 @@ func TestPipeline_Stop(t *testing.T) { Model: "whisper-1", }, Injection: config.InjectionConfig{ - Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second, ClipboardTimeout: 3 * time.Second, }, @@ -234,7 +234,7 @@ func TestPipeline_Run(t *testing.T) { Model: "whisper-1", }, Injection: config.InjectionConfig{ - Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second, ClipboardTimeout: 3 * time.Second, }, @@ -344,7 +344,7 @@ func TestPipeline_ConcurrentAccess(t *testing.T) { Model: "whisper-1", }, Injection: config.InjectionConfig{ - Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second, ClipboardTimeout: 3 * time.Second, }, diff --git a/internal/transcriber/adapter_elevenlabs.go b/internal/transcriber/adapter_elevenlabs.go new file mode 100644 index 0000000..90092c6 --- /dev/null +++ b/internal/transcriber/adapter_elevenlabs.go @@ -0,0 +1,108 @@ +package transcriber + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "mime/multipart" + "net/http" + "time" +) + +// ElevenLabsAdapter implements TranscriptionAdapter for ElevenLabs Scribe API +type ElevenLabsAdapter struct { + client *http.Client + config Config +} + +// ElevenLabsResponse represents the API response +type ElevenLabsResponse struct { + Text string `json:"text"` +} + +// NewElevenLabsAdapter creates a new ElevenLabs adapter +func NewElevenLabsAdapter(config Config) *ElevenLabsAdapter { + return &ElevenLabsAdapter{ + client: &http.Client{Timeout: 30 * time.Second}, + config: config, + } +} + +// Transcribe sends audio to ElevenLabs API for transcription +func (a *ElevenLabsAdapter) Transcribe(ctx context.Context, audioData []byte) (string, error) { + if len(audioData) == 0 { + return "", nil + } + + // Convert raw PCM to WAV format + wavData, err := convertToWAV(audioData) + if err != nil { + return "", fmt.Errorf("convert to WAV: %w", err) + } + + // Create multipart form body + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + // Add audio file + part, err := writer.CreateFormFile("file", "audio.wav") + if err != nil { + return "", fmt.Errorf("create form file: %w", err) + } + if _, err := io.Copy(part, bytes.NewReader(wavData)); err != nil { + return "", fmt.Errorf("copy audio data: %w", err) + } + + // Add model_id + if err := writer.WriteField("model_id", a.config.Model); err != nil { + return "", fmt.Errorf("write model_id: %w", err) + } + + // Add language_code if specified + if a.config.Language != "" { + if err := writer.WriteField("language_code", a.config.Language); err != nil { + return "", fmt.Errorf("write language_code: %w", err) + } + } + + if err := writer.Close(); err != nil { + return "", fmt.Errorf("close writer: %w", err) + } + + // Create HTTP request + url := "https://api.elevenlabs.io/v1/speech-to-text" + req, err := http.NewRequestWithContext(ctx, "POST", url, &body) + if err != nil { + return "", fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("xi-api-key", a.config.APIKey) + + start := time.Now() + resp, err := a.client.Do(req) + duration := time.Since(start) + + if err != nil { + log.Printf("elevenlabs-adapter: API call failed after %v: %v", duration, err) + return "", fmt.Errorf("elevenlabs request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + log.Printf("elevenlabs-adapter: API returned status %d: %s", resp.StatusCode, string(bodyBytes)) + return "", fmt.Errorf("elevenlabs API status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var result ElevenLabsResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("decode response: %w", err) + } + + log.Printf("elevenlabs-adapter: transcribed %d bytes in %v: %q", len(audioData), duration, result.Text) + return result.Text, nil +} diff --git a/internal/transcriber/adapter_elevenlabs_test.go b/internal/transcriber/adapter_elevenlabs_test.go new file mode 100644 index 0000000..74bf94e --- /dev/null +++ b/internal/transcriber/adapter_elevenlabs_test.go @@ -0,0 +1,72 @@ +package transcriber + +import ( + "context" + "testing" +) + +func TestNewElevenLabsAdapter(t *testing.T) { + config := Config{ + Provider: "elevenlabs", + APIKey: "test-api-key", + Language: "en", + Model: "scribe_v1", + } + + adapter := NewElevenLabsAdapter(config) + + if adapter == nil { + t.Fatalf("NewElevenLabsAdapter() returned nil") + } + + if adapter.config.APIKey != "test-api-key" { + t.Errorf("APIKey not set correctly, got: %s", adapter.config.APIKey) + } + + if adapter.config.Model != "scribe_v1" { + t.Errorf("Model not set correctly, got: %s", adapter.config.Model) + } +} + +func TestElevenLabsAdapter_Transcribe_EmptyAudio(t *testing.T) { + config := Config{ + Provider: "elevenlabs", + APIKey: "test-key", + Model: "scribe_v1", + } + + adapter := NewElevenLabsAdapter(config) + ctx := context.Background() + + result, err := adapter.Transcribe(ctx, []byte{}) + + if err != nil { + t.Errorf("Transcribe() with empty audio should not error, got: %v", err) + } + + if result != "" { + t.Errorf("Transcribe() with empty audio should return empty string, got: %s", result) + } +} + +func TestElevenLabsAdapter_Transcribe_ValidAudio(t *testing.T) { + // This test will require mocking the HTTP client + // For now, we test the structure exists + config := Config{ + Provider: "elevenlabs", + APIKey: "test-key", + Language: "en", + Model: "scribe_v1", + } + + adapter := NewElevenLabsAdapter(config) + + if adapter == nil { + t.Fatal("NewElevenLabsAdapter() returned nil") + } + + // Test that adapter has a client + if adapter.client == nil { + t.Error("adapter.client is nil") + } +} diff --git a/internal/transcriber/transcriber.go b/internal/transcriber/transcriber.go index 70a69b7..1dcfecb 100644 --- a/internal/transcriber/transcriber.go +++ b/internal/transcriber/transcriber.go @@ -57,6 +57,12 @@ func NewTranscriber(config Config) (Transcriber, error) { } adapter = NewMistralAdapter(config) + case "elevenlabs": + if config.APIKey == "" { + return nil, fmt.Errorf("ElevenLabs API key required") + } + adapter = NewElevenLabsAdapter(config) + default: return nil, fmt.Errorf("unsupported provider: %s", config.Provider) } diff --git a/internal/transcriber/transcriber_test.go b/internal/transcriber/transcriber_test.go index 700fd3c..fa552a6 100644 --- a/internal/transcriber/transcriber_test.go +++ b/internal/transcriber/transcriber_test.go @@ -95,6 +95,36 @@ func TestNewTranscriber(t *testing.T) { }, wantErr: true, }, + { + name: "valid elevenlabs config with scribe_v1", + config: Config{ + Provider: "elevenlabs", + APIKey: "test-key", + Language: "en", + Model: "scribe_v1", + }, + wantErr: false, + }, + { + name: "valid elevenlabs config with scribe_v2", + config: Config{ + Provider: "elevenlabs", + APIKey: "test-key", + Language: "pt", + Model: "scribe_v2", + }, + wantErr: false, + }, + { + name: "elevenlabs config without api key", + config: Config{ + Provider: "elevenlabs", + APIKey: "", + Language: "en", + Model: "scribe_v1", + }, + wantErr: true, + }, { name: "unsupported provider", config: Config{