summaryrefslogtreecommitdiff
path: root/internal/processor
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-01 21:28:31 +0300
committerPaul Buetow <paul@buetow.org>2026-04-01 21:28:31 +0300
commit5a9e7b4795e08489cfaab264b92cac51bc275d1f (patch)
treed554c6846e29ceb363ecaadbe305a2a5a51fbb8c /internal/processor
parentd1c6d860214be8895f4ed90ff74bff664fbb4422 (diff)
Fix Gemini audio format handling
Diffstat (limited to 'internal/processor')
-rw-r--r--internal/processor/processor.go77
-rw-r--r--internal/processor/processor_test.go104
2 files changed, 168 insertions, 13 deletions
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) {