diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-01 21:19:18 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-01 21:19:18 +0300 |
| commit | d1c6d860214be8895f4ed90ff74bff664fbb4422 (patch) | |
| tree | 4983cce4b69ffebdff02e1a2c3cc869d0603b4e4 /internal | |
| parent | aeeb3c6ffea828773f849b88d97c5ffead706a97 (diff) | |
Add Gemini audio CLI flags
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/cli/command.go | 20 | ||||
| -rw-r--r-- | internal/cli/command_test.go | 48 | ||||
| -rw-r--r-- | internal/cli/flags.go | 20 | ||||
| -rw-r--r-- | internal/cli/flags_test.go | 8 | ||||
| -rw-r--r-- | internal/processor/processor.go | 190 | ||||
| -rw-r--r-- | internal/processor/processor_test.go | 59 |
6 files changed, 286 insertions, 59 deletions
diff --git a/internal/cli/command.go b/internal/cli/command.go index 6416250..0e1e93b 100644 --- a/internal/cli/command.go +++ b/internal/cli/command.go @@ -21,10 +21,10 @@ func CreateRootCommand(flags *Flags) *cobra.Command { Short: "Bulgarian Anki Flashcard Generator", Long: `totalrecall generates Anki flashcard materials from Bulgarian words. -It creates audio pronunciation files using OpenAI TTS and downloads -representative images. Launching with no arguments opens the interactive GUI, which uses Nano Banana for images by default. Explicit CLI runs can use OpenAI or Nano Banana via --image-api, and config files can set image.provider too. +It creates audio pronunciation files using Gemini TTS by default and downloads +representative images. Launching with no arguments opens the interactive GUI, which uses Nano Banana for images by default. Explicit CLI runs can use OpenAI or Nano Banana via --image-api, and audio can be switched between Gemini and OpenAI with --audio-provider. -Nano Banana model and text-model flags are available for Nano Banana image generation. +Gemini audio model and voice flags are available for Gemini TTS generation. Examples: totalrecall # Launch interactive GUI (default) @@ -58,7 +58,7 @@ func setupFlags(cmd *cobra.Command, flags *Flags) { // Local flags cmd.Flags().StringVarP(&flags.OutputDir, "output", "o", defaultOutputDir, "Output directory") - cmd.Flags().StringVarP(&flags.AudioFormat, "format", "f", flags.AudioFormat, "Audio format (wav or mp3)") + cmd.Flags().StringVarP(&flags.AudioFormat, "format", "f", flags.AudioFormat, "Audio format (wav or mp3; Gemini TTS always writes wav)") cmd.Flags().StringVar(&flags.ImageAPI, "image-api", flags.ImageAPI, "Image source for explicit CLI runs (OpenAI or Nano Banana; config file image.provider also applies when unset)") cmd.Flags().StringVar(&flags.BatchFile, "batch", "", "Process words from file (one per line)") cmd.Flags().BoolVar(&flags.SkipAudio, "skip-audio", false, "Skip audio generation") @@ -77,6 +77,11 @@ func setupFlags(cmd *cobra.Command, flags *Flags) { cmd.Flags().Float64Var(&flags.OpenAISpeed, "openai-speed", flags.OpenAISpeed, "OpenAI speech speed (0.25 to 4.0, may be ignored by gpt-4o-mini-tts)") cmd.Flags().StringVar(&flags.OpenAIInstruction, "openai-instruction", "", "Voice instructions for gpt-4o-mini-tts model (e.g., 'speak slowly with a Bulgarian accent')") + // Gemini audio flags + cmd.Flags().StringVar(&flags.AudioProvider, "audio-provider", flags.AudioProvider, "Audio provider (gemini or openai; config file audio.provider also applies)") + cmd.Flags().StringVar(&flags.GeminiTTSModel, "gemini-tts-model", flags.GeminiTTSModel, "Gemini TTS model (config file audio.gemini_tts_model also applies)") + cmd.Flags().StringVar(&flags.GeminiVoice, "gemini-voice", flags.GeminiVoice, geminiVoiceUsage()) + // OpenAI Image Generation flags cmd.Flags().StringVar(&flags.OpenAIImageModel, "openai-image-model", flags.OpenAIImageModel, "OpenAI image model: dall-e-2 or dall-e-3") cmd.Flags().StringVar(&flags.OpenAIImageSize, "openai-image-size", flags.OpenAIImageSize, "Image size: 256x256, 512x512, 1024x1024 (dall-e-3: also 1024x1792, 1792x1024)") @@ -103,10 +108,13 @@ func MarkExplicitFlagValues(cmd *cobra.Command, flags *Flags) { func bindFlagsToViper(cmd *cobra.Command) error { bindings := map[string]string{ "audio.format": "format", + "audio.provider": "audio-provider", "audio.openai_model": "openai-model", "audio.openai_voice": "openai-voice", "audio.openai_speed": "openai-speed", "audio.openai_instruction": "openai-instruction", + "audio.gemini_tts_model": "gemini-tts-model", + "audio.gemini_voice": "gemini-voice", "output.directory": "output", "image.provider": "image-api", "image.openai_model": "openai-image-model", @@ -193,3 +201,7 @@ func GetGoogleAPIKey() string { func openAIVoiceUsage() string { return "OpenAI voice: " + strings.Join(audio.OpenAIVoices, ", ") + " (default: random)" } + +func geminiVoiceUsage() string { + return "Gemini voice: " + strings.Join(audio.GeminiVoices, ", ") + " (default: model default)" +} diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index df193fb..ffb741b 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -58,6 +58,9 @@ func TestCreateRootCommand(t *testing.T) { {"openai-image-size", true}, {"openai-image-quality", true}, {"openai-image-style", true}, + {"audio-provider", true}, + {"gemini-tts-model", true}, + {"gemini-voice", true}, {"nanobanana-model", true}, {"nanobanana-text-model", true}, } @@ -124,6 +127,33 @@ func TestSetupFlags(t *testing.T) { t.Errorf("Expected openai-voice help to derive from shared voice list, got %q", openAIVoiceFlag.Usage) } + audioProviderFlag := cmd.Flags().Lookup("audio-provider") + if audioProviderFlag == nil { + t.Fatal("audio-provider flag not found") + } + if audioProviderFlag.DefValue != audio.DefaultProviderConfig().Provider { + t.Errorf("Expected default audio-provider to be %s, got %s", audio.DefaultProviderConfig().Provider, audioProviderFlag.DefValue) + } + + geminiTTSModelFlag := cmd.Flags().Lookup("gemini-tts-model") + if geminiTTSModelFlag == nil { + t.Fatal("gemini-tts-model flag not found") + } + if geminiTTSModelFlag.DefValue != audio.DefaultProviderConfig().GeminiTTSModel { + t.Errorf("Expected default gemini-tts-model to be %s, got %s", audio.DefaultProviderConfig().GeminiTTSModel, geminiTTSModelFlag.DefValue) + } + + geminiVoiceFlag := cmd.Flags().Lookup("gemini-voice") + if geminiVoiceFlag == nil { + t.Fatal("gemini-voice flag not found") + } + if geminiVoiceFlag.DefValue != "" { + t.Errorf("Expected default gemini-voice to be empty, got %q", geminiVoiceFlag.DefValue) + } + if !strings.Contains(geminiVoiceFlag.Usage, "default: model default") { + t.Errorf("Expected gemini-voice help to describe the model default voice, got %q", geminiVoiceFlag.Usage) + } + nanoBananaModelFlag := cmd.Flags().Lookup("nanobanana-model") if nanoBananaModelFlag == nil { t.Fatal("nanobanana-model flag not found") @@ -413,6 +443,15 @@ func TestBindFlagsToViper(t *testing.T) { if err := cmd.Flags().Set("openai-model", "tts-1-hd"); err != nil { t.Fatalf("Failed to set openai-model flag: %v", err) } + if err := cmd.Flags().Set("audio-provider", "gemini"); err != nil { + t.Fatalf("Failed to set audio-provider flag: %v", err) + } + if err := cmd.Flags().Set("gemini-tts-model", "gemini-2.5-flash-preview-tts"); err != nil { + t.Fatalf("Failed to set gemini-tts-model flag: %v", err) + } + if err := cmd.Flags().Set("gemini-voice", "Kore"); err != nil { + t.Fatalf("Failed to set gemini-voice flag: %v", err) + } if err := cmd.Flags().Set("nanobanana-model", "gemini-3.1-flash-image-preview"); err != nil { t.Fatalf("Failed to set nanobanana-model flag: %v", err) } @@ -436,6 +475,15 @@ func TestBindFlagsToViper(t *testing.T) { if viper.GetString("audio.openai_model") != "tts-1-hd" { t.Errorf("Expected audio.openai_model to be tts-1-hd, got %s", viper.GetString("audio.openai_model")) } + if viper.GetString("audio.provider") != "gemini" { + t.Errorf("Expected audio.provider to be gemini, got %s", viper.GetString("audio.provider")) + } + if viper.GetString("audio.gemini_tts_model") != "gemini-2.5-flash-preview-tts" { + t.Errorf("Expected audio.gemini_tts_model to be gemini-2.5-flash-preview-tts, got %s", viper.GetString("audio.gemini_tts_model")) + } + if viper.GetString("audio.gemini_voice") != "Kore" { + t.Errorf("Expected audio.gemini_voice to be Kore, got %s", viper.GetString("audio.gemini_voice")) + } if viper.GetString("image.nanobanana_model") != "gemini-3.1-flash-image-preview" { t.Errorf("Expected image.nanobanana_model to be gemini-3.1-flash-image-preview, got %s", viper.GetString("image.nanobanana_model")) } diff --git a/internal/cli/flags.go b/internal/cli/flags.go index 5fcab40..4813b05 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -1,5 +1,7 @@ package cli +import "codeberg.org/snonux/totalrecall/internal/audio" + const ( defaultNanoBananaModel = "gemini-3.1-flash-image-preview" defaultNanoBananaTextModel = "gemini-2.5-flash" @@ -8,9 +10,11 @@ const ( // Flags holds all command-line flag values type Flags struct { // General flags - CfgFile string - OutputDir string - AudioFormat string + CfgFile string + OutputDir string + AudioFormat string + // AudioProvider selects the text-to-speech backend ("gemini" or "openai"). + AudioProvider string ImageAPI string ImageAPISpecified bool BatchFile string @@ -36,6 +40,12 @@ type Flags struct { OpenAIImageQuality string OpenAIImageStyle string + // Gemini audio flags + // GeminiTTSModel is the Gemini TTS model used when Gemini audio is selected. + GeminiTTSModel string + // GeminiVoice selects a specific Gemini voice; empty uses the model default. + GeminiVoice string + // NanoBananaModel is the Gemini image model used for Nano Banana generation. NanoBananaModel string // NanoBananaModelSpecified records whether the Nano Banana image model was explicitly set on the CLI. @@ -48,8 +58,11 @@ type Flags struct { // NewFlags creates a new Flags instance with default values func NewFlags() *Flags { + defaults := audio.DefaultProviderConfig() + return &Flags{ AudioFormat: "mp3", + AudioProvider: defaults.Provider, ImageAPI: "openai", DeckName: "Bulgarian Vocabulary", OpenAIModel: "gpt-4o-mini-tts", @@ -58,6 +71,7 @@ func NewFlags() *Flags { OpenAIImageSize: "512x512", OpenAIImageQuality: "standard", OpenAIImageStyle: "natural", + GeminiTTSModel: defaults.GeminiTTSModel, NanoBananaModel: defaultNanoBananaModel, NanoBananaTextModel: defaultNanoBananaTextModel, } diff --git a/internal/cli/flags_test.go b/internal/cli/flags_test.go index 7ab6425..285aa1e 100644 --- a/internal/cli/flags_test.go +++ b/internal/cli/flags_test.go @@ -3,6 +3,8 @@ package cli import ( "reflect" "testing" + + "codeberg.org/snonux/totalrecall/internal/audio" ) func TestNewFlags(t *testing.T) { @@ -15,6 +17,7 @@ func TestNewFlags(t *testing.T) { expected interface{} }{ {"AudioFormat", flags.AudioFormat, "mp3"}, + {"AudioProvider", flags.AudioProvider, audio.DefaultProviderConfig().Provider}, {"ImageAPI", flags.ImageAPI, "openai"}, {"ImageAPISpecified", flags.ImageAPISpecified, false}, {"NanoBananaModelSpecified", flags.NanoBananaModelSpecified, false}, @@ -26,6 +29,8 @@ func TestNewFlags(t *testing.T) { {"OpenAIImageSize", flags.OpenAIImageSize, "512x512"}, {"OpenAIImageQuality", flags.OpenAIImageQuality, "standard"}, {"OpenAIImageStyle", flags.OpenAIImageStyle, "natural"}, + {"GeminiTTSModel", flags.GeminiTTSModel, audio.DefaultProviderConfig().GeminiTTSModel}, + {"GeminiVoice", flags.GeminiVoice, ""}, {"NanoBananaModel", flags.NanoBananaModel, "gemini-3.1-flash-image-preview"}, {"NanoBananaTextModel", flags.NanoBananaTextModel, "gemini-2.5-flash"}, } @@ -87,11 +92,12 @@ func TestFlagsStructure(t *testing.T) { flagsType := reflect.TypeOf(*flags) expectedFields := []string{ - "CfgFile", "OutputDir", "AudioFormat", "ImageAPI", "ImageAPISpecified", "BatchFile", + "CfgFile", "OutputDir", "AudioFormat", "AudioProvider", "ImageAPI", "ImageAPISpecified", "BatchFile", "SkipAudio", "SkipImages", "GenerateAnki", "AnkiCSV", "DeckName", "ListModels", "AllVoices", "NoAutoPlay", "OpenAIModel", "OpenAIVoice", "OpenAISpeed", "OpenAIInstruction", "OpenAIImageModel", "OpenAIImageSize", "OpenAIImageQuality", "OpenAIImageStyle", + "GeminiTTSModel", "GeminiVoice", "NanoBananaModel", "NanoBananaModelSpecified", "NanoBananaTextModel", "NanoBananaTextModelSpecified", } 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 = ©Config + 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") |
