From 5a9e7b4795e08489cfaab264b92cac51bc275d1f Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 1 Apr 2026 21:28:31 +0300 Subject: Fix Gemini audio format handling --- internal/processor/processor.go | 77 +++++++++++++++++++++----- internal/processor/processor_test.go | 104 +++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 13 deletions(-) (limited to 'internal/processor') diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 1a0155c..a3fc64b 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -251,6 +251,20 @@ func (p *Processor) audioProviderName() string { return "" } +func (p *Processor) effectiveAudioFormat() string { + if p.audioProviderName() == "gemini" { + return "wav" + } + + if p != nil && p.flags != nil { + if format := strings.ToLower(strings.TrimSpace(p.flags.AudioFormat)); format != "" { + return format + } + } + + return "mp3" +} + func (p *Processor) geminiTTSModel() string { if model := strings.TrimSpace(viper.GetString("audio.gemini_tts_model")); model != "" { return model @@ -371,6 +385,7 @@ 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() + audioFormat := p.effectiveAudioFormat() // Generate random speed between 0.90 and 1.00 if not explicitly set speed := p.flags.OpenAISpeed @@ -388,7 +403,7 @@ func (p *Processor) generateAudioWithVoiceAndFilenameInDir(word, voice, filename switch audioProvider { case "gemini": - providerConfig.OutputFormat = "wav" + providerConfig.OutputFormat = audioFormat providerConfig.GeminiTTSModel = p.geminiTTSModel() if voice != "" { providerConfig.GeminiVoice = voice @@ -397,7 +412,7 @@ func (p *Processor) generateAudioWithVoiceAndFilenameInDir(word, voice, filename } providerConfig.GeminiSpeed = 1.0 default: - providerConfig.OutputFormat = p.flags.AudioFormat + providerConfig.OutputFormat = audioFormat providerConfig.OpenAIModel = p.flags.OpenAIModel providerConfig.OpenAIVoice = voice providerConfig.OpenAISpeed = speed @@ -425,10 +440,7 @@ 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" - } + outputFormat := providerConfig.OutputFormat var outputFile string if p.flags.AllVoices && filenameBase == "audio" { outputFile = filepath.Join(wordDir, fmt.Sprintf("%s_%s.%s", filenameBase, voice, outputFormat)) @@ -505,11 +517,12 @@ func (p *Processor) GenerateAnkiFile() (string, error) { } // Create Anki generator + audioFormat := p.effectiveAudioFormat() gen := anki.NewGenerator(&anki.GeneratorOptions{ OutputPath: filepath.Join(outputDir, "anki_import.csv"), MediaFolder: p.flags.OutputDir, IncludeHeaders: true, - AudioFormat: p.flags.AudioFormat, + AudioFormat: audioFormat, }) // Use the translation cache as the source of truth for cards @@ -532,7 +545,7 @@ func (p *Processor) GenerateAnkiFile() (string, error) { wordDir := p.findCardDirectory(bulgarian) if wordDir != "" { // Look for audio file - audioFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", p.flags.AudioFormat)) + audioFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", audioFormat)) if _, err := os.Stat(audioFile); err == nil { card.AudioFile = audioFile } @@ -790,11 +803,12 @@ func (p *Processor) isWordFullyProcessed(word string) bool { if !p.flags.SkipAudio { // Load card type to determine required audio files cardType := internal.LoadCardType(wordDir) + audioFormat := p.effectiveAudioFormat() if cardType.IsBgBg() { // For bg-bg cards, check for audio_front and audio_back - frontAudio := filepath.Join(wordDir, fmt.Sprintf("audio_front.%s", p.flags.AudioFormat)) - backAudio := filepath.Join(wordDir, fmt.Sprintf("audio_back.%s", p.flags.AudioFormat)) + frontAudio := filepath.Join(wordDir, fmt.Sprintf("audio_front.%s", audioFormat)) + backAudio := filepath.Join(wordDir, fmt.Sprintf("audio_back.%s", audioFormat)) if _, err := os.Stat(frontAudio); os.IsNotExist(err) { if os.Getenv("DEBUG_BATCH") != "" { fmt.Printf(" [DEBUG] No front audio file found: %s\n", frontAudio) @@ -814,9 +828,9 @@ func (p *Processor) isWordFullyProcessed(word string) bool { "audio_metadata.txt", ) - audioFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", p.flags.AudioFormat)) + audioFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", audioFormat)) if _, err := os.Stat(audioFile); os.IsNotExist(err) { - audioPattern := fmt.Sprintf("audio_*.%s", p.flags.AudioFormat) + audioPattern := fmt.Sprintf("audio_*.%s", audioFormat) matches, _ := filepath.Glob(filepath.Join(wordDir, audioPattern)) if len(matches) == 0 { if os.Getenv("DEBUG_BATCH") != "" { @@ -918,7 +932,7 @@ func (p *Processor) saveAudioAttribution(word, audioFile string, config *audio.C // Also save metadata for GUI display wordDir := filepath.Dir(audioFile) metadataFile := filepath.Join(wordDir, "audio_metadata.txt") - metadata := fmt.Sprintf("voice=%s\nspeed=%.2f\n", config.OpenAIVoice, config.OpenAISpeed) + metadata := p.buildAudioMetadata(config) if err := os.WriteFile(metadataFile, []byte(metadata), 0644); err != nil { // Non-fatal error, just log it fmt.Printf("Warning: Failed to save audio metadata: %v\n", err) @@ -926,3 +940,40 @@ func (p *Processor) saveAudioAttribution(word, audioFile string, config *audio.C return nil } + +func (p *Processor) buildAudioMetadata(config *audio.Config) string { + var b strings.Builder + provider := strings.ToLower(strings.TrimSpace(config.Provider)) + if provider == "" { + provider = "openai" + } + + fmt.Fprintf(&b, "provider=%s\n", provider) + switch provider { + case "gemini": + fmt.Fprintf(&b, "model=%s\n", config.GeminiTTSModel) + voice := strings.TrimSpace(config.GeminiVoice) + if voice == "" { + voice = "model-default" + } + fmt.Fprintf(&b, "voice=%s\n", voice) + fmt.Fprintf(&b, "speed=%.2f\n", config.GeminiSpeed) + default: + fmt.Fprintf(&b, "model=%s\n", config.OpenAIModel) + voice := strings.TrimSpace(config.OpenAIVoice) + if voice != "" { + fmt.Fprintf(&b, "voice=%s\n", voice) + } + fmt.Fprintf(&b, "speed=%.2f\n", config.OpenAISpeed) + if instruction := strings.TrimSpace(config.OpenAIInstruction); instruction != "" { + fmt.Fprintf(&b, "instruction=%s\n", instruction) + } + } + format := strings.TrimSpace(config.OutputFormat) + if format == "" { + format = p.effectiveAudioFormat() + } + fmt.Fprintf(&b, "format=%s\n", format) + + return b.String() +} diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go index 9a301cb..aa932e5 100644 --- a/internal/processor/processor_test.go +++ b/internal/processor/processor_test.go @@ -3,6 +3,7 @@ package processor import ( "context" "errors" + "fmt" "io" "os" "path/filepath" @@ -447,6 +448,109 @@ func TestGenerateAudioUsesConfiguredGeminiVoiceAndModel(t *testing.T) { if !strings.HasSuffix(fakeProvider.lastOutputFile, "audio.wav") { t.Fatalf("GenerateAudio() output file = %q, want wav output", fakeProvider.lastOutputFile) } + + wordDir := p.findCardDirectory("ябълка") + if wordDir == "" { + t.Fatal("expected generated word directory") + } + metadataData, err := os.ReadFile(filepath.Join(wordDir, "audio_metadata.txt")) + if err != nil { + t.Fatalf("expected metadata file: %v", err) + } + metadata := string(metadataData) + for _, want := range []string{ + "provider=gemini", + "model=gemini-2.5-flash-preview-tts", + "voice=Kore", + "speed=1.00", + "format=wav", + } { + if !strings.Contains(metadata, want) { + t.Fatalf("metadata = %q, missing %q", metadata, want) + } + } +} + +func TestGenerateAnkiFileUsesEffectiveAudioFormatForGemini(t *testing.T) { + originalConfig := viper.New() + *originalConfig = *viper.GetViper() + defer func() { + *viper.GetViper() = *originalConfig + }() + viper.Reset() + viper.Set("audio.provider", "gemini") + + tempDir := t.TempDir() + flags := cli.NewFlags() + flags.OutputDir = tempDir + flags.AudioProvider = "gemini" + flags.AudioFormat = "mp3" + flags.AnkiCSV = true + + p := NewProcessor(flags) + p.translationCache.Add("ябълка", "apple") + + wordDir := p.findOrCreateWordDirectory("ябълка") + if err := os.WriteFile(filepath.Join(wordDir, "audio.wav"), []byte("audio data"), 0644); err != nil { + t.Fatalf("failed to create wav audio file: %v", err) + } + if err := os.WriteFile(filepath.Join(wordDir, "phonetic.txt"), []byte("phonetic"), 0644); err != nil { + t.Fatalf("failed to create phonetic file: %v", err) + } + + outputPath, err := p.GenerateAnkiFile() + if err != nil { + t.Fatalf("GenerateAnkiFile() unexpected error: %v", err) + } + if !strings.HasSuffix(outputPath, "anki_import.csv") { + t.Fatalf("GenerateAnkiFile() output = %q, want CSV output", outputPath) + } + + csvData, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("failed to read generated CSV: %v", err) + } + cardID := filepath.Base(wordDir) + if !strings.Contains(string(csvData), fmt.Sprintf("[sound:%s_audio.wav]", cardID)) { + t.Fatalf("generated CSV did not reference wav audio for card %q: %s", cardID, csvData) + } +} + +func TestIsWordFullyProcessedUsesEffectiveAudioFormatForGemini(t *testing.T) { + originalConfig := viper.New() + *originalConfig = *viper.GetViper() + defer func() { + *viper.GetViper() = *originalConfig + }() + viper.Reset() + viper.Set("audio.provider", "gemini") + + flags := cli.NewFlags() + flags.OutputDir = t.TempDir() + flags.AudioProvider = "gemini" + flags.AudioFormat = "mp3" + flags.SkipImages = true + + p := NewProcessor(flags) + wordDir := p.findOrCreateWordDirectory("ябълка") + files := map[string]string{ + "translation.txt": "ябълка = apple", + "phonetic.txt": "phonetic", + "audio_metadata.txt": "provider=gemini\nmodel=gemini-2.5-flash-preview-tts\nvoice=Kore\nspeed=1.00\nformat=wav\n", + "audio_attribution.txt": "attribution", + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(wordDir, name), []byte(content), 0644); err != nil { + t.Fatalf("failed to create %s: %v", name, err) + } + } + if err := os.WriteFile(filepath.Join(wordDir, "audio.wav"), []byte("audio data"), 0644); err != nil { + t.Fatalf("failed to create wav audio file: %v", err) + } + + if !p.isWordFullyProcessed("ябълка") { + t.Fatal("expected Gemini word with wav audio to be treated as fully processed") + } } func TestDownloadImagesWithTranslationUsesNanoBananaConfigAndSavesPrompt(t *testing.T) { -- cgit v1.2.3