summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-02 07:16:49 +0300
committerPaul Buetow <paul@buetow.org>2026-04-02 07:16:49 +0300
commite96cc2578595a0ec22a07819cd0eb1b0b0c711f7 (patch)
tree0df43845dedeb27ba2afaadfb5a821aeee7a713b
parentfaa2465955445ac1cb1461eb70a98280d32fbf9a (diff)
Harden GUI audio loading for Gemini
-rw-r--r--internal/gui/app.go7
-rw-r--r--internal/gui/audio_paths.go74
-rw-r--r--internal/gui/audio_paths_test.go99
-rw-r--r--internal/gui/generator.go17
-rw-r--r--internal/gui/generator_test.go37
-rw-r--r--internal/gui/navigation.go58
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)