summaryrefslogtreecommitdiff
path: root/internal
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
parentaeeb3c6ffea828773f849b88d97c5ffead706a97 (diff)
Add Gemini audio CLI flags
Diffstat (limited to 'internal')
-rw-r--r--internal/cli/command.go20
-rw-r--r--internal/cli/command_test.go48
-rw-r--r--internal/cli/flags.go20
-rw-r--r--internal/cli/flags_test.go8
-rw-r--r--internal/processor/processor.go190
-rw-r--r--internal/processor/processor_test.go59
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 = &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")