diff options
Diffstat (limited to 'internal/gui')
| -rw-r--r-- | internal/gui/app.go | 12 | ||||
| -rw-r--r-- | internal/gui/app_test.go | 26 | ||||
| -rw-r--r-- | internal/gui/audio_player.go | 85 | ||||
| -rw-r--r-- | internal/gui/audio_player_test.go | 60 | ||||
| -rw-r--r-- | internal/gui/generator.go | 177 | ||||
| -rw-r--r-- | internal/gui/generator_test.go | 87 |
6 files changed, 330 insertions, 117 deletions
diff --git a/internal/gui/app.go b/internal/gui/app.go index 5d24e6d..96096f2 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -121,7 +121,7 @@ type Config struct { NanoBananaTextModel string // GeminiTTSModel selects the Gemini TTS model when Gemini audio is active. GeminiTTSModel string - // GeminiVoice selects a specific Gemini voice; empty uses the model default. + // GeminiVoice selects a specific Gemini voice; empty picks a random Gemini voice. GeminiVoice string TranslationProvider translation.Provider PhoneticProvider phonetic.Provider @@ -148,8 +148,8 @@ func DefaultConfig() *Config { NanoBananaTextModel: image.DefaultNanoBananaTextModel, GeminiTTSModel: audioDefaults.GeminiTTSModel, ImageProvider: imageProviderNanoBanana, - TranslationProvider: translation.ProviderOpenAI, - PhoneticProvider: phonetic.ProviderOpenAI, + TranslationProvider: translation.ProviderGemini, + PhoneticProvider: phonetic.ProviderGemini, AutoPlay: true, // Auto-play enabled by default } } @@ -236,8 +236,8 @@ func New(config *Config) *Application { } // translationConfigForApp normalizes the GUI translation settings. -// The GUI follows the shared translator defaults and stays on OpenAI unless a -// provider is explicitly selected by the caller. +// The GUI follows the shared translator defaults unless a provider is +// explicitly selected by the caller. func translationConfigForApp(config *Config) *translation.Config { if config == nil { config = DefaultConfig() @@ -245,7 +245,7 @@ func translationConfigForApp(config *Config) *translation.Config { provider := config.TranslationProvider if provider == "" { - provider = translation.ProviderOpenAI + provider = translation.ProviderGemini } return &translation.Config{ diff --git a/internal/gui/app_test.go b/internal/gui/app_test.go index 057c5e0..9ac0125 100644 --- a/internal/gui/app_test.go +++ b/internal/gui/app_test.go @@ -5,15 +5,19 @@ import ( "codeberg.org/snonux/totalrecall/internal/audio" "codeberg.org/snonux/totalrecall/internal/image" + "codeberg.org/snonux/totalrecall/internal/phonetic" "codeberg.org/snonux/totalrecall/internal/translation" ) -func TestDefaultConfigUsesOpenAITranslationProvider(t *testing.T) { +func TestDefaultConfigUsesGeminiLanguageProviders(t *testing.T) { config := DefaultConfig() audioDefaults := audio.DefaultProviderConfig() - if config.TranslationProvider != translation.ProviderOpenAI { - t.Fatalf("DefaultConfig() translation provider = %q, want %q", config.TranslationProvider, translation.ProviderOpenAI) + if config.TranslationProvider != translation.ProviderGemini { + t.Fatalf("DefaultConfig() translation provider = %q, want %q", config.TranslationProvider, translation.ProviderGemini) + } + if config.PhoneticProvider != phonetic.ProviderGemini { + t.Fatalf("DefaultConfig() phonetic provider = %q, want %q", config.PhoneticProvider, phonetic.ProviderGemini) } if config.ImageProvider != imageProviderNanoBanana { t.Fatalf("DefaultConfig() image provider = %q, want %q", config.ImageProvider, imageProviderNanoBanana) @@ -46,30 +50,30 @@ func TestTranslationConfigForApp(t *testing.T) { wantGoogle string }{ { - name: "default to openai when provider is unset and only openai key is available", + name: "default to gemini when provider is unset and only openai key is available", config: &Config{ OpenAIKey: "openai-key", }, - wantProv: translation.ProviderOpenAI, + wantProv: translation.ProviderGemini, wantOpen: "openai-key", wantGoogle: "", }, { - name: "default to openai when provider is unset and both keys are available", + name: "default to gemini when provider is unset and both keys are available", config: &Config{ OpenAIKey: "openai-key", GoogleAPIKey: "google-key", }, - wantProv: translation.ProviderOpenAI, + wantProv: translation.ProviderGemini, wantOpen: "openai-key", wantGoogle: "google-key", }, { - name: "default to openai when provider is unset and only google key is available", + name: "default to gemini when provider is unset and only google key is available", config: &Config{ GoogleAPIKey: "google-key", }, - wantProv: translation.ProviderOpenAI, + wantProv: translation.ProviderGemini, wantOpen: "", wantGoogle: "google-key", }, @@ -96,9 +100,9 @@ func TestTranslationConfigForApp(t *testing.T) { wantGoogle: "google-key", }, { - name: "nil config still uses openai defaults", + name: "nil config still uses gemini defaults", config: nil, - wantProv: translation.ProviderOpenAI, + wantProv: translation.ProviderGemini, wantOpen: "", wantGoogle: "", }, diff --git a/internal/gui/audio_player.go b/internal/gui/audio_player.go index a214ee0..ac8d819 100644 --- a/internal/gui/audio_player.go +++ b/internal/gui/audio_player.go @@ -1,6 +1,7 @@ package gui import ( + "errors" "fmt" "os" "os/exec" @@ -39,6 +40,11 @@ type AudioPlayer struct { autoPlayEnabled *bool // Pointer to parent's auto-play state } +type audioCommandCandidate struct { + name string + args []string +} + // NewAudioPlayer creates a new audio player widget func NewAudioPlayer() *AudioPlayer { p := &AudioPlayer{} @@ -349,33 +355,9 @@ func (p *AudioPlayer) startPlayback() error { // startPlaybackForFile starts playback of a specific audio file // This allows playing either front or back audio without modifying state func (p *AudioPlayer) startPlaybackForFile(audioFile string) error { - var cmd *exec.Cmd - - switch runtime.GOOS { - case "darwin": // macOS - cmd = exec.Command("afplay", audioFile) - case "linux": - // Try multiple commands in order of preference - // mpg123 first since it handles MP3 files best - if _, err := exec.LookPath("mpg123"); err == nil { - cmd = exec.Command("mpg123", "-q", audioFile) // -q for quiet mode - } else if _, err := exec.LookPath("ffplay"); err == nil { - cmd = exec.Command("ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", audioFile) - } else if _, err := exec.LookPath("play"); err == nil { - // SoX play command - cmd = exec.Command("play", "-q", audioFile) - } else if _, err := exec.LookPath("paplay"); err == nil { - cmd = exec.Command("paplay", audioFile) - } else if _, err := exec.LookPath("aplay"); err == nil { - cmd = exec.Command("aplay", "-q", audioFile) - } else { - return fmt.Errorf("no audio player found. Install mpg123, ffplay, sox, paplay, or aplay") - } - case "windows": - // Use Windows Media Player - cmd = exec.Command("cmd", "/c", "start", "/min", audioFile) - default: - return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + cmd, err := audioPlaybackCommand(runtime.GOOS, audioFile, exec.LookPath) + if err != nil { + return err } // Store the command so we can stop it later @@ -405,3 +387,52 @@ func (p *AudioPlayer) startPlaybackForFile(audioFile string) error { return nil } + +func audioPlaybackCommand(goos, audioFile string, lookPath func(string) (string, error)) (*exec.Cmd, error) { + switch goos { + case "darwin": + return exec.Command("afplay", audioFile), nil + case "linux": + return linuxAudioPlaybackCommand(audioFile, lookPath) + case "windows": + return exec.Command("cmd", "/c", "start", "/min", audioFile), nil + default: + return nil, fmt.Errorf("unsupported platform: %s", goos) + } +} + +func linuxAudioPlaybackCommand(audioFile string, lookPath func(string) (string, error)) (*exec.Cmd, error) { + candidates := linuxAudioCommandCandidates(audioFile) + for _, candidate := range candidates { + path, err := lookPath(candidate.name) + if err != nil { + continue + } + + args := append([]string(nil), candidate.args...) + return exec.Command(path, args...), nil + } + + return nil, errors.New("no compatible audio player found. Install ffplay, sox, paplay, aplay, or mpg123 for mp3 files") +} + +func linuxAudioCommandCandidates(audioFile string) []audioCommandCandidate { + ext := strings.ToLower(filepath.Ext(audioFile)) + switch ext { + case ".mp3": + return []audioCommandCandidate{ + {name: "mpg123", args: []string{"-q", audioFile}}, + {name: "ffplay", args: []string{"-nodisp", "-autoexit", "-loglevel", "quiet", audioFile}}, + {name: "play", args: []string{"-q", audioFile}}, + {name: "paplay", args: []string{audioFile}}, + {name: "aplay", args: []string{"-q", audioFile}}, + } + default: + return []audioCommandCandidate{ + {name: "ffplay", args: []string{"-nodisp", "-autoexit", "-loglevel", "quiet", audioFile}}, + {name: "play", args: []string{"-q", audioFile}}, + {name: "paplay", args: []string{audioFile}}, + {name: "aplay", args: []string{"-q", audioFile}}, + } + } +} diff --git a/internal/gui/audio_player_test.go b/internal/gui/audio_player_test.go new file mode 100644 index 0000000..2dd3b0c --- /dev/null +++ b/internal/gui/audio_player_test.go @@ -0,0 +1,60 @@ +package gui + +import ( + "errors" + "path/filepath" + "reflect" + "testing" +) + +func TestLinuxAudioCommandCandidates(t *testing.T) { + t.Run("mp3 prefers mpg123", func(t *testing.T) { + audioFile := "/tmp/audio.mp3" + got := linuxAudioCommandCandidates(audioFile) + want := []audioCommandCandidate{ + {name: "mpg123", args: []string{"-q", audioFile}}, + {name: "ffplay", args: []string{"-nodisp", "-autoexit", "-loglevel", "quiet", audioFile}}, + {name: "play", args: []string{"-q", audioFile}}, + {name: "paplay", args: []string{audioFile}}, + {name: "aplay", args: []string{"-q", audioFile}}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("linuxAudioCommandCandidates(mp3) mismatch\nwant: %#v\ngot: %#v", want, got) + } + }) + + t.Run("wav avoids mpg123", func(t *testing.T) { + audioFile := "/tmp/audio.wav" + got := linuxAudioCommandCandidates(audioFile) + if got[0].name != "ffplay" { + t.Fatalf("first wav candidate = %q, want %q", got[0].name, "ffplay") + } + for _, candidate := range got { + if candidate.name == "mpg123" { + t.Fatalf("wav candidates unexpectedly include mpg123: %#v", got) + } + } + }) +} + +func TestLinuxAudioPlaybackCommandUsesFormatCompatiblePlayer(t *testing.T) { + audioFile := "/tmp/audio.wav" + cmd, err := linuxAudioPlaybackCommand(audioFile, func(name string) (string, error) { + switch name { + case "ffplay": + return filepath.Join("/usr/bin", name), nil + default: + return "", errors.New("not found") + } + }) + if err != nil { + t.Fatalf("linuxAudioPlaybackCommand() unexpected error: %v", err) + } + + if got, want := filepath.Base(cmd.Path), "ffplay"; got != want { + t.Fatalf("command path base = %q, want %q", got, want) + } + if len(cmd.Args) < 2 || cmd.Args[len(cmd.Args)-1] != audioFile { + t.Fatalf("command args = %#v, want final arg %q", cmd.Args, audioFile) + } +} diff --git a/internal/gui/generator.go b/internal/gui/generator.go index f19f46e..afd0dd0 100644 --- a/internal/gui/generator.go +++ b/internal/gui/generator.go @@ -62,9 +62,11 @@ func (a *Application) audioVoiceAndSpeed() (string, float64) { switch a.audioProviderName() { case "gemini": if a.audioConfig != nil { - return strings.TrimSpace(a.audioConfig.GeminiVoice), a.geminiSpeed() + if voice := strings.TrimSpace(a.audioConfig.GeminiVoice); voice != "" { + return voice, a.geminiSpeed() + } } - return "", a.geminiSpeed() + return randomVoice(a.audioVoices()), a.geminiSpeed() default: return randomVoice(a.audioVoices()), randomOpenAISpeed() } @@ -77,11 +79,11 @@ func (a *Application) geminiSpeed() float64 { return audio.DefaultProviderConfig().GeminiSpeed } -func (a *Application) audioOutputFormat() string { - if a.audioProviderName() == "gemini" { - return "wav" - } +func (a *Application) geminiVoicePinned() bool { + return a != nil && a.audioConfig != nil && strings.TrimSpace(a.audioConfig.GeminiVoice) != "" +} +func (a *Application) audioOutputFormat() string { if a != nil && a.config != nil && strings.TrimSpace(a.config.AudioFormat) != "" { return a.config.AudioFormat } @@ -120,6 +122,42 @@ func (a *Application) audioConfigForGeneration(voice string, speed float64) audi return audioConfig } +func (a *Application) generateAudioFile(ctx context.Context, text, outputFile, voice string, speed float64) error { + audioConfig := a.audioConfigForGeneration(voice, speed) + + provider, err := newAudioProvider(&audioConfig) + if err != nil { + return err + } + + 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 { @@ -150,7 +188,6 @@ func (a *Application) generateAudio(ctx context.Context, word string, cardDir st } voice, speed := a.audioVoiceAndSpeed() - audioConfig := a.audioConfigForGeneration(voice, speed) // Log the audio generation details if isRegeneration { @@ -159,34 +196,37 @@ func (a *Application) generateAudio(ctx context.Context, word string, cardDir st fmt.Printf("Generating audio for '%s' with voice: %s, speed: %.2f\n", word, voice, speed) } - // Create audio provider - provider, err := newAudioProvider(&audioConfig) - if err != nil { - return "", err - } - // Use the provided card directory if cardDir == "" { return "", fmt.Errorf("card directory not provided") } // Generate filename in subdirectory - outputFile := filepath.Join(cardDir, fmt.Sprintf("audio.%s", audioConfig.OutputFormat)) + outputFile := filepath.Join(cardDir, fmt.Sprintf("audio.%s", a.audioOutputFormat())) - // Generate audio - err = provider.GenerateAudio(ctx, word, outputFile) + finalVoice := voice + var err error + if a.audioProviderName() == "gemini" && !a.geminiVoicePinned() { + finalVoice, err = a.generateGeminiAudioWithFallbacks(voice, func(candidate string) error { + return a.generateAudioFile(ctx, word, outputFile, candidate, speed) + }) + } else { + err = a.generateAudioFile(ctx, word, outputFile, voice, speed) + } if err != nil { return "", err } + audioConfig := a.audioConfigForGeneration(finalVoice, speed) + // Save audio attribution - if err := a.saveAudioAttribution(word, outputFile, voice, speed); err != nil { + if err := a.saveAudioAttribution(word, outputFile, finalVoice, speed); err != nil { // Non-fatal error, just log it fmt.Printf("Warning: Failed to save audio attribution: %v\n", err) } // Save voice metadata for GUI display - if err := a.saveAudioMetadata(cardDir, audioConfig, voice, speed, "en-bg", outputFile, ""); err != nil { + if err := a.saveAudioMetadata(cardDir, audioConfig, finalVoice, speed, "en-bg", outputFile, ""); err != nil { fmt.Printf("Warning: Failed to save audio metadata: %v\n", err) } @@ -203,29 +243,33 @@ func (a *Application) generateAudioFront(ctx context.Context, word string, cardD } voice, speed := a.audioVoiceAndSpeed() - audioConfig := a.audioConfigForGeneration(voice, speed) - - provider, err := newAudioProvider(&audioConfig) - if err != nil { - fmt.Printf("DEBUG (generateAudioFront): Failed to create audio provider: %v\n", err) - return "", err - } - fmt.Printf("DEBUG (generateAudioFront): Generating front audio for '%s' with voice: %s, speed: %.2f\n", word, voice, speed) fmt.Printf("Generating front audio for '%s' with voice: %s, speed: %.2f\n", word, voice, speed) - frontFile := filepath.Join(cardDir, fmt.Sprintf("audio_front.%s", audioConfig.OutputFormat)) + frontFile := filepath.Join(cardDir, fmt.Sprintf("audio_front.%s", a.audioOutputFormat())) fmt.Printf("DEBUG (generateAudioFront): Will write to: %s\n", frontFile) - if err := provider.GenerateAudio(ctx, word, frontFile); err != nil { + + finalVoice := voice + var err error + if a.audioProviderName() == "gemini" && !a.geminiVoicePinned() { + finalVoice, err = a.generateGeminiAudioWithFallbacks(voice, func(candidate string) error { + return a.generateAudioFile(ctx, word, frontFile, candidate, speed) + }) + } else { + err = a.generateAudioFile(ctx, word, frontFile, voice, speed) + } + if err != nil { return "", fmt.Errorf("failed to generate front audio: %w", err) } fmt.Printf("DEBUG (generateAudioFront): Successfully wrote front audio to: %s\n", frontFile) - if err := a.saveAudioAttribution(word, frontFile, voice, speed); err != nil { + audioConfig := a.audioConfigForGeneration(finalVoice, speed) + + if err := a.saveAudioAttribution(word, frontFile, finalVoice, speed); err != nil { fmt.Printf("Warning: Failed to save audio attribution: %v\n", err) } // Update metadata - if err := a.saveAudioMetadata(cardDir, audioConfig, voice, speed, "bg-bg", frontFile, a.currentAudioFileBack); err != nil { + if err := a.saveAudioMetadata(cardDir, audioConfig, finalVoice, speed, "bg-bg", frontFile, a.currentAudioFileBack); err != nil { fmt.Printf("Warning: Failed to save audio metadata: %v\n", err) } @@ -242,29 +286,33 @@ func (a *Application) generateAudioBack(ctx context.Context, text string, cardDi } voice, speed := a.audioVoiceAndSpeed() - audioConfig := a.audioConfigForGeneration(voice, speed) - - provider, err := newAudioProvider(&audioConfig) - if err != nil { - fmt.Printf("DEBUG (generateAudioBack): Failed to create audio provider: %v\n", err) - return "", err - } - fmt.Printf("DEBUG (generateAudioBack): Generating back audio for '%s' with voice: %s, speed: %.2f\n", text, voice, speed) fmt.Printf("Generating back audio for '%s' with voice: %s, speed: %.2f\n", text, voice, speed) - backFile := filepath.Join(cardDir, fmt.Sprintf("audio_back.%s", audioConfig.OutputFormat)) + backFile := filepath.Join(cardDir, fmt.Sprintf("audio_back.%s", a.audioOutputFormat())) fmt.Printf("DEBUG (generateAudioBack): Will write to: %s\n", backFile) - if err := provider.GenerateAudio(ctx, text, backFile); err != nil { + + finalVoice := voice + var err error + if a.audioProviderName() == "gemini" && !a.geminiVoicePinned() { + finalVoice, err = a.generateGeminiAudioWithFallbacks(voice, func(candidate string) error { + return a.generateAudioFile(ctx, text, backFile, candidate, speed) + }) + } else { + err = a.generateAudioFile(ctx, text, backFile, voice, speed) + } + if err != nil { return "", fmt.Errorf("failed to generate back audio: %w", err) } fmt.Printf("DEBUG (generateAudioBack): Successfully wrote back audio to: %s\n", backFile) - if err := a.saveAudioAttribution(text, backFile, voice, speed); err != nil { + audioConfig := a.audioConfigForGeneration(finalVoice, speed) + + if err := a.saveAudioAttribution(text, backFile, finalVoice, speed); err != nil { 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 { + if err := a.saveAudioMetadata(cardDir, audioConfig, finalVoice, speed, "bg-bg", a.currentAudioFile, backFile); err != nil { fmt.Printf("Warning: Failed to save audio metadata: %v\n", err) } @@ -278,37 +326,48 @@ func (a *Application) generateAudioBgBg(ctx context.Context, front, back, cardDi } voice, speed := a.audioVoiceAndSpeed() - audioConfig := a.audioConfigForGeneration(voice, speed) - - provider, err := newAudioProvider(&audioConfig) - if err != nil { - return "", "", err - } // Generate front audio fmt.Printf("Generating front audio for '%s' with voice: %s, speed: %.2f\n", front, voice, speed) - frontFile := filepath.Join(cardDir, fmt.Sprintf("audio_front.%s", audioConfig.OutputFormat)) - if err := provider.GenerateAudio(ctx, front, frontFile); err != nil { - return "", "", fmt.Errorf("failed to generate front audio: %w", err) + frontFile := filepath.Join(cardDir, fmt.Sprintf("audio_front.%s", a.audioOutputFormat())) + backFile := filepath.Join(cardDir, fmt.Sprintf("audio_back.%s", a.audioOutputFormat())) + + runPair := func(candidate string) error { + if err := a.generateAudioFile(ctx, front, frontFile, candidate, speed); err != nil { + return fmt.Errorf("failed to generate front audio: %w", err) + } + + fmt.Printf("Generating back audio for '%s' with voice: %s, speed: %.2f\n", back, candidate, speed) + if err := a.generateAudioFile(ctx, back, backFile, candidate, speed); err != nil { + return fmt.Errorf("failed to generate back audio: %w", err) + } + + return nil } - // Generate back audio - fmt.Printf("Generating back audio for '%s' with voice: %s, speed: %.2f\n", back, voice, speed) - backFile := filepath.Join(cardDir, fmt.Sprintf("audio_back.%s", audioConfig.OutputFormat)) - if err := provider.GenerateAudio(ctx, back, backFile); err != nil { - return frontFile, "", fmt.Errorf("failed to generate back audio: %w", err) + finalVoice := voice + var err error + if a.audioProviderName() == "gemini" && !a.geminiVoicePinned() { + finalVoice, err = a.generateGeminiAudioWithFallbacks(voice, runPair) + } else { + err = runPair(voice) } + if err != nil { + return "", "", err + } + + audioConfig := a.audioConfigForGeneration(finalVoice, speed) // Save audio attribution - if err := a.saveAudioAttribution(front, frontFile, voice, speed); err != nil { + if err := a.saveAudioAttribution(front, frontFile, finalVoice, speed); err != nil { fmt.Printf("Warning: Failed to save audio attribution: %v\n", err) } - if err := a.saveAudioAttribution(back, backFile, voice, speed); err != nil { + if err := a.saveAudioAttribution(back, backFile, finalVoice, speed); err != nil { fmt.Printf("Warning: Failed to save audio attribution: %v\n", err) } // Save metadata for both sides - if err := a.saveAudioMetadata(cardDir, audioConfig, voice, speed, "bg-bg", frontFile, backFile); err != nil { + if err := a.saveAudioMetadata(cardDir, audioConfig, finalVoice, speed, "bg-bg", frontFile, backFile); err != nil { fmt.Printf("Warning: Failed to save audio metadata: %v\n", err) } diff --git a/internal/gui/generator_test.go b/internal/gui/generator_test.go index 0c1ac41..2828337 100644 --- a/internal/gui/generator_test.go +++ b/internal/gui/generator_test.go @@ -61,6 +61,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 { @@ -69,6 +70,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 } @@ -254,7 +258,7 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) { } } -func TestGenerateAudioUsesGeminiModelDefaultVoiceAndAttribution(t *testing.T) { +func TestGenerateAudioUsesRandomGeminiVoiceAndAttribution(t *testing.T) { originalFactory := newAudioProvider t.Cleanup(func() { newAudioProvider = originalFactory @@ -284,7 +288,7 @@ func TestGenerateAudioUsesGeminiModelDefaultVoiceAndAttribution(t *testing.T) { app := &Application{ config: &Config{ OutputDir: tempDir, - AudioFormat: "wav", + AudioFormat: "mp3", }, audioConfig: &audio.Config{ Provider: "gemini", @@ -306,17 +310,17 @@ func TestGenerateAudioUsesGeminiModelDefaultVoiceAndAttribution(t *testing.T) { if capturedConfig.Provider != "gemini" { t.Fatalf("captured Provider = %q, want %q", capturedConfig.Provider, "gemini") } - 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 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(outputPath, "audio.wav") { - t.Fatalf("outputPath = %q, want a WAV output file", outputPath) + if !strings.HasSuffix(outputPath, "audio.mp3") { + t.Fatalf("outputPath = %q, want an MP3 output file", outputPath) } attrPath := audio.AttributionPath(outputPath) @@ -328,8 +332,8 @@ func TestGenerateAudioUsesGeminiModelDefaultVoiceAndAttribution(t *testing.T) { if !strings.Contains(attribution, "Audio generated by Google Gemini TTS") { t.Fatalf("gemini attribution missing header: %q", attribution) } - if strings.Contains(attribution, "sentinel-gemini-voice") { - t.Fatalf("gemini attribution should not use the shared voice list when voice is unset: %q", attribution) + if !strings.Contains(attribution, "sentinel-gemini-voice") { + t.Fatalf("gemini attribution should use the selected random Gemini voice: %q", attribution) } if !strings.Contains(attribution, "Processed text sent to TTS: ябълка...") { t.Fatalf("gemini attribution missing processed text: %q", attribution) @@ -340,13 +344,13 @@ func TestGenerateAudioUsesGeminiModelDefaultVoiceAndAttribution(t *testing.T) { t.Fatalf("expected metadata file: %v", err) } metadata := string(metadataData) - if !strings.Contains(metadata, "audio_file=audio.wav") { + if !strings.Contains(metadata, "audio_file=audio.mp3") { t.Fatalf("gemini metadata missing fresh audio file reference: %q", metadata) } - if !strings.Contains(metadata, "voice=model-default") { - t.Fatalf("gemini metadata missing model-default voice marker: %q", metadata) + if !strings.Contains(metadata, "voice=sentinel-gemini-voice") { + t.Fatalf("gemini metadata missing selected random voice: %q", metadata) } - if !strings.Contains(metadata, "format=wav") { + if !strings.Contains(metadata, "format=mp3") { t.Fatalf("gemini metadata missing format: %q", metadata) } if !strings.Contains(metadata, "cardtype=en-bg") { @@ -354,6 +358,61 @@ func TestGenerateAudioUsesGeminiModelDefaultVoiceAndAttribution(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 + } + + tempDir := t.TempDir() + outputPath := filepath.Join(tempDir, "audio.wav") + app := &Application{ + config: &Config{ + OutputDir: tempDir, + AudioFormat: "wav", + }, + audioConfig: &audio.Config{ + Provider: "gemini", + OutputDir: tempDir, + GoogleAPIKey: "google-key", + GeminiTTSModel: "gemini-2.5-flash-preview-tts", + }, + } + + voice, err := app.generateGeminiAudioWithFallbacks("Charon", func(candidate string) error { + return app.generateAudioFile(context.Background(), "ябълка", outputPath, candidate, 1.0) + }) + if err != nil { + t.Fatalf("generateGeminiAudioWithFallbacks() unexpected error: %v", err) + } + + if voice != "Kore" { + t.Fatalf("final voice = %q, want %q", voice, "Kore") + } + if got, want := strings.Join(attemptedVoices, ","), "Charon,Kore"; got != want { + t.Fatalf("attempted voices = %q, want %q", got, want) + } +} + func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) { originalFactory := newAudioProvider t.Cleanup(func() { |
