summaryrefslogtreecommitdiff
path: root/internal/processor
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-02 16:36:25 +0300
committerPaul Buetow <paul@buetow.org>2026-04-02 16:36:25 +0300
commit6bd23a588bacee2e8c75f477150b7e2d345002ff (patch)
tree67bfa78afe206e7560350bdd52c60e0e80ec3243 /internal/processor
parent6ce9123de04ffff961cbf1da73648679216ff637 (diff)
Release v0.9.0v0.9.0
Diffstat (limited to 'internal/processor')
-rw-r--r--internal/processor/processor.go117
-rw-r--r--internal/processor/processor_test.go125
2 files changed, 180 insertions, 62 deletions
diff --git a/internal/processor/processor.go b/internal/processor/processor.go
index 8536b98..9b8fe8a 100644
--- a/internal/processor/processor.go
+++ b/internal/processor/processor.go
@@ -252,8 +252,10 @@ func (p *Processor) audioProviderName() string {
}
func (p *Processor) effectiveAudioFormat() string {
- if p.audioProviderName() == "gemini" {
- return "wav"
+ if p != nil && p.flags != nil && p.flags.AudioFormatSpecified {
+ if format := strings.ToLower(strings.TrimSpace(p.flags.AudioFormat)); format != "" {
+ return format
+ }
}
if viper.IsSet("audio.format") {
@@ -268,6 +270,10 @@ func (p *Processor) effectiveAudioFormat() string {
}
}
+ if p.audioProviderName() == "gemini" {
+ return audio.DefaultProviderConfig().OutputFormat
+ }
+
return "mp3"
}
@@ -313,7 +319,11 @@ func (p *Processor) audioVoicesForProvider() []string {
func (p *Processor) audioVoiceForProvider() string {
switch p.audioProviderName() {
case "gemini":
- return p.geminiVoice()
+ if voice := p.geminiVoice(); voice != "" {
+ return voice
+ }
+ voices := p.audioVoicesForProvider()
+ return voices[rand.Intn(len(voices))]
default:
if voice := p.openAIVoice(); voice != "" {
return voice
@@ -323,6 +333,48 @@ func (p *Processor) audioVoiceForProvider() string {
}
}
+func (p *Processor) logSelectedAudioVoice(provider, voice string) {
+ switch provider {
+ case "gemini":
+ if p.geminiVoice() != "" {
+ fmt.Printf(" Using specified Gemini voice: %s\n", voice)
+ } else {
+ fmt.Printf(" Using random Gemini voice: %s\n", voice)
+ }
+ default:
+ if p.openAIVoice() != "" {
+ fmt.Printf(" Using specified voice: %s\n", voice)
+ } else {
+ fmt.Printf(" Using random voice: %s\n", voice)
+ }
+ }
+}
+
+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()
@@ -333,19 +385,11 @@ func (p *Processor) generateAudio(word string) error {
voices = p.audioVoicesForProvider()
} else {
voice := p.audioVoiceForProvider()
- switch provider {
- case "gemini":
- if voice != "" {
- fmt.Printf(" Using specified Gemini voice: %s\n", voice)
- } else {
- fmt.Printf(" Using Gemini model default voice\n")
- }
- default:
- if p.openAIVoice() != "" {
- fmt.Printf(" Using specified voice: %s\n", voice)
- } else {
- fmt.Printf(" Using random voice: %s\n", voice)
- }
+ p.logSelectedAudioVoice(provider, voice)
+ if provider == "gemini" && p.geminiVoice() == "" {
+ return p.generateGeminiAudioWithFallbacks(voice, func(candidate string) error {
+ return p.generateAudioWithVoice(word, candidate)
+ })
}
voices = []string{voice}
}
@@ -368,35 +412,32 @@ func (p *Processor) generateAudioBgBg(front, back string) error {
provider := p.audioProviderName()
voice := p.audioVoiceForProvider()
- switch provider {
- case "gemini":
- if voice != "" {
- fmt.Printf(" Using specified Gemini voice: %s\n", voice)
- } else {
- fmt.Printf(" Using Gemini model default voice\n")
- }
- default:
- if p.openAIVoice() != "" {
- fmt.Printf(" Using specified voice: %s\n", voice)
- } else {
- fmt.Printf(" Using random voice: %s\n", voice)
- }
- }
+ p.logSelectedAudioVoice(provider, voice)
// Find or create the word directory ONCE (for the front word)
// Both audio files will be saved to this same directory
wordDir := p.findOrCreateWordDirectory(front)
- // Generate front audio
- fmt.Printf(" Generating front audio for '%s'...\n", front)
- if err := p.generateAudioWithVoiceAndFilenameInDir(front, voice, "audio_front", wordDir); err != nil {
- return fmt.Errorf("failed to generate front audio: %w", err)
+ generatePair := func(candidate string) error {
+ fmt.Printf(" Generating front audio for '%s'...\n", front)
+ if err := p.generateAudioWithVoiceAndFilenameInDir(front, candidate, "audio_front", wordDir); err != nil {
+ return fmt.Errorf("failed to generate front audio: %w", err)
+ }
+
+ fmt.Printf(" Generating back audio for '%s'...\n", back)
+ if err := p.generateAudioWithVoiceAndFilenameInDir(back, candidate, "audio_back", wordDir); err != nil {
+ return fmt.Errorf("failed to generate back audio: %w", err)
+ }
+
+ return nil
}
- // Generate back audio
- fmt.Printf(" Generating back audio for '%s'...\n", back)
- if err := p.generateAudioWithVoiceAndFilenameInDir(back, voice, "audio_back", wordDir); err != nil {
- return fmt.Errorf("failed to generate back audio: %w", err)
+ if provider == "gemini" && p.geminiVoice() == "" {
+ return p.generateGeminiAudioWithFallbacks(voice, generatePair)
+ }
+
+ if err := generatePair(voice); err != nil {
+ return err
}
return nil
diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go
index e75fa8d..a0c6ae1 100644
--- a/internal/processor/processor_test.go
+++ b/internal/processor/processor_test.go
@@ -77,6 +77,7 @@ type fakeAudioProvider struct {
outputFiles []string
lastText string
lastOutputFile string
+ generateFunc func(text, outputFile string) error
}
func (f *fakeAudioProvider) GenerateAudio(_ context.Context, text, outputFile string) error {
@@ -85,6 +86,9 @@ func (f *fakeAudioProvider) GenerateAudio(_ context.Context, text, outputFile st
f.outputFiles = append(f.outputFiles, outputFile)
f.lastText = text
f.lastOutputFile = outputFile
+ if f.generateFunc != nil {
+ return f.generateFunc(text, outputFile)
+ }
return nil
}
@@ -131,7 +135,7 @@ func TestNewProcessor(t *testing.T) {
}
}
-func TestNewProcessor_DefaultPhoneticProviderUsesOpenAI(t *testing.T) {
+func TestNewProcessor_DefaultPhoneticProviderUsesGemini(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "")
t.Setenv("GOOGLE_API_KEY", "")
@@ -145,8 +149,8 @@ func TestNewProcessor_DefaultPhoneticProviderUsesOpenAI(t *testing.T) {
flags := cli.NewFlags()
p := NewProcessor(flags)
- if got := p.phoneticFetcher.Provider(); got != phonetic.ProviderOpenAI {
- t.Fatalf("expected default phonetic provider %q, got %q", phonetic.ProviderOpenAI, got)
+ if got := p.phoneticFetcher.Provider(); got != phonetic.ProviderGemini {
+ t.Fatalf("expected default phonetic provider %q, got %q", phonetic.ProviderGemini, got)
}
}
@@ -170,7 +174,7 @@ func TestNewProcessor_ExplicitGeminiPhoneticProvider(t *testing.T) {
}
}
-func TestNewProcessor_DefaultTranslationProviderUsesOpenAI(t *testing.T) {
+func TestNewProcessor_DefaultTranslationProviderUsesGemini(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "")
t.Setenv("GOOGLE_API_KEY", "")
@@ -186,10 +190,10 @@ func TestNewProcessor_DefaultTranslationProviderUsesOpenAI(t *testing.T) {
_, err := p.translator.TranslateWord("ябълка")
if err == nil {
- t.Fatal("Expected error for missing OpenAI API key")
+ t.Fatal("Expected error for missing Google API key")
}
- if err.Error() != "OpenAI API key not found" {
- t.Fatalf("Expected OpenAI default provider error, got: %v", err)
+ if err.Error() != "google API key not found" {
+ t.Fatalf("Expected Gemini default provider error, got: %v", err)
}
}
@@ -231,7 +235,7 @@ func TestGUIConfigForRunModeUsesNanoBananaDefaultWhenImageAPIIsNotSpecified(t *t
viper.Set("image.nanobanana_text_model", "config-text-model")
flags := cli.NewFlags()
- flags.AudioFormat = "wav"
+ flags.AudioFormat = "mp3"
flags.ImageAPI = "openai"
flags.ImageAPISpecified = false
p := NewProcessor(flags)
@@ -243,8 +247,8 @@ func TestGUIConfigForRunModeUsesNanoBananaDefaultWhenImageAPIIsNotSpecified(t *t
if guiConfig.AudioProvider != "gemini" {
t.Fatalf("guiConfig.AudioProvider = %q, want %q", guiConfig.AudioProvider, "gemini")
}
- if guiConfig.AudioFormat != "wav" {
- t.Fatalf("guiConfig.AudioFormat = %q, want %q", guiConfig.AudioFormat, "wav")
+ if guiConfig.AudioFormat != "mp3" {
+ t.Fatalf("guiConfig.AudioFormat = %q, want %q", guiConfig.AudioFormat, "mp3")
}
if guiConfig.NanoBananaModel != "config-image-model" {
t.Fatalf("guiConfig.NanoBananaModel = %q, want %q", guiConfig.NanoBananaModel, "config-image-model")
@@ -536,14 +540,14 @@ func TestGenerateAudioUsesConfiguredGeminiVoiceAndModel(t *testing.T) {
if capturedConfig.GeminiVoice != "Kore" {
t.Fatalf("captured GeminiVoice = %q, want %q", capturedConfig.GeminiVoice, "Kore")
}
- if capturedConfig.OutputFormat != "wav" {
- t.Fatalf("captured OutputFormat = %q, want %q", capturedConfig.OutputFormat, "wav")
+ if capturedConfig.OutputFormat != "mp3" {
+ t.Fatalf("captured OutputFormat = %q, want %q", capturedConfig.OutputFormat, "mp3")
}
if fakeProvider.generateCalls != 1 {
t.Fatalf("GenerateAudio() calls = %d, want %d", fakeProvider.generateCalls, 1)
}
- if !strings.HasSuffix(fakeProvider.lastOutputFile, "audio.wav") {
- t.Fatalf("GenerateAudio() output file = %q, want wav output", fakeProvider.lastOutputFile)
+ if !strings.HasSuffix(fakeProvider.lastOutputFile, "audio.mp3") {
+ t.Fatalf("GenerateAudio() output file = %q, want mp3 output", fakeProvider.lastOutputFile)
}
wordDir := p.findCardDirectory("ябълка!?")
@@ -560,8 +564,8 @@ func TestGenerateAudioUsesConfiguredGeminiVoiceAndModel(t *testing.T) {
"model=gemini-2.5-flash-preview-tts",
"voice=Kore",
"speed=1.00",
- "format=wav",
- "audio_file=audio.wav",
+ "format=mp3",
+ "audio_file=audio.mp3",
"cardtype=en-bg",
} {
if !strings.Contains(metadata, want) {
@@ -591,6 +595,11 @@ func TestGenerateAudioUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) {
t.Cleanup(func() {
newAudioProvider = originalFactory
})
+ originalVoices := append([]string(nil), audio.GeminiVoices...)
+ t.Cleanup(func() {
+ audio.GeminiVoices = originalVoices
+ })
+ audio.GeminiVoices = []string{"sentinel-gemini-voice"}
fakeProvider := &fakeAudioProvider{}
var capturedConfig *audio.Config
@@ -621,14 +630,14 @@ func TestGenerateAudioUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) {
if capturedConfig == nil {
t.Fatal("expected audio provider config to be captured")
}
- if capturedConfig.GeminiVoice != "" {
- t.Fatalf("captured GeminiVoice = %q, want empty model-default voice", capturedConfig.GeminiVoice)
+ if capturedConfig.GeminiVoice != "sentinel-gemini-voice" {
+ t.Fatalf("captured GeminiVoice = %q, want %q", capturedConfig.GeminiVoice, "sentinel-gemini-voice")
}
if fakeProvider.generateCalls != 1 {
t.Fatalf("GenerateAudio() calls = %d, want %d", fakeProvider.generateCalls, 1)
}
- if !strings.HasSuffix(fakeProvider.lastOutputFile, "audio.wav") {
- t.Fatalf("GenerateAudio() output file = %q, want wav output", fakeProvider.lastOutputFile)
+ if !strings.HasSuffix(fakeProvider.lastOutputFile, "audio.mp3") {
+ t.Fatalf("GenerateAudio() output file = %q, want mp3 output", fakeProvider.lastOutputFile)
}
wordDir := p.findCardDirectory("ябълка!?")
@@ -642,8 +651,8 @@ func TestGenerateAudioUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) {
metadata := string(metadataData)
for _, want := range []string{
"provider=gemini",
- "voice=model-default",
- "audio_file=audio.wav",
+ "voice=sentinel-gemini-voice",
+ "audio_file=audio.mp3",
"cardtype=en-bg",
} {
if !strings.Contains(metadata, want) {
@@ -665,11 +674,79 @@ func TestGenerateAudioUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) {
}
}
+func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) {
+ originalFactory := newAudioProvider
+ t.Cleanup(func() {
+ newAudioProvider = originalFactory
+ })
+
+ originalVoices := append([]string(nil), audio.GeminiVoices...)
+ t.Cleanup(func() {
+ audio.GeminiVoices = originalVoices
+ })
+ audio.GeminiVoices = []string{"Charon", "Kore", "Leda"}
+
+ var attemptedVoices []string
+ newAudioProvider = func(config *audio.Config) (audio.Provider, error) {
+ attemptedVoices = append(attemptedVoices, config.GeminiVoice)
+ return &fakeAudioProvider{
+ generateFunc: func(_ string, _ string) error {
+ if config.GeminiVoice == "Charon" {
+ return audio.ErrGeminiNoAudioData
+ }
+ return nil
+ },
+ }, nil
+ }
+
+ originalConfig := viper.New()
+ *originalConfig = *viper.GetViper()
+ defer func() {
+ *viper.GetViper() = *originalConfig
+ }()
+ viper.Reset()
+ viper.Set("audio.provider", "gemini")
+
+ flags := cli.NewFlags()
+ flags.OutputDir = t.TempDir()
+ flags.AudioProvider = "gemini"
+
+ p := NewProcessor(flags)
+ if err := p.generateGeminiAudioWithFallbacks("Charon", func(voice string) error {
+ return p.generateAudioWithVoice("ябълка", voice)
+ }); err != nil {
+ t.Fatalf("generateGeminiAudioWithFallbacks() unexpected error: %v", err)
+ }
+
+ if got, want := strings.Join(attemptedVoices, ","), "Charon,Kore"; got != want {
+ t.Fatalf("attempted voices = %q, want %q", got, want)
+ }
+
+ wordDir := p.findCardDirectory("ябълка")
+ if wordDir == "" {
+ t.Fatal("expected generated word directory")
+ }
+
+ metadataData, err := os.ReadFile(filepath.Join(wordDir, "audio_metadata.txt"))
+ if err != nil {
+ t.Fatalf("expected metadata file: %v", err)
+ }
+ metadata := string(metadataData)
+ if !strings.Contains(metadata, "voice=Kore") {
+ t.Fatalf("metadata should use the successful fallback voice: %q", metadata)
+ }
+}
+
func TestGenerateAudioBgBgUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) {
originalFactory := newAudioProvider
t.Cleanup(func() {
newAudioProvider = originalFactory
})
+ originalVoices := append([]string(nil), audio.GeminiVoices...)
+ t.Cleanup(func() {
+ audio.GeminiVoices = originalVoices
+ })
+ audio.GeminiVoices = []string{"sentinel-bg-gemini-voice"}
fakeProvider := &fakeAudioProvider{}
var capturedConfigs []*audio.Config
@@ -701,8 +778,8 @@ func TestGenerateAudioBgBgUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) {
t.Fatalf("captured config count = %d, want %d", len(capturedConfigs), 2)
}
for i, capturedConfig := range capturedConfigs {
- if capturedConfig.GeminiVoice != "" {
- t.Fatalf("captured config %d GeminiVoice = %q, want empty model-default voice", i, capturedConfig.GeminiVoice)
+ if capturedConfig.GeminiVoice != "sentinel-bg-gemini-voice" {
+ t.Fatalf("captured config %d GeminiVoice = %q, want %q", i, capturedConfig.GeminiVoice, "sentinel-bg-gemini-voice")
}
}
if fakeProvider.generateCalls != 2 {