summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-02 07:37:19 +0300
committerPaul Buetow <paul@buetow.org>2026-04-02 07:37:19 +0300
commitf623b64d083d52cc9a278e9d229215a6686c6b29 (patch)
treeb82a3371d6a7d1d73d0f22fc6cec0ebcc9f389b1
parentc1a2a5a1df54611437d36e1b2f448f78b32e8eb1 (diff)
Fix GUI audio reload and bg-bg session state
-rw-r--r--internal/gui/app.go2
-rw-r--r--internal/gui/audio_paths.go65
-rw-r--r--internal/gui/audio_paths_test.go134
-rw-r--r--internal/gui/generator.go56
-rw-r--r--internal/gui/generator_test.go30
-rw-r--r--internal/gui/queue.go3
6 files changed, 243 insertions, 47 deletions
diff --git a/internal/gui/app.go b/internal/gui/app.go
index 05955cf..a5924fc 100644
--- a/internal/gui/app.go
+++ b/internal/gui/app.go
@@ -2357,7 +2357,7 @@ func (a *Application) processWordJob(job *WordJob) {
a.updateStatus(fmt.Sprintf("Finalizing '%s'...", job.Word))
})
- a.queue.CompleteJob(job.ID, translation, audioFile, imageFile)
+ a.queue.CompleteJob(job.ID, translation, audioFile, audioFileBack, imageFile)
// Update UI with results if this is still the current job
a.mu.Lock()
diff --git a/internal/gui/audio_paths.go b/internal/gui/audio_paths.go
index 42402da..737f033 100644
--- a/internal/gui/audio_paths.go
+++ b/internal/gui/audio_paths.go
@@ -1,13 +1,33 @@
package gui
-import "codeberg.org/snonux/totalrecall/internal/anki"
+import (
+ "os"
+ "path/filepath"
+ "strings"
+
+ "codeberg.org/snonux/totalrecall/internal/anki"
+)
func (a *Application) resolveSingleAudioFile(wordDir string) string {
+ if audioFile := resolveAudioFileFromMetadata(wordDir, "audio_file"); audioFile != "" {
+ return audioFile
+ }
+
return anki.ResolveAudioFile(wordDir, "audio", "")
}
func (a *Application) resolveBgBgAudioFiles(wordDir string) (string, string) {
- return anki.ResolveAudioFile(wordDir, "audio_front", ""), anki.ResolveAudioFile(wordDir, "audio_back", "")
+ front := resolveAudioFileFromMetadata(wordDir, "audio_file")
+ if front == "" {
+ front = anki.ResolveAudioFile(wordDir, "audio_front", "")
+ }
+
+ back := resolveAudioFileFromMetadata(wordDir, "audio_file_back")
+ if back == "" {
+ back = anki.ResolveAudioFile(wordDir, "audio_back", "")
+ }
+
+ return front, back
}
func (a *Application) hasAnyAudioFile(wordDir string) bool {
@@ -19,3 +39,44 @@ func (a *Application) hasAnyAudioFile(wordDir string) bool {
front, back := a.resolveBgBgAudioFiles(wordDir)
return front != "" || back != ""
}
+
+func resolveAudioFileFromMetadata(wordDir, key string) string {
+ metadata := readAudioMetadata(wordDir)
+ value := strings.TrimSpace(metadata[key])
+ if value == "" {
+ return ""
+ }
+
+ if !filepath.IsAbs(value) {
+ value = filepath.Join(wordDir, value)
+ }
+
+ if _, err := os.Stat(value); err == nil {
+ return value
+ }
+
+ return ""
+}
+
+func readAudioMetadata(wordDir string) map[string]string {
+ metadataFile := filepath.Join(wordDir, "audio_metadata.txt")
+ data, err := os.ReadFile(metadataFile)
+ if err != nil {
+ return map[string]string{}
+ }
+
+ values := make(map[string]string)
+ for _, line := range strings.Split(string(data), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ key, value, found := strings.Cut(line, "=")
+ if !found {
+ continue
+ }
+ values[strings.TrimSpace(key)] = strings.TrimSpace(value)
+ }
+
+ return values
+}
diff --git a/internal/gui/audio_paths_test.go b/internal/gui/audio_paths_test.go
index a61a24e..d412004 100644
--- a/internal/gui/audio_paths_test.go
+++ b/internal/gui/audio_paths_test.go
@@ -5,13 +5,49 @@ import (
"os"
"path/filepath"
"testing"
- "time"
fyneapp "fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/widget"
ttwidget "github.com/dweymouth/fyne-tooltip/widget"
)
+func newGUIAudioTestApp(t *testing.T, tempDir string) *Application {
+ t.Helper()
+
+ fyneApp := fyneapp.New()
+ t.Cleanup(func() {
+ fyneApp.Quit()
+ })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+
+ return &Application{
+ config: &Config{
+ OutputDir: tempDir,
+ AudioFormat: "wav",
+ },
+ ctx: ctx,
+ cancel: cancel,
+ queue: NewWordQueue(ctx),
+ wordInput: NewCustomEntry(),
+ audioPlayer: NewAudioPlayer(),
+ imageDisplay: NewImageDisplay(),
+ translationEntry: NewCustomEntry(),
+ cardTypeSelect: widget.NewSelect([]string{"English → Bulgarian", "Bulgarian → Bulgarian"}, nil),
+ imagePromptEntry: NewCustomMultiLineEntry(),
+ statusLabel: widget.NewLabel(""),
+ prevWordBtn: ttwidget.NewButton("", nil),
+ nextWordBtn: ttwidget.NewButton("", nil),
+ keepButton: ttwidget.NewButton("", nil),
+ regenerateImageBtn: ttwidget.NewButton("", nil),
+ regenerateRandomImageBtn: ttwidget.NewButton("", nil),
+ regenerateAudioBtn: ttwidget.NewButton("", nil),
+ regenerateAllBtn: ttwidget.NewButton("", nil),
+ deleteButton: ttwidget.NewButton("", nil),
+ }
+}
+
func TestResolveSingleAudioFileFindsLegacyMp3WhenGuiDefaultIsWav(t *testing.T) {
tempDir := t.TempDir()
wordDir := filepath.Join(tempDir, "word")
@@ -35,13 +71,6 @@ func TestResolveSingleAudioFileFindsLegacyMp3WhenGuiDefaultIsWav(t *testing.T) {
}
func TestGUIDiscoveryAndLoadingRecognizeVoiceSuffixedAudio(t *testing.T) {
- fyneApp := fyneapp.New()
- t.Cleanup(func() {
- fyneApp.Quit()
- })
- ctx, cancel := context.WithCancel(context.Background())
- t.Cleanup(cancel)
-
tempDir := t.TempDir()
wordDir := filepath.Join(tempDir, "word-card")
if err := os.MkdirAll(wordDir, 0755); err != nil {
@@ -58,30 +87,7 @@ func TestGUIDiscoveryAndLoadingRecognizeVoiceSuffixedAudio(t *testing.T) {
t.Fatalf("failed to write voice-suffixed audio file: %v", err)
}
- app := &Application{
- config: &Config{
- OutputDir: tempDir,
- AudioFormat: "wav",
- },
- ctx: ctx,
- cancel: cancel,
- queue: NewWordQueue(context.Background()),
- wordInput: NewCustomEntry(),
- audioPlayer: NewAudioPlayer(),
- imageDisplay: NewImageDisplay(),
- translationEntry: NewCustomEntry(),
- cardTypeSelect: widget.NewSelect([]string{"English → Bulgarian", "Bulgarian → Bulgarian"}, nil),
- imagePromptEntry: NewCustomMultiLineEntry(),
- statusLabel: widget.NewLabel(""),
- prevWordBtn: ttwidget.NewButton("", nil),
- nextWordBtn: ttwidget.NewButton("", nil),
- keepButton: ttwidget.NewButton("", nil),
- regenerateImageBtn: ttwidget.NewButton("", nil),
- regenerateRandomImageBtn: ttwidget.NewButton("", nil),
- regenerateAudioBtn: ttwidget.NewButton("", nil),
- regenerateAllBtn: ttwidget.NewButton("", nil),
- deleteButton: ttwidget.NewButton("", nil),
- }
+ app := newGUIAudioTestApp(t, tempDir)
app.scanExistingWords()
if len(app.existingWords) != 1 || app.existingWords[0] != word {
@@ -89,7 +95,6 @@ func TestGUIDiscoveryAndLoadingRecognizeVoiceSuffixedAudio(t *testing.T) {
}
app.loadExistingFiles(word)
- time.Sleep(50 * time.Millisecond)
if app.currentAudioFile != voiceAudioPath {
t.Fatalf("currentAudioFile = %q, want voice-suffixed path %q", app.currentAudioFile, voiceAudioPath)
@@ -107,6 +112,69 @@ func TestGUIDiscoveryAndLoadingRecognizeVoiceSuffixedAudio(t *testing.T) {
}
}
+func TestLoadExistingFilesPrefersFreshMetadataAudioOverLegacyVoiceSpecificFile(t *testing.T) {
+ tempDir := t.TempDir()
+ wordDir := filepath.Join(tempDir, "word-card")
+ if err := os.MkdirAll(wordDir, 0755); err != nil {
+ t.Fatalf("failed to create word dir: %v", err)
+ }
+
+ word := "ябълка"
+ if err := os.WriteFile(filepath.Join(wordDir, "word.txt"), []byte(word), 0644); err != nil {
+ t.Fatalf("failed to write word file: %v", err)
+ }
+
+ staleLegacyAudio := filepath.Join(wordDir, "audio_legacy-voice.wav")
+ if err := os.WriteFile(staleLegacyAudio, []byte("stale-legacy-audio"), 0644); err != nil {
+ t.Fatalf("failed to write stale legacy audio file: %v", err)
+ }
+
+ freshAudio := filepath.Join(wordDir, "audio.wav")
+ if err := os.WriteFile(freshAudio, []byte("fresh-audio"), 0644); err != nil {
+ t.Fatalf("failed to write fresh audio file: %v", err)
+ }
+
+ metadata := "provider=gemini\nvoice=\nspeed=1.00\nformat=wav\ncardtype=en-bg\naudio_file=audio.wav\n"
+ if err := os.WriteFile(filepath.Join(wordDir, "audio_metadata.txt"), []byte(metadata), 0644); err != nil {
+ t.Fatalf("failed to write audio metadata: %v", err)
+ }
+
+ app := newGUIAudioTestApp(t, tempDir)
+
+ app.loadExistingFiles(word)
+
+ if app.currentAudioFile != freshAudio {
+ t.Fatalf("currentAudioFile = %q, want fresh generated audio %q", app.currentAudioFile, freshAudio)
+ }
+ if app.audioPlayer.audioFile != freshAudio {
+ t.Fatalf("audioPlayer.audioFile = %q, want fresh generated audio %q", app.audioPlayer.audioFile, freshAudio)
+ }
+}
+
+func TestCompletedBgBgJobKeepsBackAudioForSessionNavigation(t *testing.T) {
+ tempDir := t.TempDir()
+ app := newGUIAudioTestApp(t, tempDir)
+
+ job := app.queue.AddWord("ябълка")
+ job.CardType = "bg-bg"
+
+ frontAudio := filepath.Join(tempDir, "card", "audio_front.wav")
+ backAudio := filepath.Join(tempDir, "card", "audio_back.wav")
+ app.queue.CompleteJob(job.ID, "определение", frontAudio, backAudio, "")
+
+ app.loadWordByIndex(0)
+
+ if app.queue.GetCompletedJobs()[0].AudioFileBack != backAudio {
+ t.Fatalf("completed job back audio = %q, want %q", app.queue.GetCompletedJobs()[0].AudioFileBack, backAudio)
+ }
+ if app.currentAudioFileBack != backAudio {
+ t.Fatalf("currentAudioFileBack = %q, want %q", app.currentAudioFileBack, backAudio)
+ }
+ if app.currentCardType != "bg-bg" {
+ t.Fatalf("currentCardType = %q, want %q", app.currentCardType, "bg-bg")
+ }
+}
+
func TestResolveBgBgAudioFilesFindLegacyMp3Files(t *testing.T) {
tempDir := t.TempDir()
wordDir := filepath.Join(tempDir, "word")
diff --git a/internal/gui/generator.go b/internal/gui/generator.go
index 1b9548a..f409216 100644
--- a/internal/gui/generator.go
+++ b/internal/gui/generator.go
@@ -186,9 +186,7 @@ func (a *Application) generateAudio(ctx context.Context, word string, cardDir st
}
// Save voice metadata for GUI display
- metadataFile := filepath.Join(cardDir, "audio_metadata.txt")
- metadata := fmt.Sprintf("voice=%s\nspeed=%.2f\n", voice, speed)
- if err := os.WriteFile(metadataFile, []byte(metadata), 0644); err != nil {
+ if err := a.saveAudioMetadata(cardDir, audioConfig, voice, speed, "en-bg", outputFile, ""); err != nil {
fmt.Printf("Warning: Failed to save audio metadata: %v\n", err)
}
@@ -227,9 +225,7 @@ func (a *Application) generateAudioFront(ctx context.Context, word string, cardD
}
// Update metadata
- metadataFile := filepath.Join(cardDir, "audio_metadata.txt")
- metadata := fmt.Sprintf("voice=%s\nspeed=%.2f\ncardtype=bg-bg\n", voice, speed)
- if err := os.WriteFile(metadataFile, []byte(metadata), 0644); err != nil {
+ if err := a.saveAudioMetadata(cardDir, audioConfig, voice, speed, "bg-bg", frontFile, a.currentAudioFileBack); err != nil {
fmt.Printf("Warning: Failed to save audio metadata: %v\n", err)
}
@@ -267,6 +263,11 @@ func (a *Application) generateAudioBack(ctx context.Context, text string, cardDi
fmt.Printf("Warning: Failed to save audio attribution: %v\n", err)
}
+ // Update metadata
+ if err := a.saveAudioMetadata(cardDir, audioConfig, voice, speed, "bg-bg", a.currentAudioFile, backFile); err != nil {
+ fmt.Printf("Warning: Failed to save audio metadata: %v\n", err)
+ }
+
return backFile, nil
}
@@ -306,10 +307,8 @@ func (a *Application) generateAudioBgBg(ctx context.Context, front, back, cardDi
fmt.Printf("Warning: Failed to save audio attribution: %v\n", err)
}
- // Save voice metadata
- metadataFile := filepath.Join(cardDir, "audio_metadata.txt")
- metadata := fmt.Sprintf("voice=%s\nspeed=%.2f\ncardtype=bg-bg\n", voice, speed)
- if err := os.WriteFile(metadataFile, []byte(metadata), 0644); err != nil {
+ // Save metadata for both sides
+ if err := a.saveAudioMetadata(cardDir, audioConfig, voice, speed, "bg-bg", frontFile, backFile); err != nil {
fmt.Printf("Warning: Failed to save audio metadata: %v\n", err)
}
@@ -459,3 +458,40 @@ func (a *Application) saveAudioAttribution(word, audioFile, voice string, speed
return nil
}
+
+func (a *Application) saveAudioMetadata(cardDir string, audioConfig audio.Config, voice string, speed float64, cardType string, audioFile string, audioFileBack string) error {
+ metadataFile := filepath.Join(cardDir, "audio_metadata.txt")
+ if cardType == "bg-bg" {
+ if audioFile == "" {
+ audioFile, _ = a.resolveBgBgAudioFiles(cardDir)
+ }
+ if audioFileBack == "" {
+ _, audioFileBack = a.resolveBgBgAudioFiles(cardDir)
+ }
+ }
+
+ metadata := strings.Builder{}
+
+ fmt.Fprintf(&metadata, "provider=%s\n", audioConfig.Provider)
+ if strings.TrimSpace(audioConfig.GeminiTTSModel) != "" {
+ fmt.Fprintf(&metadata, "model=%s\n", audioConfig.GeminiTTSModel)
+ } else if strings.TrimSpace(audioConfig.OpenAIModel) != "" {
+ fmt.Fprintf(&metadata, "model=%s\n", audioConfig.OpenAIModel)
+ }
+ fmt.Fprintf(&metadata, "voice=%s\n", voice)
+ fmt.Fprintf(&metadata, "speed=%.2f\n", speed)
+ fmt.Fprintf(&metadata, "format=%s\n", audioConfig.OutputFormat)
+ fmt.Fprintf(&metadata, "cardtype=%s\n", cardType)
+ if audioFile != "" {
+ fmt.Fprintf(&metadata, "audio_file=%s\n", filepath.Base(audioFile))
+ }
+ if audioFileBack != "" {
+ fmt.Fprintf(&metadata, "audio_file_back=%s\n", filepath.Base(audioFileBack))
+ }
+
+ if err := os.WriteFile(metadataFile, []byte(metadata.String()), 0644); err != nil {
+ return fmt.Errorf("failed to write audio metadata file: %w", err)
+ }
+
+ return nil
+}
diff --git a/internal/gui/generator_test.go b/internal/gui/generator_test.go
index be0c254..a6e3742 100644
--- a/internal/gui/generator_test.go
+++ b/internal/gui/generator_test.go
@@ -297,6 +297,21 @@ func TestGenerateAudioUsesGeminiModelDefaultVoiceAndAttribution(t *testing.T) {
if strings.Contains(attribution, "sentinel-gemini-voice") {
t.Fatalf("gemini attribution should not use the shared voice list when voice is unset: %q", attribution)
}
+
+ metadataData, err := os.ReadFile(filepath.Join(cardDir, "audio_metadata.txt"))
+ if err != nil {
+ t.Fatalf("expected metadata file: %v", err)
+ }
+ metadata := string(metadataData)
+ if !strings.Contains(metadata, "audio_file=audio.wav") {
+ t.Fatalf("gemini metadata missing fresh audio file reference: %q", metadata)
+ }
+ if !strings.Contains(metadata, "format=wav") {
+ t.Fatalf("gemini metadata missing format: %q", metadata)
+ }
+ if !strings.Contains(metadata, "cardtype=en-bg") {
+ t.Fatalf("gemini metadata missing card type: %q", metadata)
+ }
}
func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) {
@@ -368,6 +383,21 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) {
if !strings.HasSuffix(backPath, "audio_back.mp3") {
t.Fatalf("backPath = %q, want audio_back.mp3", backPath)
}
+
+ metadataData, err := os.ReadFile(filepath.Join(cardDir, "audio_metadata.txt"))
+ if err != nil {
+ t.Fatalf("expected metadata file: %v", err)
+ }
+ metadata := string(metadataData)
+ if !strings.Contains(metadata, "audio_file=audio_front.mp3") {
+ t.Fatalf("bg-bg metadata missing front audio reference: %q", metadata)
+ }
+ if !strings.Contains(metadata, "audio_file_back=audio_back.mp3") {
+ t.Fatalf("bg-bg metadata missing back audio reference: %q", metadata)
+ }
+ if !strings.Contains(metadata, "cardtype=bg-bg") {
+ t.Fatalf("bg-bg metadata missing card type: %q", metadata)
+ }
}
func TestGenerateAudioFrontUsesSharedOpenAIVoices(t *testing.T) {
diff --git a/internal/gui/queue.go b/internal/gui/queue.go
index cbf814c..1646708 100644
--- a/internal/gui/queue.go
+++ b/internal/gui/queue.go
@@ -196,7 +196,7 @@ func (q *WordQueue) Stop() {
}
// CompleteJob marks a job as completed with results
-func (q *WordQueue) CompleteJob(jobID int, translation, audioFile, imageFile string) {
+func (q *WordQueue) CompleteJob(jobID int, translation, audioFile, audioFileBack, imageFile string) {
q.mu.Lock()
defer q.mu.Unlock()
@@ -204,6 +204,7 @@ func (q *WordQueue) CompleteJob(jobID int, translation, audioFile, imageFile str
job.Status = StatusCompleted
job.Translation = translation
job.AudioFile = audioFile
+ job.AudioFileBack = audioFileBack
job.ImageFile = imageFile
job.CompletedAt = time.Now()