Merge branch 'main' into support_older_ydotool

This commit is contained in:
Leonardo Trapani
2026-01-08 10:11:19 +01:00
committed by GitHub
9 changed files with 308 additions and 22 deletions
+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.
+7 -7
View File
@@ -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,
},
+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{