From f623b64d083d52cc9a278e9d229215a6686c6b29 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 2 Apr 2026 07:37:19 +0300 Subject: Fix GUI audio reload and bg-bg session state --- internal/gui/app.go | 2 +- internal/gui/audio_paths.go | 65 ++++++++++++++++++- internal/gui/audio_paths_test.go | 134 +++++++++++++++++++++++++++++---------- internal/gui/generator.go | 56 +++++++++++++--- internal/gui/generator_test.go | 30 +++++++++ internal/gui/queue.go | 3 +- 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() -- cgit v1.2.3