summaryrefslogtreecommitdiff
path: root/internal/processor
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-01 21:19:18 +0300
committerPaul Buetow <paul@buetow.org>2026-04-01 21:19:18 +0300
commitd1c6d860214be8895f4ed90ff74bff664fbb4422 (patch)
tree4983cce4b69ffebdff02e1a2c3cc869d0603b4e4 /internal/processor
parentaeeb3c6ffea828773f849b88d97c5ffead706a97 (diff)
Add Gemini audio CLI flags
Diffstat (limited to 'internal/processor')
-rw-r--r--internal/processor/processor.go190
-rw-r--r--internal/processor/processor_test.go59
2 files changed, 198 insertions, 51 deletions
diff --git a/internal/processor/processor.go b/internal/processor/processor.go
index 3b53737..1a0155c 100644
--- a/internal/processor/processor.go
+++ b/internal/processor/processor.go
@@ -241,21 +241,66 @@ func (p *Processor) ProcessWordWithTranslationAndType(word, providedTranslation
return nil
}
+func (p *Processor) audioProviderName() string {
+ if provider := strings.ToLower(strings.TrimSpace(viper.GetString("audio.provider"))); provider != "" {
+ return provider
+ }
+ if p != nil && p.flags != nil {
+ return strings.ToLower(strings.TrimSpace(p.flags.AudioProvider))
+ }
+ return ""
+}
+
+func (p *Processor) geminiTTSModel() string {
+ if model := strings.TrimSpace(viper.GetString("audio.gemini_tts_model")); model != "" {
+ return model
+ }
+ if p != nil && p.flags != nil {
+ return strings.TrimSpace(p.flags.GeminiTTSModel)
+ }
+ return ""
+}
+
+func (p *Processor) geminiVoice() string {
+ if voice := strings.TrimSpace(viper.GetString("audio.gemini_voice")); voice != "" {
+ return voice
+ }
+ if p != nil && p.flags != nil {
+ return strings.TrimSpace(p.flags.GeminiVoice)
+ }
+ return ""
+}
+
// generateAudio generates audio files for a word
func (p *Processor) generateAudio(word string) error {
- // Get list of voices to use
+ provider := p.audioProviderName()
+
+ // Get list of voices to use.
var voices []string
- if p.flags.AllVoices {
- voices = audio.OpenAIVoices
- } else if p.flags.OpenAIVoice != "" {
- // Use explicitly specified voice
- voices = []string{p.flags.OpenAIVoice}
- fmt.Printf(" Using specified voice: %s\n", p.flags.OpenAIVoice)
- } else {
- // Select a random voice
- randomVoice := audio.OpenAIVoices[rand.Intn(len(audio.OpenAIVoices))]
- voices = []string{randomVoice}
- fmt.Printf(" Using random voice: %s\n", randomVoice)
+ switch provider {
+ case "gemini":
+ if p.flags.AllVoices {
+ voices = audio.GeminiVoices
+ } else if voice := p.geminiVoice(); voice != "" {
+ voices = []string{voice}
+ fmt.Printf(" Using specified Gemini voice: %s\n", voice)
+ } else {
+ voices = []string{""}
+ fmt.Printf(" Using Gemini model default voice\n")
+ }
+ default:
+ if p.flags.AllVoices {
+ voices = audio.OpenAIVoices
+ } else if p.flags.OpenAIVoice != "" {
+ // Use explicitly specified voice
+ voices = []string{p.flags.OpenAIVoice}
+ fmt.Printf(" Using specified voice: %s\n", p.flags.OpenAIVoice)
+ } else {
+ // Select a random voice
+ randomVoice := audio.OpenAIVoices[rand.Intn(len(audio.OpenAIVoices))]
+ voices = []string{randomVoice}
+ fmt.Printf(" Using random voice: %s\n", randomVoice)
+ }
}
// Generate audio for each voice
@@ -273,12 +318,25 @@ func (p *Processor) generateAudio(word string) error {
// generateAudioBgBg generates audio files for both sides of a bg-bg card
func (p *Processor) generateAudioBgBg(front, back string) error {
- // Select a random voice (same voice for both sides for consistency)
- voice := audio.OpenAIVoices[rand.Intn(len(audio.OpenAIVoices))]
- if p.flags.OpenAIVoice != "" {
- voice = p.flags.OpenAIVoice
+ provider := p.audioProviderName()
+
+ voice := ""
+ switch provider {
+ case "gemini":
+ voice = p.geminiVoice()
+ if voice != "" {
+ fmt.Printf(" Using Gemini voice: %s\n", voice)
+ } else {
+ fmt.Printf(" Using Gemini model default voice\n")
+ }
+ default:
+ // Select a random voice (same voice for both sides for consistency)
+ voice = audio.OpenAIVoices[rand.Intn(len(audio.OpenAIVoices))]
+ if p.flags.OpenAIVoice != "" {
+ voice = p.flags.OpenAIVoice
+ }
+ fmt.Printf(" Using voice: %s\n", voice)
}
- fmt.Printf(" Using voice: %s\n", voice)
// Find or create the word directory ONCE (for the front word)
// Both audio files will be saved to this same directory
@@ -312,36 +370,49 @@ func (p *Processor) generateAudioWithVoiceAndFilename(word, voice, filenameBase
// generateAudioWithVoiceAndFilenameInDir generates audio for a word and saves it to a specific directory
func (p *Processor) generateAudioWithVoiceAndFilenameInDir(word, voice, filenameBase, wordDir string) error {
+ audioProvider := p.audioProviderName()
+
// Generate random speed between 0.90 and 1.00 if not explicitly set
speed := p.flags.OpenAISpeed
- if p.flags.OpenAISpeed == 0.9 && !viper.IsSet("audio.openai_speed") {
+ if audioProvider == "openai" && p.flags.OpenAISpeed == 0.9 && !viper.IsSet("audio.openai_speed") {
// Default was used, generate random speed
speed = 0.90 + rand.Float64()*0.10
}
// Create audio provider configuration
- providerConfig := &audio.Config{
- Provider: "openai",
- OutputDir: p.flags.OutputDir,
- OutputFormat: p.flags.AudioFormat,
-
- // OpenAI settings
- OpenAIKey: cli.GetOpenAIKey(),
- OpenAIModel: p.flags.OpenAIModel,
- OpenAIVoice: voice,
- OpenAISpeed: speed,
- OpenAIInstruction: p.flags.OpenAIInstruction,
- }
-
- // Use config file values if not overridden by flags
- if p.flags.OpenAIModel == "gpt-4o-mini-tts" && viper.IsSet("audio.openai_model") {
- providerConfig.OpenAIModel = viper.GetString("audio.openai_model")
- }
- if p.flags.OpenAISpeed == 0.9 && viper.IsSet("audio.openai_speed") {
- providerConfig.OpenAISpeed = viper.GetFloat64("audio.openai_speed")
- }
- if p.flags.OpenAIInstruction == "" && viper.IsSet("audio.openai_instruction") {
- providerConfig.OpenAIInstruction = viper.GetString("audio.openai_instruction")
+ providerConfig := audio.DefaultProviderConfig()
+ providerConfig.Provider = audioProvider
+ providerConfig.OutputDir = p.flags.OutputDir
+ providerConfig.OpenAIKey = cli.GetOpenAIKey()
+ providerConfig.GoogleAPIKey = cli.GetGoogleAPIKey()
+
+ switch audioProvider {
+ case "gemini":
+ providerConfig.OutputFormat = "wav"
+ providerConfig.GeminiTTSModel = p.geminiTTSModel()
+ if voice != "" {
+ providerConfig.GeminiVoice = voice
+ } else {
+ providerConfig.GeminiVoice = p.geminiVoice()
+ }
+ providerConfig.GeminiSpeed = 1.0
+ default:
+ providerConfig.OutputFormat = p.flags.AudioFormat
+ providerConfig.OpenAIModel = p.flags.OpenAIModel
+ providerConfig.OpenAIVoice = voice
+ providerConfig.OpenAISpeed = speed
+ providerConfig.OpenAIInstruction = p.flags.OpenAIInstruction
+
+ // Use config file values if not overridden by flags
+ if p.flags.OpenAIModel == "gpt-4o-mini-tts" && viper.IsSet("audio.openai_model") {
+ providerConfig.OpenAIModel = viper.GetString("audio.openai_model")
+ }
+ if p.flags.OpenAISpeed == 0.9 && viper.IsSet("audio.openai_speed") {
+ providerConfig.OpenAISpeed = viper.GetFloat64("audio.openai_speed")
+ }
+ if p.flags.OpenAIInstruction == "" && viper.IsSet("audio.openai_instruction") {
+ providerConfig.OpenAIInstruction = viper.GetString("audio.openai_instruction")
+ }
}
// Create the audio provider
@@ -354,11 +425,15 @@ func (p *Processor) generateAudioWithVoiceAndFilenameInDir(word, voice, filename
ctx := context.Background()
// Build filename using the provided base
+ outputFormat := p.flags.AudioFormat
+ if providerConfig.Provider == "gemini" {
+ outputFormat = "wav"
+ }
var outputFile string
if p.flags.AllVoices && filenameBase == "audio" {
- outputFile = filepath.Join(wordDir, fmt.Sprintf("%s_%s.%s", filenameBase, voice, p.flags.AudioFormat))
+ outputFile = filepath.Join(wordDir, fmt.Sprintf("%s_%s.%s", filenameBase, voice, outputFormat))
} else {
- outputFile = filepath.Join(wordDir, fmt.Sprintf("%s.%s", filenameBase, p.flags.AudioFormat))
+ outputFile = filepath.Join(wordDir, fmt.Sprintf("%s.%s", filenameBase, outputFormat))
}
// Generate the audio
@@ -811,15 +886,28 @@ func (p *Processor) saveAudioAttribution(word, audioFile string, config *audio.C
cleanedWord = strings.ReplaceAll(cleanedWord, punct, "")
}
processedText := fmt.Sprintf("%s...", strings.TrimSpace(cleanedWord))
- attribution := audio.BuildOpenAIAttribution(audio.AttributionParams{
- Word: word,
- Model: config.OpenAIModel,
- Voice: config.OpenAIVoice,
- Speed: config.OpenAISpeed,
- Instruction: config.OpenAIInstruction,
- ProcessedText: processedText,
- GeneratedAt: time.Now(),
- })
+ var attribution string
+ switch strings.ToLower(strings.TrimSpace(config.Provider)) {
+ case "gemini":
+ attribution = audio.BuildGeminiAttribution(audio.AttributionParams{
+ Word: word,
+ Model: config.GeminiTTSModel,
+ Voice: config.GeminiVoice,
+ Speed: config.GeminiSpeed,
+ ProcessedText: processedText,
+ GeneratedAt: time.Now(),
+ })
+ default:
+ attribution = audio.BuildOpenAIAttribution(audio.AttributionParams{
+ Word: word,
+ Model: config.OpenAIModel,
+ Voice: config.OpenAIVoice,
+ Speed: config.OpenAISpeed,
+ Instruction: config.OpenAIInstruction,
+ ProcessedText: processedText,
+ GeneratedAt: time.Now(),
+ })
+ }
// Save to file
attrPath := audio.AttributionPath(audioFile)
diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go
index c944d7a..9a301cb 100644
--- a/internal/processor/processor_test.go
+++ b/internal/processor/processor_test.go
@@ -284,6 +284,7 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) {
flags.OutputDir = tempDir
flags.AudioFormat = "mp3"
flags.AllVoices = true
+ flags.AudioProvider = "openai"
p := NewProcessor(flags)
@@ -333,6 +334,7 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) {
flags := cli.NewFlags()
flags.OutputDir = tempDir
flags.AudioFormat = "mp3"
+ flags.AudioProvider = "openai"
p := NewProcessor(flags)
if err := p.generateAudioBgBg("ябълка", "круша"); err != nil {
@@ -379,6 +381,7 @@ func TestGenerateAudioProviderFactoryError(t *testing.T) {
flags := cli.NewFlags()
flags.OutputDir = tempDir
flags.AudioFormat = "mp3"
+ flags.AudioProvider = "openai"
p := NewProcessor(flags)
err := p.generateAudio("ябълка")
@@ -390,6 +393,62 @@ func TestGenerateAudioProviderFactoryError(t *testing.T) {
}
}
+func TestGenerateAudioUsesConfiguredGeminiVoiceAndModel(t *testing.T) {
+ originalFactory := newAudioProvider
+ t.Cleanup(func() {
+ newAudioProvider = originalFactory
+ })
+
+ fakeProvider := &fakeAudioProvider{}
+ var capturedConfig *audio.Config
+ newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
+ copyConfig := *config
+ capturedConfig = &copyConfig
+ return fakeProvider, nil
+ }
+
+ originalConfig := viper.New()
+ *originalConfig = *viper.GetViper()
+ defer func() {
+ *viper.GetViper() = *originalConfig
+ }()
+ viper.Reset()
+ viper.Set("audio.provider", "gemini")
+ viper.Set("audio.gemini_tts_model", "gemini-2.5-flash-preview-tts")
+ viper.Set("audio.gemini_voice", "Kore")
+
+ flags := cli.NewFlags()
+ flags.OutputDir = t.TempDir()
+ flags.AudioFormat = "mp3"
+
+ p := NewProcessor(flags)
+ if err := p.generateAudio("ябълка"); err != nil {
+ t.Fatalf("generateAudio() unexpected error: %v", err)
+ }
+
+ if capturedConfig == nil {
+ t.Fatal("expected audio provider config to be captured")
+ }
+ if capturedConfig.Provider != "gemini" {
+ t.Fatalf("captured provider = %q, want %q", capturedConfig.Provider, "gemini")
+ }
+ if capturedConfig.GeminiTTSModel != "gemini-2.5-flash-preview-tts" {
+ t.Fatalf("captured GeminiTTSModel = %q, want %q", capturedConfig.GeminiTTSModel, "gemini-2.5-flash-preview-tts")
+ }
+ if capturedConfig.GeminiVoice != "Kore" {
+ t.Fatalf("captured GeminiVoice = %q, want %q", capturedConfig.GeminiVoice, "Kore")
+ }
+ if capturedConfig.OutputFormat != "wav" {
+ t.Fatalf("captured OutputFormat = %q, want %q", capturedConfig.OutputFormat, "wav")
+ }
+ if fakeProvider.generateCalls != 1 {
+ t.Fatalf("GenerateAudio() calls = %d, want %d", fakeProvider.generateCalls, 1)
+ }
+ if !strings.HasSuffix(fakeProvider.lastOutputFile, "audio.wav") {
+ t.Fatalf("GenerateAudio() output file = %q, want wav output", fakeProvider.lastOutputFile)
+ }
+}
+
func TestDownloadImagesWithTranslationUsesNanoBananaConfigAndSavesPrompt(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "test-openai-key")
t.Setenv("GOOGLE_API_KEY", "test-google-key")