diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-02 07:16:49 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-02 07:16:49 +0300 |
| commit | e96cc2578595a0ec22a07819cd0eb1b0b0c711f7 (patch) | |
| tree | 0df43845dedeb27ba2afaadfb5a821aeee7a713b | |
| parent | faa2465955445ac1cb1461eb70a98280d32fbf9a (diff) | |
Harden GUI audio loading for Gemini
| -rw-r--r-- | internal/gui/app.go | 7 | ||||
| -rw-r--r-- | internal/gui/audio_paths.go | 74 | ||||
| -rw-r--r-- | internal/gui/audio_paths_test.go | 99 | ||||
| -rw-r--r-- | internal/gui/generator.go | 17 | ||||
| -rw-r--r-- | internal/gui/generator_test.go | 37 | ||||
| -rw-r--r-- | internal/gui/navigation.go | 58 |
6 files changed, 249 insertions, 43 deletions
diff --git a/internal/gui/app.go b/internal/gui/app.go index a96c627..05955cf 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -116,7 +116,7 @@ type Config struct { GoogleAPIKey string // GeminiTTSModel selects the Gemini TTS model when Gemini audio is active. GeminiTTSModel string - // GeminiVoice selects a specific Gemini voice; empty uses a random shared voice. + // GeminiVoice selects a specific Gemini voice; empty uses the model default. GeminiVoice string TranslationProvider translation.Provider PhoneticProvider phonetic.Provider @@ -1925,6 +1925,11 @@ func (a *Application) clearUI() { a.imageDisplay.Clear() a.audioPlayer.Clear() + a.currentAudioFile = "" + a.currentAudioFileBack = "" + a.currentImage = "" + a.currentTranslation = "" + a.currentCardType = "" // Don't clear the word input or translation entry - they should stay populated // Clear the image prompt entry - it will be loaded from disk if available a.imagePromptEntry.SetText("") diff --git a/internal/gui/audio_paths.go b/internal/gui/audio_paths.go new file mode 100644 index 0000000..baee232 --- /dev/null +++ b/internal/gui/audio_paths.go @@ -0,0 +1,74 @@ +package gui + +import ( + "os" + "path/filepath" + "strings" + "time" +) + +var supportedAudioExtensions = map[string]struct{}{ + ".aac": {}, + ".flac": {}, + ".mp3": {}, + ".opus": {}, + ".wav": {}, +} + +func (a *Application) resolveSingleAudioFile(wordDir string) string { + return resolveAudioFileByBaseName(wordDir, "audio") +} + +func (a *Application) resolveBgBgAudioFiles(wordDir string) (string, string) { + return resolveAudioFileByBaseName(wordDir, "audio_front"), resolveAudioFileByBaseName(wordDir, "audio_back") +} + +func (a *Application) hasAnyAudioFile(wordDir string) bool { + single := a.resolveSingleAudioFile(wordDir) + if single != "" { + return true + } + + front, back := a.resolveBgBgAudioFiles(wordDir) + return front != "" || back != "" +} + +func resolveAudioFileByBaseName(wordDir, baseName string) string { + entries, err := os.ReadDir(wordDir) + if err != nil { + return "" + } + + prefix := baseName + "." + var resolved string + var resolvedModTime time.Time + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + if !strings.HasPrefix(name, prefix) { + continue + } + + ext := strings.ToLower(filepath.Ext(name)) + if _, ok := supportedAudioExtensions[ext]; !ok { + continue + } + + info, err := entry.Info() + if err != nil { + continue + } + + candidate := filepath.Join(wordDir, name) + if resolved == "" || info.ModTime().After(resolvedModTime) || (info.ModTime().Equal(resolvedModTime) && candidate < resolved) { + resolved = candidate + resolvedModTime = info.ModTime() + } + } + + return resolved +} diff --git a/internal/gui/audio_paths_test.go b/internal/gui/audio_paths_test.go new file mode 100644 index 0000000..1251c00 --- /dev/null +++ b/internal/gui/audio_paths_test.go @@ -0,0 +1,99 @@ +package gui + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestResolveSingleAudioFileFindsLegacyMp3WhenGuiDefaultIsWav(t *testing.T) { + tempDir := t.TempDir() + wordDir := filepath.Join(tempDir, "word") + if err := os.MkdirAll(wordDir, 0755); err != nil { + t.Fatalf("failed to create word dir: %v", err) + } + + mp3Path := filepath.Join(wordDir, "audio.mp3") + if err := os.WriteFile(mp3Path, []byte("mp3"), 0644); err != nil { + t.Fatalf("failed to write mp3 file: %v", err) + } + + app := &Application{ + config: &Config{AudioFormat: "wav"}, + } + + got := app.resolveSingleAudioFile(wordDir) + if got != mp3Path { + t.Fatalf("resolveSingleAudioFile() = %q, want %q", got, mp3Path) + } +} + +func TestResolveSingleAudioFilePrefersNewerOnDiskAudio(t *testing.T) { + tempDir := t.TempDir() + wordDir := filepath.Join(tempDir, "word") + if err := os.MkdirAll(wordDir, 0755); err != nil { + t.Fatalf("failed to create word dir: %v", err) + } + + mp3Path := filepath.Join(wordDir, "audio.mp3") + wavPath := filepath.Join(wordDir, "audio.wav") + if err := os.WriteFile(mp3Path, []byte("mp3"), 0644); err != nil { + t.Fatalf("failed to write mp3 file: %v", err) + } + if err := os.WriteFile(wavPath, []byte("wav"), 0644); err != nil { + t.Fatalf("failed to write wav file: %v", err) + } + + older := time.Now().Add(-time.Hour) + newer := time.Now() + if err := os.Chtimes(mp3Path, older, older); err != nil { + t.Fatalf("failed to set mp3 file time: %v", err) + } + if err := os.Chtimes(wavPath, newer, newer); err != nil { + t.Fatalf("failed to set wav file time: %v", err) + } + + app := &Application{ + config: &Config{AudioFormat: "wav"}, + } + + got := app.resolveSingleAudioFile(wordDir) + if got != wavPath { + t.Fatalf("resolveSingleAudioFile() = %q, want newer wav %q", got, wavPath) + } +} + +func TestResolveBgBgAudioFilesFindLegacyMp3Files(t *testing.T) { + tempDir := t.TempDir() + wordDir := filepath.Join(tempDir, "word") + if err := os.MkdirAll(wordDir, 0755); err != nil { + t.Fatalf("failed to create word dir: %v", err) + } + + frontPath := filepath.Join(wordDir, "audio_front.mp3") + backPath := filepath.Join(wordDir, "audio_back.mp3") + if err := os.WriteFile(frontPath, []byte("front"), 0644); err != nil { + t.Fatalf("failed to write front file: %v", err) + } + if err := os.WriteFile(backPath, []byte("back"), 0644); err != nil { + t.Fatalf("failed to write back file: %v", err) + } + + older := time.Now().Add(-time.Hour) + if err := os.Chtimes(frontPath, older, older); err != nil { + t.Fatalf("failed to set front file time: %v", err) + } + + app := &Application{ + config: &Config{AudioFormat: "wav"}, + } + + gotFront, gotBack := app.resolveBgBgAudioFiles(wordDir) + if gotFront != frontPath { + t.Fatalf("resolveBgBgAudioFiles() front = %q, want %q", gotFront, frontPath) + } + if gotBack != backPath { + t.Fatalf("resolveBgBgAudioFiles() back = %q, want %q", gotBack, backPath) + } +} diff --git a/internal/gui/generator.go b/internal/gui/generator.go index 6d1c883..1b9548a 100644 --- a/internal/gui/generator.go +++ b/internal/gui/generator.go @@ -62,11 +62,9 @@ func (a *Application) audioVoiceAndSpeed() (string, float64) { switch a.audioProviderName() { case "gemini": if a.audioConfig != nil { - if voice := strings.TrimSpace(a.audioConfig.GeminiVoice); voice != "" { - return voice, a.geminiSpeed() - } + return strings.TrimSpace(a.audioConfig.GeminiVoice), a.geminiSpeed() } - return randomVoice(a.audioVoices()), a.geminiSpeed() + return "", a.geminiSpeed() default: return randomVoice(a.audioVoices()), randomOpenAISpeed() } @@ -224,6 +222,10 @@ func (a *Application) generateAudioFront(ctx context.Context, word string, cardD } fmt.Printf("DEBUG (generateAudioFront): Successfully wrote front audio to: %s\n", frontFile) + if err := a.saveAudioAttribution(word, frontFile, voice, speed); err != nil { + fmt.Printf("Warning: Failed to save audio attribution: %v\n", err) + } + // Update metadata metadataFile := filepath.Join(cardDir, "audio_metadata.txt") metadata := fmt.Sprintf("voice=%s\nspeed=%.2f\ncardtype=bg-bg\n", voice, speed) @@ -261,6 +263,10 @@ func (a *Application) generateAudioBack(ctx context.Context, text string, cardDi } fmt.Printf("DEBUG (generateAudioBack): Successfully wrote back audio to: %s\n", backFile) + if err := a.saveAudioAttribution(text, backFile, voice, speed); err != nil { + fmt.Printf("Warning: Failed to save audio attribution: %v\n", err) + } + return backFile, nil } @@ -296,6 +302,9 @@ func (a *Application) generateAudioBgBg(ctx context.Context, front, back, cardDi if err := a.saveAudioAttribution(front, frontFile, voice, speed); err != nil { fmt.Printf("Warning: Failed to save audio attribution: %v\n", err) } + if err := a.saveAudioAttribution(back, backFile, voice, speed); err != nil { + fmt.Printf("Warning: Failed to save audio attribution: %v\n", err) + } // Save voice metadata metadataFile := filepath.Join(cardDir, "audio_metadata.txt") diff --git a/internal/gui/generator_test.go b/internal/gui/generator_test.go index 635b990..be0c254 100644 --- a/internal/gui/generator_test.go +++ b/internal/gui/generator_test.go @@ -220,7 +220,7 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) { } } -func TestGenerateAudioUsesSharedGeminiVoicesAndAttribution(t *testing.T) { +func TestGenerateAudioUsesGeminiModelDefaultVoiceAndAttribution(t *testing.T) { originalFactory := newAudioProvider t.Cleanup(func() { newAudioProvider = originalFactory @@ -257,6 +257,7 @@ func TestGenerateAudioUsesSharedGeminiVoicesAndAttribution(t *testing.T) { OutputDir: tempDir, GoogleAPIKey: "google-key", GeminiTTSModel: "gemini-2.5-flash-preview-tts", + GeminiVoice: "", }, } @@ -271,8 +272,8 @@ func TestGenerateAudioUsesSharedGeminiVoicesAndAttribution(t *testing.T) { if capturedConfig.Provider != "gemini" { t.Fatalf("captured Provider = %q, want %q", capturedConfig.Provider, "gemini") } - if capturedConfig.GeminiVoice != "sentinel-gemini-voice" { - t.Fatalf("captured GeminiVoice = %q, want %q", capturedConfig.GeminiVoice, "sentinel-gemini-voice") + if capturedConfig.GeminiVoice != "" { + t.Fatalf("captured GeminiVoice = %q, want empty model-default voice", capturedConfig.GeminiVoice) } if capturedConfig.OutputFormat != "wav" { t.Fatalf("captured OutputFormat = %q, want %q", capturedConfig.OutputFormat, "wav") @@ -293,8 +294,8 @@ func TestGenerateAudioUsesSharedGeminiVoicesAndAttribution(t *testing.T) { if !strings.Contains(attribution, "Audio generated by Google Gemini TTS") { t.Fatalf("gemini attribution missing header: %q", attribution) } - if !strings.Contains(attribution, "Voice: sentinel-gemini-voice") { - t.Fatalf("gemini attribution missing voice: %q", attribution) + if strings.Contains(attribution, "sentinel-gemini-voice") { + t.Fatalf("gemini attribution should not use the shared voice list when voice is unset: %q", attribution) } } @@ -432,6 +433,19 @@ func TestGenerateAudioFrontUsesSharedOpenAIVoices(t *testing.T) { if !strings.HasSuffix(outputPath, "audio_front.mp3") { t.Fatalf("outputPath = %q, want audio_front.mp3", outputPath) } + + attrPath := audio.AttributionPath(outputPath) + attributionData, err := os.ReadFile(attrPath) + if err != nil { + t.Fatalf("expected attribution file %q: %v", attrPath, err) + } + attribution := string(attributionData) + if !strings.Contains(attribution, "Audio generated by OpenAI TTS") { + t.Fatalf("front attribution missing header: %q", attribution) + } + if !strings.Contains(attribution, "Voice: sentinel-front-voice") { + t.Fatalf("front attribution missing voice: %q", attribution) + } } func TestGenerateAudioBackUsesSharedOpenAIVoices(t *testing.T) { @@ -497,6 +511,19 @@ func TestGenerateAudioBackUsesSharedOpenAIVoices(t *testing.T) { if !strings.HasSuffix(outputPath, "audio_back.mp3") { t.Fatalf("outputPath = %q, want audio_back.mp3", outputPath) } + + attrPath := audio.AttributionPath(outputPath) + attributionData, err := os.ReadFile(attrPath) + if err != nil { + t.Fatalf("expected attribution file %q: %v", attrPath, err) + } + attribution := string(attributionData) + if !strings.Contains(attribution, "Audio generated by OpenAI TTS") { + t.Fatalf("back attribution missing header: %q", attribution) + } + if !strings.Contains(attribution, "Bulgarian word: круша") { + t.Fatalf("back attribution missing spoken text: %q", attribution) + } } func TestGenerateAudioProviderFactoryError(t *testing.T) { diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go index 59b5492..cc1d9e6 100644 --- a/internal/gui/navigation.go +++ b/internal/gui/navigation.go @@ -95,22 +95,10 @@ func (a *Application) scanExistingWords() { hasContent := false // Check for audio file (both en-bg and bg-bg formats) - audioFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", a.config.AudioFormat)) - if _, err := os.Stat(audioFile); err == nil { + if a.hasAnyAudioFile(wordDir) { hasContent = true } - // Check for bg-bg audio files (audio_front and audio_back) - if !hasContent { - frontAudio := filepath.Join(wordDir, fmt.Sprintf("audio_front.%s", a.config.AudioFormat)) - backAudio := filepath.Join(wordDir, fmt.Sprintf("audio_back.%s", a.config.AudioFormat)) - if _, errFront := os.Stat(frontAudio); errFront == nil { - hasContent = true - } else if _, errBack := os.Stat(backAudio); errBack == nil { - hasContent = true - } - } - // Check for image files if !hasContent { patterns := []string{ @@ -310,7 +298,9 @@ func (a *Application) loadWordByIndex(index int) { // Load from queue job a.currentTranslation = job.Translation a.currentAudioFile = job.AudioFile + a.currentAudioFileBack = job.AudioFileBack a.currentImage = job.ImageFile + a.currentCardType = job.CardType fyne.Do(func() { if job.Translation != "" { @@ -319,6 +309,9 @@ func (a *Application) loadWordByIndex(index int) { if job.AudioFile != "" { a.audioPlayer.SetAudioFile(job.AudioFile) } + if job.AudioFileBack != "" { + a.audioPlayer.SetBackAudioFile(job.AudioFileBack) + } if job.ImageFile != "" { a.imageDisplay.SetImages([]string{job.ImageFile}) } @@ -446,10 +439,9 @@ func (a *Application) loadExistingFiles(word string) { if cardType.IsBgBg() { fmt.Printf("DEBUG (loadExistingFiles): Loading audio files for bg-bg card\n") // For bg-bg cards, load both front and back audio - frontAudio := filepath.Join(wordDir, fmt.Sprintf("audio_front.%s", a.config.AudioFormat)) - backAudio := filepath.Join(wordDir, fmt.Sprintf("audio_back.%s", a.config.AudioFormat)) + frontAudio, backAudio := a.resolveBgBgAudioFiles(wordDir) - if _, err := os.Stat(frontAudio); err == nil { + if frontAudio != "" { a.currentAudioFile = frontAudio fmt.Printf("DEBUG (loadExistingFiles): Found front audio: %s\n", frontAudio) fyne.Do(func() { @@ -459,7 +451,7 @@ func (a *Application) loadExistingFiles(word string) { fmt.Printf("DEBUG (loadExistingFiles): Front audio not found: %s\n", frontAudio) } - if _, err := os.Stat(backAudio); err == nil { + if backAudio != "" { a.currentAudioFileBack = backAudio fmt.Printf("DEBUG (loadExistingFiles): Found back audio: %s\n", backAudio) fyne.Do(func() { @@ -471,8 +463,8 @@ func (a *Application) loadExistingFiles(word string) { } else { fmt.Printf("DEBUG (loadExistingFiles): Loading audio files for en-bg card\n") // For en-bg cards, load standard audio file - audioFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", a.config.AudioFormat)) - if _, err := os.Stat(audioFile); err == nil { + audioFile := a.resolveSingleAudioFile(wordDir) + if audioFile != "" { a.currentAudioFile = audioFile fmt.Printf("DEBUG (loadExistingFiles): Found audio: %s\n", audioFile) fyne.Do(func() { @@ -581,31 +573,31 @@ func (a *Application) checkForMissingFiles(word string) { // Check for missing audio file if a.currentAudioFile == "" { - // First check for en-bg audio file - audioFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", a.config.AudioFormat)) - if _, err := os.Stat(audioFile); err == nil { - a.currentAudioFile = audioFile - fyne.Do(func() { - a.audioPlayer.SetAudioFile(audioFile) - a.updateStatus(fmt.Sprintf("Found audio file for %s", word)) - }) - } else { - // Check for bg-bg audio_front file - frontAudio := filepath.Join(wordDir, fmt.Sprintf("audio_front.%s", a.config.AudioFormat)) - if _, err := os.Stat(frontAudio); err == nil { + if a.currentCardType == "bg-bg" { + frontAudio, _ := a.resolveBgBgAudioFiles(wordDir) + if frontAudio != "" { a.currentAudioFile = frontAudio fyne.Do(func() { a.audioPlayer.SetAudioFile(frontAudio) a.updateStatus(fmt.Sprintf("Found audio file for %s", word)) }) } + } else { + audioFile := a.resolveSingleAudioFile(wordDir) + if audioFile != "" { + a.currentAudioFile = audioFile + fyne.Do(func() { + a.audioPlayer.SetAudioFile(audioFile) + a.updateStatus(fmt.Sprintf("Found audio file for %s", word)) + }) + } } } // Check for missing back audio file (bg-bg cards) if a.currentAudioFileBack == "" { - backAudio := filepath.Join(wordDir, fmt.Sprintf("audio_back.%s", a.config.AudioFormat)) - if _, err := os.Stat(backAudio); err == nil { + _, backAudio := a.resolveBgBgAudioFiles(wordDir) + if backAudio != "" { a.currentAudioFileBack = backAudio fyne.Do(func() { a.audioPlayer.SetBackAudioFile(backAudio) |
