From ec8614a94b63cd58ae4aaeeac27f9c6d0612b215 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 1 Apr 2026 21:47:16 +0300 Subject: Fix multi-voice audio path resolution --- internal/anki/audio_paths.go | 89 ++++++++++++++++++++++++++++++++++++ internal/anki/audio_paths_test.go | 32 +++++++++++++ internal/anki/generator.go | 27 ++--------- internal/anki/generator_test.go | 36 +++++++++++++++ internal/processor/processor.go | 52 +++++++++++---------- internal/processor/processor_test.go | 39 +++++++++------- 6 files changed, 213 insertions(+), 62 deletions(-) create mode 100644 internal/anki/audio_paths.go create mode 100644 internal/anki/audio_paths_test.go diff --git a/internal/anki/audio_paths.go b/internal/anki/audio_paths.go new file mode 100644 index 0000000..ca418cf --- /dev/null +++ b/internal/anki/audio_paths.go @@ -0,0 +1,89 @@ +package anki + +import ( + "os" + "path/filepath" + "sort" + "strings" +) + +// ResolveAudioPaths returns the matching audio files for a logical base name. +// It prefers multi-voice outputs (audio_.) over a stale single file +// and uses metadata hints before falling back to common formats. +func ResolveAudioPaths(wordDir, baseName, preferredFormat string) []string { + formats := audioFormatsToTry(wordDir, preferredFormat) + + // Prefer voice-specific files so multi-voice output wins over any stale + // single-file audio that may be left behind in the directory. + for _, format := range formats { + globPattern := filepath.Join(wordDir, baseName+"_*."+format) + matches, err := filepath.Glob(globPattern) + if err == nil && len(matches) > 0 { + sort.Strings(matches) + return matches + } + } + + for _, format := range formats { + exactPath := filepath.Join(wordDir, baseName+"."+format) + if fileExists(exactPath) { + return []string{exactPath} + } + } + + return nil +} + +// ResolveAudioFile returns the first resolved audio file for a logical base name. +func ResolveAudioFile(wordDir, baseName, preferredFormat string) string { + paths := ResolveAudioPaths(wordDir, baseName, preferredFormat) + if len(paths) == 0 { + return "" + } + + return paths[0] +} + +func audioFormatsToTry(wordDir, preferredFormat string) []string { + var candidates []string + appendFormat := func(format string) { + format = strings.ToLower(strings.TrimSpace(format)) + if format == "" || containsString(candidates, format) { + return + } + candidates = append(candidates, format) + } + + appendFormat(readAudioFormatHint(wordDir)) + appendFormat(preferredFormat) + appendFormat("wav") + appendFormat("mp3") + + return candidates +} + +func readAudioFormatHint(wordDir string) string { + metadataFile := filepath.Join(wordDir, "audio_metadata.txt") + data, err := os.ReadFile(metadataFile) + if err != nil { + return "" + } + + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "format=") { + return strings.TrimSpace(strings.TrimPrefix(line, "format=")) + } + } + + return "" +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/internal/anki/audio_paths_test.go b/internal/anki/audio_paths_test.go new file mode 100644 index 0000000..394347d --- /dev/null +++ b/internal/anki/audio_paths_test.go @@ -0,0 +1,32 @@ +package anki + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolveAudioFilePrefersVoiceSpecificFilesOverStaleSingleFile(t *testing.T) { + tempDir := t.TempDir() + wordDir := filepath.Join(tempDir, "ябълка") + if err := os.MkdirAll(wordDir, 0755); err != nil { + t.Fatalf("failed to create word dir: %v", err) + } + + files := map[string]string{ + "audio.mp3": "stale audio", + "audio_alpha.wav": "voice alpha audio", + "audio_beta.wav": "voice beta audio", + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(wordDir, name), []byte(content), 0644); err != nil { + t.Fatalf("failed to write %s: %v", name, err) + } + } + + got := ResolveAudioFile(wordDir, "audio", "mp3") + if !strings.HasSuffix(got, "audio_alpha.wav") { + t.Fatalf("ResolveAudioFile() = %q, want voice-specific wav file", got) + } +} diff --git a/internal/anki/generator.go b/internal/anki/generator.go index 07ea5df..7a1c0dc 100644 --- a/internal/anki/generator.go +++ b/internal/anki/generator.go @@ -202,28 +202,11 @@ func (g *Generator) GenerateFromDirectory(dir string) error { } // Look for audio file(s) - audioFormats := []string{"mp3", "wav"} - for _, format := range audioFormats { - // For bg-bg cards, look for audio_front and audio_back - if cardType.IsBgBg() { - frontAudio := filepath.Join(wordDir, fmt.Sprintf("audio_front.%s", format)) - backAudio := filepath.Join(wordDir, fmt.Sprintf("audio_back.%s", format)) - if _, err := os.Stat(frontAudio); err == nil { - card.AudioFile = frontAudio - } - if _, err := os.Stat(backAudio); err == nil { - card.AudioFileBack = backAudio - } - if card.AudioFile != "" { - break - } - } - // For en-bg cards (or fallback), look for standard audio file - audioFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", format)) - if _, err := os.Stat(audioFile); err == nil { - card.AudioFile = audioFile - break - } + if cardType.IsBgBg() { + card.AudioFile = ResolveAudioFile(wordDir, "audio_front", "") + card.AudioFileBack = ResolveAudioFile(wordDir, "audio_back", "") + } else { + card.AudioFile = ResolveAudioFile(wordDir, "audio", "") } // Look for image files diff --git a/internal/anki/generator_test.go b/internal/anki/generator_test.go index 685f864..5966f07 100644 --- a/internal/anki/generator_test.go +++ b/internal/anki/generator_test.go @@ -393,6 +393,42 @@ func TestGenerateFromDirectory(t *testing.T) { } } +func TestGenerateFromDirectoryPrefersMultiVoiceAudioFiles(t *testing.T) { + tempDir := t.TempDir() + + wordDir := filepath.Join(tempDir, "ябълка") + if err := os.MkdirAll(wordDir, 0755); err != nil { + t.Fatalf("Failed to create word dir: %v", err) + } + + files := map[string]string{ + "word.txt": "ябълка", + "translation.txt": "ябълка = apple", + "phonetic.txt": "phonetic", + "audio.mp3": "stale audio", + "audio_alpha.wav": "audio data", + "audio_beta.wav": "audio data", + "audio_metadata.txt": "provider=gemini\nmodel=gemini-2.5-flash-preview-tts\nvoice=Kore\nspeed=1.00\nformat=wav\n", + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(wordDir, name), []byte(content), 0644); err != nil { + t.Fatalf("Failed to write %s: %v", name, err) + } + } + + gen := NewGenerator(nil) + if err := gen.GenerateFromDirectory(tempDir); err != nil { + t.Fatalf("GenerateFromDirectory() error = %v", err) + } + + if len(gen.cards) != 1 { + t.Fatalf("Expected 1 card, got %d", len(gen.cards)) + } + if !strings.HasSuffix(gen.cards[0].AudioFile, "audio_alpha.wav") { + t.Fatalf("Expected multi-voice wav selection, got %q", gen.cards[0].AudioFile) + } +} + func TestCopyMediaFile(t *testing.T) { tempDir := t.TempDir() diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 66b975e..813d5b3 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -550,10 +550,12 @@ func (p *Processor) GenerateAnkiFile() (string, error) { // Find associated media files in the output directory wordDir := p.findCardDirectory(bulgarian) if wordDir != "" { - // Look for audio file - audioFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", audioFormat)) - if _, err := os.Stat(audioFile); err == nil { - card.AudioFile = audioFile + cardType := internal.LoadCardType(wordDir) + if cardType.IsBgBg() { + card.AudioFile = anki.ResolveAudioFile(wordDir, "audio_front", audioFormat) + card.AudioFileBack = anki.ResolveAudioFile(wordDir, "audio_back", audioFormat) + } else { + card.AudioFile = anki.ResolveAudioFile(wordDir, "audio", audioFormat) } // Look for image file @@ -812,35 +814,37 @@ func (p *Processor) isWordFullyProcessed(word string) bool { 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", audioFormat)) - backAudio := filepath.Join(wordDir, fmt.Sprintf("audio_back.%s", audioFormat)) - if _, err := os.Stat(frontAudio); os.IsNotExist(err) { + frontAudioFiles := anki.ResolveAudioPaths(wordDir, "audio_front", audioFormat) + backAudioFiles := anki.ResolveAudioPaths(wordDir, "audio_back", audioFormat) + if len(frontAudioFiles) == 0 || len(backAudioFiles) == 0 { if os.Getenv("DEBUG_BATCH") != "" { - fmt.Printf(" [DEBUG] No front audio file found: %s\n", frontAudio) + fmt.Printf(" [DEBUG] No bg-bg audio files found in %s\n", wordDir) } return false } - if _, err := os.Stat(backAudio); os.IsNotExist(err) { + for _, audioFile := range append(frontAudioFiles, backAudioFiles...) { + if _, err := os.Stat(audio.AttributionPath(audioFile)); os.IsNotExist(err) { + if os.Getenv("DEBUG_BATCH") != "" { + fmt.Printf(" [DEBUG] Missing attribution for audio file: %s\n", audioFile) + } + return false + } + } + } else { + // For en-bg cards, check for at least one resolved audio file and its metadata. + requiredFiles = append(requiredFiles, "audio_metadata.txt") + + audioFiles := anki.ResolveAudioPaths(wordDir, "audio", audioFormat) + if len(audioFiles) == 0 { if os.Getenv("DEBUG_BATCH") != "" { - fmt.Printf(" [DEBUG] No back audio file found: %s\n", backAudio) + fmt.Printf(" [DEBUG] No audio files found in %s\n", wordDir) } return false } - } else { - // For en-bg cards, check for standard audio file - requiredFiles = append(requiredFiles, - "audio_attribution.txt", - "audio_metadata.txt", - ) - - audioFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", audioFormat)) - if _, err := os.Stat(audioFile); os.IsNotExist(err) { - audioPattern := fmt.Sprintf("audio_*.%s", audioFormat) - matches, _ := filepath.Glob(filepath.Join(wordDir, audioPattern)) - if len(matches) == 0 { + for _, audioFile := range audioFiles { + if _, err := os.Stat(audio.AttributionPath(audioFile)); os.IsNotExist(err) { if os.Getenv("DEBUG_BATCH") != "" { - fmt.Printf(" [DEBUG] No audio file found: %s or pattern %s\n", audioFile, audioPattern) + fmt.Printf(" [DEBUG] Missing attribution for audio file: %s\n", audioFile) } return false } diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go index 35ceb9b..77a9344 100644 --- a/internal/processor/processor_test.go +++ b/internal/processor/processor_test.go @@ -555,11 +555,18 @@ func TestGenerateAnkiFileUsesEffectiveAudioFormatForGemini(t *testing.T) { 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) + for name, content := range map[string]string{ + "audio_metadata.txt": "provider=gemini\nmodel=gemini-2.5-flash-preview-tts\nvoice=Kore\nspeed=1.00\nformat=wav\n", + "phonetic.txt": "phonetic", + "audio_alpha.wav": "audio data", + "audio_beta.wav": "audio data", + "audio_alpha_attribution.txt": "attribution", + "audio_beta_attribution.txt": "attribution", + "translation.txt": "ябълка = apple", + } { + if err := os.WriteFile(filepath.Join(wordDir, name), []byte(content), 0644); err != nil { + t.Fatalf("failed to create %s: %v", name, err) + } } outputPath, err := p.GenerateAnkiFile() @@ -575,12 +582,12 @@ func TestGenerateAnkiFileUsesEffectiveAudioFormatForGemini(t *testing.T) { 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) + if !strings.Contains(string(csvData), fmt.Sprintf("[sound:%s_audio_alpha.wav]", cardID)) { + t.Fatalf("generated CSV did not reference the resolved multi-voice wav audio for card %q: %s", cardID, csvData) } } -func TestIsWordFullyProcessedUsesEffectiveAudioFormatForGemini(t *testing.T) { +func TestIsWordFullyProcessedUsesMultiVoiceAttributionFiles(t *testing.T) { originalConfig := viper.New() *originalConfig = *viper.GetViper() defer func() { @@ -598,22 +605,22 @@ func TestIsWordFullyProcessedUsesEffectiveAudioFormatForGemini(t *testing.T) { 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", + "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_alpha.wav": "audio data", + "audio_beta.wav": "audio data", + "audio_alpha_attribution.txt": "attribution", + "audio_beta_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") + t.Fatal("expected Gemini word with multi-voice wav audio to be treated as fully processed") } } -- cgit v1.2.3