diff --git a/.gitignore b/.gitignore index 72f185d..24722d8 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ go.work.sum hyprvoice hyprvoice-* tmp/* +CLAUDE.md diff --git a/README.md b/README.md index c1e9120..3ea888a 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ export PATH="$HOME/.local/bin:$PATH" - **Wayland desktop** (Hyprland, Niri, GNOME, KDE, etc.) - **PipeWire audio system** with tools -- **OpenAI API key** (for transcription) +- **API key for transcription**: OpenAI API key or Groq API key (Groq offers faster processing and free tier) **System packages** (automatically installed with AUR package): @@ -216,6 +216,59 @@ Configuration is stored in `~/.config/hyprvoice/config.toml` and can also be edi Hyprvoice supports multiple transcription backends: +#### OpenAI Whisper API + +Cloud-based transcription using OpenAI's Whisper API: + +```toml +[transcription] +provider = "openai" +api_key = "sk-..." # Or set OPENAI_API_KEY environment variable +language = "" # Empty for auto-detect, or "en", "es", "fr", etc. +model = "whisper-1" +``` + +**Features:** +- High-quality transcription +- Supports 50+ languages +- Auto-detection or specify language for better accuracy + +#### Groq Whisper API (Transcription) + +Fast cloud-based transcription using Groq's Whisper API: + +```toml +[transcription] +provider = "groq-transcription" +api_key = "gsk_..." # Or set GROQ_API_KEY environment variable +language = "" # Empty for auto-detect, or "en", "es", "fr", etc. +model = "whisper-large-v3" # Or "whisper-large-v3-turbo" for faster processing +``` + +**Features:** +- Ultra-fast transcription (significantly faster than OpenAI) +- Same Whisper model quality +- Supports 50+ languages +- Free tier available with generous limits + +#### Groq Translation API + +Fast translation of audio to English using Groq's Whisper API: + +```toml +[transcription] +provider = "groq-translation" +api_key = "gsk_..." # Or set GROQ_API_KEY environment variable +language = "es" # Optional: hint source language for better accuracy +model = "whisper-large-v3-turbo" +``` + +**Features:** +- Translates any language audio → English text +- Ultra-fast processing +- Language field hints at source language (improves accuracy) +- Always outputs English regardless of input language + #### Generated Configuration Example The daemon automatically creates `~/.config/hyprvoice/config.toml` with helpful comments: @@ -237,10 +290,10 @@ The daemon automatically creates `~/.config/hyprvoice/config.toml` with helpful # Speech Transcription Configuration [transcription] - provider = "openai" # Transcription service ("openai" only currently supported) - api_key = "" # OpenAI API key (or set OPENAI_API_KEY environment variable) + provider = "openai" # Transcription service: "openai", "groq-transcription", or "groq-translation" + api_key = "" # API key (or set OPENAI_API_KEY/GROQ_API_KEY environment variable) language = "" # Language code (empty for auto-detect, "en", "it", "es", "fr", etc.) - model = "whisper-1" # OpenAI model name ("whisper-1" recommended) + model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3" or "whisper-large-v3-turbo" # Text Injection Configuration [injection] @@ -373,6 +426,7 @@ journalctl --user -u hyprvoice.service -f | Audio capture | ✅ | Efficient PipeWire integration | | Desktop notifications | ✅ | Status feedback via notify-send | | OpenAI transcription | ✅ | HTTP API integration | +| Groq transcription | ✅ | Fast Whisper API with transcription and translation | | Text injection | ✅ | Clipboard + wtype with fallback | | Configuration system | ✅ | TOML-based user settings with hot-reload | | Interactive setup | ✅ | `hyprvoice configure` wizard for easy setup | diff --git a/cmd/hyprvoice/main.go b/cmd/hyprvoice/main.go index 8ca4404..501f2bf 100644 --- a/cmd/hyprvoice/main.go +++ b/cmd/hyprvoice/main.go @@ -131,7 +131,8 @@ func configureCmd() *cobra.Command { Short: "Interactive configuration setup", Long: `Interactive configuration wizard for hyprvoice. This will guide you through setting up: -- OpenAI API key for transcription +- Transcription provider (OpenAI or Groq) +- API keys and model selection - Audio and text injection preferences - Notification settings`, RunE: func(cmd *cobra.Command, args []string) error { @@ -157,8 +158,81 @@ func runInteractiveConfig() error { fmt.Println("📝 Transcription Configuration") fmt.Println("------------------------------") - // OpenAI API Key - fmt.Printf("OpenAI API Key (current: %s, leave empty to use OPENAI_API_KEY env var): ", maskAPIKey(cfg.Transcription.APIKey)) + // Provider selection + fmt.Println("Select transcription provider:") + fmt.Println(" 1. openai - OpenAI Whisper API (cloud-based)") + fmt.Println(" 2. groq-transcription - Groq Whisper API (fast transcription)") + fmt.Println(" 3. groq-translation - Groq Whisper API (translate to English)") + fmt.Printf("Provider [1-3] (current: %s): ", cfg.Transcription.Provider) + if scanner.Scan() { + input := strings.TrimSpace(scanner.Text()) + switch input { + case "1": + cfg.Transcription.Provider = "openai" + case "2": + cfg.Transcription.Provider = "groq-transcription" + case "3": + cfg.Transcription.Provider = "groq-translation" + case "openai", "groq-transcription", "groq-translation": + cfg.Transcription.Provider = input + } + } + + // Model selection based on provider + if cfg.Transcription.Provider == "openai" { + fmt.Println("\nOpenAI Model:") + fmt.Printf("Model (current: %s): ", cfg.Transcription.Model) + if scanner.Scan() { + input := strings.TrimSpace(scanner.Text()) + if input != "" { + cfg.Transcription.Model = input + } else if cfg.Transcription.Model == "" { + cfg.Transcription.Model = "whisper-1" + } + } + } else if cfg.Transcription.Provider == "groq-transcription" { + fmt.Println("\nGroq Transcription Model:") + fmt.Println(" 1. whisper-large-v3 - Standard model") + fmt.Println(" 2. whisper-large-v3-turbo - Faster model") + fmt.Printf("Model [1-2] (current: %s): ", cfg.Transcription.Model) + if scanner.Scan() { + input := strings.TrimSpace(scanner.Text()) + switch input { + case "1": + cfg.Transcription.Model = "whisper-large-v3" + case "2": + cfg.Transcription.Model = "whisper-large-v3-turbo" + case "whisper-large-v3", "whisper-large-v3-turbo": + cfg.Transcription.Model = input + case "": + if cfg.Transcription.Model == "" { + cfg.Transcription.Model = "whisper-large-v3-turbo" + } + } + } + } else if cfg.Transcription.Provider == "groq-translation" { + fmt.Println("\nGroq Translation Model:") + fmt.Println(" Note: Translation only supports whisper-large-v3 (turbo not available)") + fmt.Printf("Model (current: %s, press Enter for whisper-large-v3): ", cfg.Transcription.Model) + if scanner.Scan() { + input := strings.TrimSpace(scanner.Text()) + if input == "" || input == "whisper-large-v3" || input == "1" { + cfg.Transcription.Model = "whisper-large-v3" + } else { + fmt.Println(" Warning: Only whisper-large-v3 is supported for translation. Using whisper-large-v3.") + cfg.Transcription.Model = "whisper-large-v3" + } + } + } + + // API Key (provider-aware) + var envVarName string + if cfg.Transcription.Provider == "openai" { + envVarName = "OPENAI_API_KEY" + } else { + envVarName = "GROQ_API_KEY" + } + fmt.Printf("\nAPI Key (current: %s, leave empty to use %s env var): ", maskAPIKey(cfg.Transcription.APIKey), envVarName) if scanner.Scan() { input := strings.TrimSpace(scanner.Text()) if input != "" { @@ -167,7 +241,12 @@ func runInteractiveConfig() error { } // Language - fmt.Printf("Language (empty for auto-detect, current: %s): ", cfg.Transcription.Language) + 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 { + fmt.Printf("\nLanguage (empty for auto-detect, current: %s): ", cfg.Transcription.Language) + } if scanner.Scan() { input := strings.TrimSpace(scanner.Text()) cfg.Transcription.Language = input @@ -303,12 +382,12 @@ func saveConfig(cfg *config.Config) error { channel_buffer_size = %d # Audio frame buffer size (frames to buffer) timeout = "%s" # Maximum recording duration (e.g., "30s", "2m", "5m") -# Speech Transcription Configuration +# Speech Transcription Configuration [transcription] - provider = "%s" # Transcription service ("openai" only currently supported) - api_key = "%s" # OpenAI API key (or set OPENAI_API_KEY environment variable) + provider = "%s" # Transcription service: "openai", "groq-transcription", or "groq-translation" + api_key = "%s" # API key (or set OPENAI_API_KEY/GROQ_API_KEY environment variable) language = "%s" # Language code (empty for auto-detect, "en", "it", "es", "fr", etc.) - model = "%s" # OpenAI model name ("whisper-1" recommended) + model = "%s" # Model: OpenAI="whisper-1", Groq="whisper-large-v3" or "whisper-large-v3-turbo" # Text Injection Configuration [injection] @@ -324,11 +403,19 @@ func saveConfig(cfg *config.Config) error { # Mode explanations: # - "clipboard": Copy text to clipboard only -# - "type": Direct typing via wtype only +# - "type": Direct typing via wtype only # - "fallback": Try typing first, fallback to clipboard if it fails # +# Provider explanations: +# - "openai": OpenAI Whisper API (cloud-based, requires OPENAI_API_KEY) +# - "groq-transcription": Groq Whisper API for transcription (fast, requires GROQ_API_KEY) +# Models: whisper-large-v3 or whisper-large-v3-turbo +# - "groq-translation": Groq Whisper API for translation to English (always outputs English text) +# Models: whisper-large-v3 only (turbo not supported for translation) +# # Language codes: Use empty string ("") for automatic detection, or specific codes like: # "en" (English), "it" (Italian), "es" (Spanish), "fr" (French), "de" (German), etc. +# For groq-translation, the language field hints at the source audio language for better accuracy. `, cfg.Recording.SampleRate, cfg.Recording.Channels, diff --git a/internal/config/config.go b/internal/config/config.go index 2bc809c..f93e0c8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -69,8 +69,14 @@ func (c *Config) ToTranscriberConfig() transcriber.Config { Model: c.Transcription.Model, } + // Check for API key in environment variables if not in config if config.APIKey == "" { - config.APIKey = os.Getenv("OPENAI_API_KEY") + switch c.Transcription.Provider { + case "openai": + config.APIKey = os.Getenv("OPENAI_API_KEY") + case "groq-transcription", "groq-translation": + config.APIKey = os.Getenv("GROQ_API_KEY") + } } return config @@ -110,7 +116,10 @@ func (c *Config) Validate() error { if c.Transcription.Provider == "" { return fmt.Errorf("invalid transcription.provider: empty") } - if c.Transcription.Provider == "openai" { + + // Validate provider-specific settings + switch c.Transcription.Provider { + case "openai": apiKey := c.Transcription.APIKey if apiKey == "" { apiKey = os.Getenv("OPENAI_API_KEY") @@ -123,7 +132,50 @@ func (c *Config) Validate() error { 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', 'es', 'fr')", c.Transcription.Language) } + + case "groq-transcription": + apiKey := c.Transcription.APIKey + if apiKey == "" { + apiKey = os.Getenv("GROQ_API_KEY") + } + if apiKey == "" { + return fmt.Errorf("Groq API key required: not found in config (transcription.api_key) or environment variable (GROQ_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', 'es', 'fr')", c.Transcription.Language) + } + + // Validate Groq model + validGroqModels := map[string]bool{"whisper-large-v3": true, "whisper-large-v3-turbo": true} + if c.Transcription.Model != "" && !validGroqModels[c.Transcription.Model] { + return fmt.Errorf("invalid model for groq-transcription: %s (must be whisper-large-v3 or whisper-large-v3-turbo)", c.Transcription.Model) + } + + case "groq-translation": + apiKey := c.Transcription.APIKey + if apiKey == "" { + apiKey = os.Getenv("GROQ_API_KEY") + } + if apiKey == "" { + return fmt.Errorf("Groq API key required: not found in config (transcription.api_key) or environment variable (GROQ_API_KEY)") + } + + // For translation, language field hints at source language (output is always English) + 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', 'es', 'fr')", c.Transcription.Language) + } + + // Validate Groq translation model - only whisper-large-v3 is supported (no turbo) + if c.Transcription.Model != "" && c.Transcription.Model != "whisper-large-v3" { + return fmt.Errorf("invalid model for groq-translation: %s (must be whisper-large-v3, turbo version not supported for translation)", c.Transcription.Model) + } + + default: + return fmt.Errorf("unsupported transcription.provider: %s (must be openai, groq-transcription, or groq-translation)", c.Transcription.Provider) } + if c.Transcription.Model == "" { return fmt.Errorf("invalid transcription.model: empty") } @@ -235,12 +287,12 @@ func SaveDefaultConfig() error { channel_buffer_size = 30 # Audio frame buffer size (frames to buffer) timeout = "5m" # Maximum recording duration (e.g., "30s", "2m", "5m") -# Speech Transcription Configuration +# Speech Transcription Configuration [transcription] - provider = "openai" # Transcription service ("openai" only currently supported) - api_key = "" # OpenAI API key (or set OPENAI_API_KEY environment variable) + provider = "openai" # Transcription service: "openai", "groq-transcription", or "groq-translation" + api_key = "" # API key (or set OPENAI_API_KEY/GROQ_API_KEY environment variable) language = "" # Language code (empty for auto-detect, "en", "it", "es", "fr", etc.) - model = "whisper-1" # OpenAI model name ("whisper-1" recommended) + model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3" or "whisper-large-v3-turbo" # Text Injection Configuration [injection] @@ -256,11 +308,19 @@ func SaveDefaultConfig() error { # Mode explanations: # - "clipboard": Copy text to clipboard only -# - "type": Direct typing via wtype only +# - "type": Direct typing via wtype only # - "fallback": Try typing first, fallback to clipboard if it fails # +# Provider explanations: +# - "openai": OpenAI Whisper API (cloud-based, requires OPENAI_API_KEY) +# - "groq-transcription": Groq Whisper API for transcription (fast, requires GROQ_API_KEY) +# Models: whisper-large-v3 or whisper-large-v3-turbo +# - "groq-translation": Groq Whisper API for translation to English (always outputs English text) +# Models: whisper-large-v3 only (turbo not supported for translation) +# # Language codes: Use empty string ("") for automatic detection, or specific codes like: # "en" (English), "it" (Italian), "es" (Spanish), "fr" (French), "de" (German), etc. +# For groq-translation, the language field hints at the source audio language for better accuracy. ` if _, err := file.WriteString(configContent); err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b130015..caa2f63 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -788,3 +788,244 @@ func TestConfig_Validate_RecordingBufferSizes(t *testing.T) { t.Errorf("Validate() should have failed with invalid recording buffer sizes") } } + +func TestConfig_Validate_GroqTranscription(t *testing.T) { + config := &Config{ + Recording: RecordingConfig{ + SampleRate: 16000, + Channels: 1, + Format: "s16", + BufferSize: 8192, + ChannelBufferSize: 30, + Timeout: time.Minute, + }, + Transcription: TranscriptionConfig{ + Provider: "groq-transcription", + APIKey: "gsk-test-key", + Language: "en", + Model: "whisper-large-v3", + }, + Injection: InjectionConfig{ + Mode: "fallback", + WtypeTimeout: time.Second, + ClipboardTimeout: time.Second, + }, + Notifications: NotificationsConfig{ + Type: "log", + }, + } + + err := config.Validate() + if err != nil { + t.Errorf("Validate() should have passed with valid groq-transcription config: %v", err) + } +} + +func TestConfig_Validate_GroqTranslation(t *testing.T) { + config := &Config{ + Recording: RecordingConfig{ + SampleRate: 16000, + Channels: 1, + Format: "s16", + BufferSize: 8192, + ChannelBufferSize: 30, + Timeout: time.Minute, + }, + Transcription: TranscriptionConfig{ + Provider: "groq-translation", + APIKey: "gsk-test-key", + Language: "es", + Model: "whisper-large-v3", // Translation only supports non-turbo + }, + Injection: InjectionConfig{ + Mode: "fallback", + WtypeTimeout: time.Second, + ClipboardTimeout: time.Second, + }, + Notifications: NotificationsConfig{ + Type: "log", + }, + } + + err := config.Validate() + if err != nil { + t.Errorf("Validate() should have passed with valid groq-translation config: %v", err) + } +} + +func TestConfig_Validate_GroqInvalidModel(t *testing.T) { + config := &Config{ + Recording: RecordingConfig{ + SampleRate: 16000, + Channels: 1, + Format: "s16", + BufferSize: 8192, + ChannelBufferSize: 30, + Timeout: time.Minute, + }, + Transcription: TranscriptionConfig{ + Provider: "groq-transcription", + APIKey: "gsk-test-key", + Language: "en", + Model: "invalid-model", + }, + Injection: InjectionConfig{ + Mode: "fallback", + WtypeTimeout: time.Second, + ClipboardTimeout: time.Second, + }, + Notifications: NotificationsConfig{ + Type: "log", + }, + } + + err := config.Validate() + if err == nil { + t.Errorf("Validate() should have failed with invalid Groq model") + } +} + +func TestConfig_Validate_GroqWithoutAPIKey(t *testing.T) { + config := &Config{ + Recording: RecordingConfig{ + SampleRate: 16000, + Channels: 1, + Format: "s16", + BufferSize: 8192, + ChannelBufferSize: 30, + Timeout: time.Minute, + }, + Transcription: TranscriptionConfig{ + Provider: "groq-transcription", + APIKey: "", // No API key + Model: "whisper-large-v3", + }, + Injection: InjectionConfig{ + Mode: "fallback", + WtypeTimeout: time.Second, + ClipboardTimeout: time.Second, + }, + Notifications: NotificationsConfig{ + Type: "log", + }, + } + + // Ensure environment variable is not set + originalAPIKey := os.Getenv("GROQ_API_KEY") + os.Unsetenv("GROQ_API_KEY") + defer func() { + if originalAPIKey != "" { + os.Setenv("GROQ_API_KEY", originalAPIKey) + } + }() + + err := config.Validate() + if err == nil { + t.Errorf("Validate() should have failed without Groq API key") + } +} + +func TestConfig_Validate_GroqWithEnvVarAPIKey(t *testing.T) { + config := &Config{ + Recording: RecordingConfig{ + SampleRate: 16000, + Channels: 1, + Format: "s16", + BufferSize: 8192, + ChannelBufferSize: 30, + Timeout: time.Minute, + }, + Transcription: TranscriptionConfig{ + Provider: "groq-transcription", + APIKey: "", // No API key in config + Model: "whisper-large-v3", + }, + Injection: InjectionConfig{ + Mode: "fallback", + WtypeTimeout: time.Second, + ClipboardTimeout: time.Second, + }, + Notifications: NotificationsConfig{ + Type: "log", + }, + } + + // Set environment variable + originalAPIKey := os.Getenv("GROQ_API_KEY") + os.Setenv("GROQ_API_KEY", "gsk-env-api-key") + defer func() { + if originalAPIKey == "" { + os.Unsetenv("GROQ_API_KEY") + } else { + os.Setenv("GROQ_API_KEY", originalAPIKey) + } + }() + + err := config.Validate() + if err != nil { + t.Errorf("Validate() should have passed with Groq API key from environment: %v", err) + } +} + +func TestConfig_ToTranscriberConfig_GroqWithEnvVar(t *testing.T) { + config := &Config{ + Transcription: TranscriptionConfig{ + Provider: "groq-transcription", + APIKey: "", // Empty API key to test env var fallback + Language: "en", + Model: "whisper-large-v3", + }, + } + + // Set environment variable + originalAPIKey := os.Getenv("GROQ_API_KEY") + os.Setenv("GROQ_API_KEY", "gsk-env-api-key") + defer func() { + if originalAPIKey == "" { + os.Unsetenv("GROQ_API_KEY") + } else { + os.Setenv("GROQ_API_KEY", originalAPIKey) + } + }() + + transcriberConfig := config.ToTranscriberConfig() + + if transcriberConfig.APIKey != "gsk-env-api-key" { + t.Errorf("Expected APIKey from env var 'gsk-env-api-key', got %s", transcriberConfig.APIKey) + } +} + +func TestConfig_Validate_GroqTranslation_RejectsTurbo(t *testing.T) { + config := &Config{ + Recording: RecordingConfig{ + SampleRate: 16000, + Channels: 1, + Format: "s16", + BufferSize: 8192, + ChannelBufferSize: 30, + Timeout: time.Minute, + }, + Transcription: TranscriptionConfig{ + Provider: "groq-translation", + APIKey: "gsk-test-key", + Language: "es", + Model: "whisper-large-v3-turbo", // Turbo not supported for translation + }, + Injection: InjectionConfig{ + Mode: "fallback", + WtypeTimeout: time.Second, + ClipboardTimeout: time.Second, + }, + Notifications: NotificationsConfig{ + Type: "log", + }, + } + + err := config.Validate() + if err == nil { + t.Error("Validate() should have rejected whisper-large-v3-turbo for groq-translation") + } + if err != nil && err.Error() != "invalid model for groq-translation: whisper-large-v3-turbo (must be whisper-large-v3, turbo version not supported for translation)" { + t.Errorf("Unexpected error message: %v", err) + } +} diff --git a/internal/transcriber/adapter_groq_transcription.go b/internal/transcriber/adapter_groq_transcription.go new file mode 100644 index 0000000..a164b88 --- /dev/null +++ b/internal/transcriber/adapter_groq_transcription.go @@ -0,0 +1,60 @@ +package transcriber + +import ( + "bytes" + "context" + "fmt" + "log" + "time" + + "github.com/sashabaranov/go-openai" +) + +// GroqTranscriptionAdapter implements TranscriptionAdapter for Groq Whisper API +type GroqTranscriptionAdapter struct { + client *openai.Client + config Config +} + +func NewGroqTranscriptionAdapter(config Config) *GroqTranscriptionAdapter { + clientConfig := openai.DefaultConfig(config.APIKey) + clientConfig.BaseURL = "https://api.groq.com/openai/v1" + client := openai.NewClientWithConfig(clientConfig) + + return &GroqTranscriptionAdapter{ + client: client, + config: config, + } +} + +func (a *GroqTranscriptionAdapter) 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 transcription request + req := openai.AudioRequest{ + Model: a.config.Model, + Reader: bytes.NewReader(wavData), + FilePath: "audio.wav", + Language: a.config.Language, + } + + start := time.Now() + resp, err := a.client.CreateTranscription(ctx, req) + duration := time.Since(start) + + if err != nil { + log.Printf("groq-transcription-adapter: API call failed after %v: %v", duration, err) + return "", fmt.Errorf("groq transcription: %w", err) + } + + log.Printf("groq-transcription-adapter: transcribed %d bytes in %v: %q", len(audioData), duration, resp.Text) + return resp.Text, nil +} diff --git a/internal/transcriber/adapter_groq_translation.go b/internal/transcriber/adapter_groq_translation.go new file mode 100644 index 0000000..5842a1b --- /dev/null +++ b/internal/transcriber/adapter_groq_translation.go @@ -0,0 +1,63 @@ +package transcriber + +import ( + "bytes" + "context" + "fmt" + "log" + "time" + + "github.com/sashabaranov/go-openai" +) + +// GroqTranslationAdapter implements TranscriptionAdapter for Groq Translation API +// Translates audio to English text. The Language field in config hints at the source language. +type GroqTranslationAdapter struct { + client *openai.Client + config Config +} + +func NewGroqTranslationAdapter(config Config) *GroqTranslationAdapter { + clientConfig := openai.DefaultConfig(config.APIKey) + clientConfig.BaseURL = "https://api.groq.com/openai/v1" + client := openai.NewClientWithConfig(clientConfig) + + return &GroqTranslationAdapter{ + client: client, + config: config, + } +} + +func (a *GroqTranslationAdapter) 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 translation request + // Note: Translation always outputs English, regardless of target language + // The Language field in the request hints at the source audio language for better accuracy + req := openai.AudioRequest{ + Model: a.config.Model, + Reader: bytes.NewReader(wavData), + FilePath: "audio.wav", + Language: a.config.Language, // Source language hint + } + + start := time.Now() + resp, err := a.client.CreateTranslation(ctx, req) + duration := time.Since(start) + + if err != nil { + log.Printf("groq-translation-adapter: API call failed after %v: %v", duration, err) + return "", fmt.Errorf("groq translation: %w", err) + } + + log.Printf("groq-translation-adapter: translated %d bytes in %v: %q", len(audioData), duration, resp.Text) + return resp.Text, nil +} diff --git a/internal/transcriber/adapter_openai.go b/internal/transcriber/adapter_openai.go index 4259860..53fc5ae 100644 --- a/internal/transcriber/adapter_openai.go +++ b/internal/transcriber/adapter_openai.go @@ -3,7 +3,6 @@ package transcriber import ( "bytes" "context" - "encoding/binary" "fmt" "log" "time" @@ -31,7 +30,7 @@ func (a *OpenAIAdapter) Transcribe(ctx context.Context, audioData []byte) (strin } // Convert raw PCM to WAV format - wavData, err := a.convertToWAV(audioData) + wavData, err := convertToWAV(audioData) if err != nil { return "", fmt.Errorf("convert to WAV: %w", err) } @@ -56,39 +55,3 @@ func (a *OpenAIAdapter) Transcribe(ctx context.Context, audioData []byte) (strin log.Printf("openai-adapter: transcribed %d bytes in %v: %q", len(audioData), duration, resp.Text) return resp.Text, nil } - -// convertToWAV converts raw 16-bit PCM audio to WAV format -func (a *OpenAIAdapter) convertToWAV(rawAudio []byte) ([]byte, error) { - var buf bytes.Buffer - - const sampleRate = 16000 - const channels = 1 - const bitsPerSample = 16 - const byteRate = sampleRate * channels * bitsPerSample / 8 - const blockAlign = channels * bitsPerSample / 8 - - dataSize := len(rawAudio) - fileSize := 36 + dataSize - - // WAV header - buf.WriteString("RIFF") - binary.Write(&buf, binary.LittleEndian, uint32(fileSize)) - buf.WriteString("WAVE") - - // fmt chunk - buf.WriteString("fmt ") - binary.Write(&buf, binary.LittleEndian, uint32(16)) // fmt chunk size - binary.Write(&buf, binary.LittleEndian, uint16(1)) // PCM format - binary.Write(&buf, binary.LittleEndian, uint16(channels)) // number of channels - binary.Write(&buf, binary.LittleEndian, uint32(sampleRate)) // sample rate - binary.Write(&buf, binary.LittleEndian, uint32(byteRate)) // byte rate - binary.Write(&buf, binary.LittleEndian, uint16(blockAlign)) // block align - binary.Write(&buf, binary.LittleEndian, uint16(bitsPerSample)) // bits per sample - - // data chunk - buf.WriteString("data") - binary.Write(&buf, binary.LittleEndian, uint32(dataSize)) - buf.Write(rawAudio) - - return buf.Bytes(), nil -} diff --git a/internal/transcriber/audio_utils.go b/internal/transcriber/audio_utils.go new file mode 100644 index 0000000..0ee7ad9 --- /dev/null +++ b/internal/transcriber/audio_utils.go @@ -0,0 +1,42 @@ +package transcriber + +import ( + "bytes" + "encoding/binary" +) + +// convertToWAV converts raw 16-bit PCM audio to WAV format +func convertToWAV(rawAudio []byte) ([]byte, error) { + var buf bytes.Buffer + + const sampleRate = 16000 + const channels = 1 + const bitsPerSample = 16 + const byteRate = sampleRate * channels * bitsPerSample / 8 + const blockAlign = channels * bitsPerSample / 8 + + dataSize := len(rawAudio) + fileSize := 36 + dataSize + + // WAV header + buf.WriteString("RIFF") + binary.Write(&buf, binary.LittleEndian, uint32(fileSize)) + buf.WriteString("WAVE") + + // fmt chunk + buf.WriteString("fmt ") + binary.Write(&buf, binary.LittleEndian, uint32(16)) // fmt chunk size + binary.Write(&buf, binary.LittleEndian, uint16(1)) // PCM format + binary.Write(&buf, binary.LittleEndian, uint16(channels)) // number of channels + binary.Write(&buf, binary.LittleEndian, uint32(sampleRate)) // sample rate + binary.Write(&buf, binary.LittleEndian, uint32(byteRate)) // byte rate + binary.Write(&buf, binary.LittleEndian, uint16(blockAlign)) // block align + binary.Write(&buf, binary.LittleEndian, uint16(bitsPerSample)) // bits per sample + + // data chunk + buf.WriteString("data") + binary.Write(&buf, binary.LittleEndian, uint32(dataSize)) + buf.Write(rawAudio) + + return buf.Bytes(), nil +} diff --git a/internal/transcriber/transcriber.go b/internal/transcriber/transcriber.go index 9fdd35a..3e04656 100644 --- a/internal/transcriber/transcriber.go +++ b/internal/transcriber/transcriber.go @@ -39,6 +39,18 @@ func NewTranscriber(config Config) (Transcriber, error) { } adapter = NewOpenAIAdapter(config) + case "groq-transcription": + if config.APIKey == "" { + return nil, fmt.Errorf("Groq API key required") + } + adapter = NewGroqTranscriptionAdapter(config) + + case "groq-translation": + if config.APIKey == "" { + return nil, fmt.Errorf("Groq API key required") + } + adapter = NewGroqTranslationAdapter(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 8024815..51d5db9 100644 --- a/internal/transcriber/transcriber_test.go +++ b/internal/transcriber/transcriber_test.go @@ -35,6 +35,46 @@ func TestNewTranscriber(t *testing.T) { }, wantErr: true, }, + { + name: "valid groq-transcription config", + config: Config{ + Provider: "groq-transcription", + APIKey: "gsk-test-key", + Language: "en", + Model: "whisper-large-v3", + }, + wantErr: false, + }, + { + name: "groq-transcription config without api key", + config: Config{ + Provider: "groq-transcription", + APIKey: "", + Language: "en", + Model: "whisper-large-v3", + }, + wantErr: true, + }, + { + name: "valid groq-translation config", + config: Config{ + Provider: "groq-translation", + APIKey: "gsk-test-key", + Language: "es", + Model: "whisper-large-v3-turbo", + }, + wantErr: false, + }, + { + name: "groq-translation config without api key", + config: Config{ + Provider: "groq-translation", + APIKey: "", + Language: "es", + Model: "whisper-large-v3-turbo", + }, + wantErr: true, + }, { name: "unsupported provider", config: Config{