summaryrefslogtreecommitdiff
path: root/internal/gui
diff options
context:
space:
mode:
Diffstat (limited to 'internal/gui')
-rw-r--r--internal/gui/app.go12
-rw-r--r--internal/gui/app_test.go26
-rw-r--r--internal/gui/audio_player.go85
-rw-r--r--internal/gui/audio_player_test.go60
-rw-r--r--internal/gui/generator.go177
-rw-r--r--internal/gui/generator_test.go87
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() {