From f3b5f58a171eeb47716e2d15a40c4c480d2d5ef9 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 2 Apr 2026 17:31:29 +0300 Subject: task 002: centralize Gemini voice fallbacks --- internal/audio/fallbacks.go | 29 +++++++++++++++++++++ internal/audio/voices_test.go | 49 ++++++++++++++++++++++++++++++++++++ internal/gui/generator.go | 47 +++++++++++++--------------------- internal/gui/generator_test.go | 4 +-- internal/processor/processor.go | 39 +++++++++------------------- internal/processor/processor_test.go | 4 +-- 6 files changed, 112 insertions(+), 60 deletions(-) create mode 100644 internal/audio/fallbacks.go diff --git a/internal/audio/fallbacks.go b/internal/audio/fallbacks.go new file mode 100644 index 0000000..bd0c86e --- /dev/null +++ b/internal/audio/fallbacks.go @@ -0,0 +1,29 @@ +package audio + +import ( + "fmt" + "strings" +) + +// RunWithVoiceFallbacks tries the selected Gemini voice first, then the remaining known voices. +func RunWithVoiceFallbacks(initialVoice string, generate func(voice string) error) (usedVoice string, err error) { + attempted := make([]string, 0, len(GeminiVoices)) + var lastErr error + + for _, voice := range GeminiVoiceFallbacks(initialVoice) { + attempted = append(attempted, voice) + + err := generate(voice) + if err == nil { + return voice, nil + } + if !IsGeminiNoAudioDataError(err) { + return "", err + } + + lastErr = err + fmt.Printf("Warning: Gemini returned no audio for voice %s\n", voice) + } + + return "", fmt.Errorf("Gemini returned no audio for voices %s: %w", strings.Join(attempted, ", "), lastErr) +} diff --git a/internal/audio/voices_test.go b/internal/audio/voices_test.go index ea0f797..5e940d0 100644 --- a/internal/audio/voices_test.go +++ b/internal/audio/voices_test.go @@ -1,10 +1,13 @@ package audio import ( + "errors" "reflect" "testing" ) +var errTestSentinel = errors.New("test sentinel error") + func TestVoiceLists(t *testing.T) { t.Parallel() @@ -57,3 +60,49 @@ func TestGeminiVoiceFallbacks(t *testing.T) { } }) } + +func TestRunWithVoiceFallbacks(t *testing.T) { + originalVoices := append([]string(nil), GeminiVoices...) + t.Cleanup(func() { + GeminiVoices = originalVoices + }) + GeminiVoices = []string{"Charon", "Kore", "Leda"} + + t.Run("retries no-audio errors until success", func(t *testing.T) { + var attempted []string + usedVoice, err := RunWithVoiceFallbacks("Charon", func(voice string) error { + attempted = append(attempted, voice) + if voice == "Charon" { + return ErrGeminiNoAudioData + } + return nil + }) + if err != nil { + t.Fatalf("RunWithVoiceFallbacks() unexpected error: %v", err) + } + if usedVoice != "Kore" { + t.Fatalf("used voice = %q, want %q", usedVoice, "Kore") + } + if got, want := attempted, []string{"Charon", "Kore"}; !reflect.DeepEqual(got, want) { + t.Fatalf("attempted voices = %#v, want %#v", got, want) + } + }) + + t.Run("returns non-retryable errors immediately", func(t *testing.T) { + sentinel := errTestSentinel + var attempted []string + usedVoice, err := RunWithVoiceFallbacks("Charon", func(voice string) error { + attempted = append(attempted, voice) + return sentinel + }) + if !errors.Is(err, sentinel) { + t.Fatalf("error = %v, want %v", err, sentinel) + } + if usedVoice != "" { + t.Fatalf("used voice = %q, want empty", usedVoice) + } + if got, want := attempted, []string{"Charon"}; !reflect.DeepEqual(got, want) { + t.Fatalf("attempted voices = %#v, want %#v", got, want) + } + }) +} diff --git a/internal/gui/generator.go b/internal/gui/generator.go index a1ab22c..f3fe9b5 100644 --- a/internal/gui/generator.go +++ b/internal/gui/generator.go @@ -133,31 +133,6 @@ func (a *Application) generateAudioFile(ctx context.Context, text, outputFile, v return provider.GenerateAudio(ctx, text, outputFile) } -func (a *Application) generateGeminiAudioWithFallbacks(initialVoice string, generate func(voice string) error) (string, error) { - attempted := make([]string, 0, len(audio.GeminiVoices)) - var lastErr error - - for i, voice := range audio.GeminiVoiceFallbacks(initialVoice) { - if i > 0 { - fmt.Printf("Retrying Gemini audio with voice: %s\n", voice) - } - - attempted = append(attempted, voice) - err := generate(voice) - if err == nil { - return voice, nil - } - if !audio.IsGeminiNoAudioDataError(err) { - return "", err - } - - lastErr = err - fmt.Printf("Warning: Gemini returned no audio for voice %s\n", voice) - } - - return "", fmt.Errorf("Gemini returned no audio for voices %s: %w", strings.Join(attempted, ", "), lastErr) -} - // translateWord translates a Bulgarian word to English func (a *Application) translateWord(word string) (string, error) { if a.translator == nil { @@ -207,7 +182,10 @@ func (a *Application) generateAudio(ctx context.Context, word string, cardDir st finalVoice := voice var err error if a.audioProviderName() == "gemini" && !a.geminiVoicePinned() { - finalVoice, err = a.generateGeminiAudioWithFallbacks(voice, func(candidate string) error { + finalVoice, err = audio.RunWithVoiceFallbacks(voice, func(candidate string) error { + if candidate != voice { + fmt.Printf("Retrying Gemini audio with voice: %s\n", candidate) + } return a.generateAudioFile(ctx, word, outputFile, candidate, speed) }) } else { @@ -246,7 +224,10 @@ func (a *Application) generateAudioFront(ctx context.Context, word string, cardD finalVoice := voice var err error if a.audioProviderName() == "gemini" && !a.geminiVoicePinned() { - finalVoice, err = a.generateGeminiAudioWithFallbacks(voice, func(candidate string) error { + finalVoice, err = audio.RunWithVoiceFallbacks(voice, func(candidate string) error { + if candidate != voice { + fmt.Printf("Retrying Gemini audio with voice: %s\n", candidate) + } return a.generateAudioFile(ctx, word, frontFile, candidate, speed) }) } else { @@ -283,7 +264,10 @@ func (a *Application) generateAudioBack(ctx context.Context, text string, cardDi finalVoice := voice var err error if a.audioProviderName() == "gemini" && !a.geminiVoicePinned() { - finalVoice, err = a.generateGeminiAudioWithFallbacks(voice, func(candidate string) error { + finalVoice, err = audio.RunWithVoiceFallbacks(voice, func(candidate string) error { + if candidate != voice { + fmt.Printf("Retrying Gemini audio with voice: %s\n", candidate) + } return a.generateAudioFile(ctx, text, backFile, candidate, speed) }) } else { @@ -336,7 +320,12 @@ func (a *Application) generateAudioBgBg(ctx context.Context, front, back, cardDi finalVoice := voice var err error if a.audioProviderName() == "gemini" && !a.geminiVoicePinned() { - finalVoice, err = a.generateGeminiAudioWithFallbacks(voice, runPair) + finalVoice, err = audio.RunWithVoiceFallbacks(voice, func(candidate string) error { + if candidate != voice { + fmt.Printf("Retrying Gemini audio with voice: %s\n", candidate) + } + return runPair(candidate) + }) } else { err = runPair(voice) } diff --git a/internal/gui/generator_test.go b/internal/gui/generator_test.go index 2828337..fcb8f51 100644 --- a/internal/gui/generator_test.go +++ b/internal/gui/generator_test.go @@ -398,11 +398,11 @@ func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) { }, } - voice, err := app.generateGeminiAudioWithFallbacks("Charon", func(candidate string) error { + voice, err := audio.RunWithVoiceFallbacks("Charon", func(candidate string) error { return app.generateAudioFile(context.Background(), "ябълка", outputPath, candidate, 1.0) }) if err != nil { - t.Fatalf("generateGeminiAudioWithFallbacks() unexpected error: %v", err) + t.Fatalf("RunWithVoiceFallbacks() unexpected error: %v", err) } if voice != "Kore" { diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 38c2413..f6eb706 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -344,31 +344,6 @@ func (p *Processor) logSelectedAudioVoice(provider, voice string) { } } -func (p *Processor) generateGeminiAudioWithFallbacks(initialVoice string, generate func(voice string) error) error { - attempted := make([]string, 0, len(audio.GeminiVoices)) - var lastErr error - - for i, voice := range audio.GeminiVoiceFallbacks(initialVoice) { - if i > 0 { - fmt.Printf(" Retrying Gemini audio with voice: %s\n", voice) - } - - attempted = append(attempted, voice) - err := generate(voice) - if err == nil { - return nil - } - if !audio.IsGeminiNoAudioDataError(err) { - return err - } - - lastErr = err - fmt.Printf(" Warning: Gemini returned no audio for voice %s\n", voice) - } - - return fmt.Errorf("Gemini returned no audio for voices %s: %w", strings.Join(attempted, ", "), lastErr) -} - // generateAudio generates audio files for a word func (p *Processor) generateAudio(word string) error { provider := p.audioProviderName() @@ -381,9 +356,13 @@ func (p *Processor) generateAudio(word string) error { voice := p.audioVoiceForProvider() p.logSelectedAudioVoice(provider, voice) if provider == "gemini" && p.geminiVoice() == "" { - return p.generateGeminiAudioWithFallbacks(voice, func(candidate string) error { + _, err := audio.RunWithVoiceFallbacks(voice, func(candidate string) error { + if candidate != voice { + fmt.Printf(" Retrying Gemini audio with voice: %s\n", candidate) + } return p.generateAudioWithVoice(word, candidate) }) + return err } voices = []string{voice} } @@ -427,7 +406,13 @@ func (p *Processor) generateAudioBgBg(front, back string) error { } if provider == "gemini" && p.geminiVoice() == "" { - return p.generateGeminiAudioWithFallbacks(voice, generatePair) + _, err := audio.RunWithVoiceFallbacks(voice, func(candidate string) error { + if candidate != voice { + fmt.Printf(" Retrying Gemini audio with voice: %s\n", candidate) + } + return generatePair(candidate) + }) + return err } if err := generatePair(voice); err != nil { diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go index a0c6ae1..cd6e2eb 100644 --- a/internal/processor/processor_test.go +++ b/internal/processor/processor_test.go @@ -712,10 +712,10 @@ func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) { flags.AudioProvider = "gemini" p := NewProcessor(flags) - if err := p.generateGeminiAudioWithFallbacks("Charon", func(voice string) error { + if _, err := audio.RunWithVoiceFallbacks("Charon", func(voice string) error { return p.generateAudioWithVoice("ябълка", voice) }); err != nil { - t.Fatalf("generateGeminiAudioWithFallbacks() unexpected error: %v", err) + t.Fatalf("RunWithVoiceFallbacks() unexpected error: %v", err) } if got, want := strings.Join(attemptedVoices, ","), "Charon,Kore"; got != want { -- cgit v1.2.3