Merge pull request #2 from nasedkinpv/main

feat: add Groq Whisper API support for transcription and translation
This commit is contained in:
Leonardo Trapani
2025-10-27 14:19:29 +01:00
committed by GitHub
11 changed files with 681 additions and 58 deletions
+1
View File
@@ -32,3 +32,4 @@ go.work.sum
hyprvoice hyprvoice
hyprvoice-* hyprvoice-*
tmp/* tmp/*
CLAUDE.md
+58 -4
View File
@@ -62,7 +62,7 @@ export PATH="$HOME/.local/bin:$PATH"
- **Wayland desktop** (Hyprland, Niri, GNOME, KDE, etc.) - **Wayland desktop** (Hyprland, Niri, GNOME, KDE, etc.)
- **PipeWire audio system** with tools - **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): **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: 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 #### Generated Configuration Example
The daemon automatically creates `~/.config/hyprvoice/config.toml` with helpful comments: 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 # Speech Transcription Configuration
[transcription] [transcription]
provider = "openai" # Transcription service ("openai" only currently supported) provider = "openai" # Transcription service: "openai", "groq-transcription", or "groq-translation"
api_key = "" # OpenAI API key (or set OPENAI_API_KEY environment variable) 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.) 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 # Text Injection Configuration
[injection] [injection]
@@ -373,6 +426,7 @@ journalctl --user -u hyprvoice.service -f
| Audio capture | ✅ | Efficient PipeWire integration | | Audio capture | ✅ | Efficient PipeWire integration |
| Desktop notifications | ✅ | Status feedback via notify-send | | Desktop notifications | ✅ | Status feedback via notify-send |
| OpenAI transcription | ✅ | HTTP API integration | | OpenAI transcription | ✅ | HTTP API integration |
| Groq transcription | ✅ | Fast Whisper API with transcription and translation |
| Text injection | ✅ | Clipboard + wtype with fallback | | Text injection | ✅ | Clipboard + wtype with fallback |
| Configuration system | ✅ | TOML-based user settings with hot-reload | | Configuration system | ✅ | TOML-based user settings with hot-reload |
| Interactive setup | ✅ | `hyprvoice configure` wizard for easy setup | | Interactive setup | ✅ | `hyprvoice configure` wizard for easy setup |
+94 -7
View File
@@ -131,7 +131,8 @@ func configureCmd() *cobra.Command {
Short: "Interactive configuration setup", Short: "Interactive configuration setup",
Long: `Interactive configuration wizard for hyprvoice. Long: `Interactive configuration wizard for hyprvoice.
This will guide you through setting up: 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 - Audio and text injection preferences
- Notification settings`, - Notification settings`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
@@ -157,8 +158,81 @@ func runInteractiveConfig() error {
fmt.Println("📝 Transcription Configuration") fmt.Println("📝 Transcription Configuration")
fmt.Println("------------------------------") fmt.Println("------------------------------")
// OpenAI API Key // Provider selection
fmt.Printf("OpenAI API Key (current: %s, leave empty to use OPENAI_API_KEY env var): ", maskAPIKey(cfg.Transcription.APIKey)) 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() { if scanner.Scan() {
input := strings.TrimSpace(scanner.Text()) input := strings.TrimSpace(scanner.Text())
if input != "" { if input != "" {
@@ -167,7 +241,12 @@ func runInteractiveConfig() error {
} }
// Language // 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() { if scanner.Scan() {
input := strings.TrimSpace(scanner.Text()) input := strings.TrimSpace(scanner.Text())
cfg.Transcription.Language = input cfg.Transcription.Language = input
@@ -305,10 +384,10 @@ func saveConfig(cfg *config.Config) error {
# Speech Transcription Configuration # Speech Transcription Configuration
[transcription] [transcription]
provider = "%s" # Transcription service ("openai" only currently supported) provider = "%s" # Transcription service: "openai", "groq-transcription", or "groq-translation"
api_key = "%s" # OpenAI API key (or set OPENAI_API_KEY environment variable) 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.) 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 # Text Injection Configuration
[injection] [injection]
@@ -327,8 +406,16 @@ func saveConfig(cfg *config.Config) error {
# - "type": Direct typing via wtype only # - "type": Direct typing via wtype only
# - "fallback": Try typing first, fallback to clipboard if it fails # - "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: # Language codes: Use empty string ("") for automatic detection, or specific codes like:
# "en" (English), "it" (Italian), "es" (Spanish), "fr" (French), "de" (German), etc. # "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.SampleRate,
cfg.Recording.Channels, cfg.Recording.Channels,
+65 -5
View File
@@ -69,8 +69,14 @@ func (c *Config) ToTranscriberConfig() transcriber.Config {
Model: c.Transcription.Model, Model: c.Transcription.Model,
} }
// Check for API key in environment variables if not in config
if config.APIKey == "" { 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 return config
@@ -110,7 +116,10 @@ func (c *Config) Validate() error {
if c.Transcription.Provider == "" { if c.Transcription.Provider == "" {
return fmt.Errorf("invalid transcription.provider: empty") 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 apiKey := c.Transcription.APIKey
if apiKey == "" { if apiKey == "" {
apiKey = os.Getenv("OPENAI_API_KEY") apiKey = os.Getenv("OPENAI_API_KEY")
@@ -123,7 +132,50 @@ func (c *Config) Validate() error {
if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) { 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) 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 == "" { if c.Transcription.Model == "" {
return fmt.Errorf("invalid transcription.model: empty") return fmt.Errorf("invalid transcription.model: empty")
} }
@@ -237,10 +289,10 @@ func SaveDefaultConfig() error {
# Speech Transcription Configuration # Speech Transcription Configuration
[transcription] [transcription]
provider = "openai" # Transcription service ("openai" only currently supported) provider = "openai" # Transcription service: "openai", "groq-transcription", or "groq-translation"
api_key = "" # OpenAI API key (or set OPENAI_API_KEY environment variable) 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.) 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 # Text Injection Configuration
[injection] [injection]
@@ -259,8 +311,16 @@ func SaveDefaultConfig() error {
# - "type": Direct typing via wtype only # - "type": Direct typing via wtype only
# - "fallback": Try typing first, fallback to clipboard if it fails # - "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: # Language codes: Use empty string ("") for automatic detection, or specific codes like:
# "en" (English), "it" (Italian), "es" (Spanish), "fr" (French), "de" (German), etc. # "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 { if _, err := file.WriteString(configContent); err != nil {
+241
View File
@@ -788,3 +788,244 @@ func TestConfig_Validate_RecordingBufferSizes(t *testing.T) {
t.Errorf("Validate() should have failed with invalid recording buffer sizes") 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)
}
}
@@ -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
}
@@ -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
}
+1 -38
View File
@@ -3,7 +3,6 @@ package transcriber
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/binary"
"fmt" "fmt"
"log" "log"
"time" "time"
@@ -31,7 +30,7 @@ func (a *OpenAIAdapter) Transcribe(ctx context.Context, audioData []byte) (strin
} }
// Convert raw PCM to WAV format // Convert raw PCM to WAV format
wavData, err := a.convertToWAV(audioData) wavData, err := convertToWAV(audioData)
if err != nil { if err != nil {
return "", fmt.Errorf("convert to WAV: %w", err) 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) log.Printf("openai-adapter: transcribed %d bytes in %v: %q", len(audioData), duration, resp.Text)
return resp.Text, nil 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
}
+42
View File
@@ -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
}
+12
View File
@@ -39,6 +39,18 @@ func NewTranscriber(config Config) (Transcriber, error) {
} }
adapter = NewOpenAIAdapter(config) 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: default:
return nil, fmt.Errorf("unsupported provider: %s", config.Provider) return nil, fmt.Errorf("unsupported provider: %s", config.Provider)
} }
+40
View File
@@ -35,6 +35,46 @@ func TestNewTranscriber(t *testing.T) {
}, },
wantErr: true, 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", name: "unsupported provider",
config: Config{ config: Config{