summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-02 17:31:29 +0300
committerPaul Buetow <paul@buetow.org>2026-04-02 17:31:29 +0300
commitf3b5f58a171eeb47716e2d15a40c4c480d2d5ef9 (patch)
treecf461894a613e0d7bad1a36e04dd420654514391
parent4d16684f92544d1cf90dfc5080378ca2f1abe18e (diff)
task 002: centralize Gemini voice fallbacks
-rw-r--r--internal/audio/fallbacks.go29
-rw-r--r--internal/audio/voices_test.go49
-rw-r--r--internal/gui/generator.go47
-rw-r--r--internal/gui/generator_test.go4
-rw-r--r--internal/processor/processor.go39
-rw-r--r--internal/processor/processor_test.go4
6 files changed, 112 insertions, 60 deletions
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 {