Merge pull request #13 from mateusnobre/feature/elevenlabs-scribe-support

feat: Add Eleven Labs Scribe API support
This commit is contained in:
Leonardo Trapani
2026-01-07 09:54:33 +01:00
committed by GitHub
8 changed files with 306 additions and 20 deletions
+3 -3
View File
@@ -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
+52 -6
View File
@@ -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.
+28 -4
View File
@@ -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.
+108
View File
@@ -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
}
@@ -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")
}
}
+6
View File
@@ -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)
}
+30
View File
@@ -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{