feat: add Groq Whisper API support for transcription and translation

Add support for Groq Whisper API as an alternative to OpenAI:
- groq-transcription: Fast transcription using whisper-large-v3 or whisper-large-v3-turbo
- groq-translation: Translation to English (whisper-large-v3 only)

Features:
- New transcription adapters for both Groq services
- Config validation with provider-specific model restrictions
- API key support via config file or GROQ_API_KEY environment variable
- Updated interactive configuration wizard with provider selection
- Comprehensive test coverage for all providers
- Shared WAV conversion utility for audio preprocessing

Breaking changes: None
Backward compatibility: Existing OpenAI configurations continue to work unchanged
This commit is contained in:
Ben Nasedkin
2025-10-18 19:19:03 +03:00
parent 937c15e4de
commit 025ff7fc55
10 changed files with 676 additions and 58 deletions
@@ -0,0 +1,97 @@
package transcriber
import (
"bytes"
"context"
"encoding/binary"
"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
}
// 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
}
@@ -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 (
"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
}
+12
View File
@@ -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)
}
+40
View File
@@ -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{