From 6bd23a588bacee2e8c75f477150b7e2d345002ff Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 2 Apr 2026 16:36:25 +0300 Subject: Release v0.9.0 --- internal/audio/gemini_provider.go | 63 ++++++++++-- internal/audio/gemini_provider_test.go | 40 +++++++- internal/audio/provider.go | 4 +- internal/audio/provider_test.go | 8 +- internal/audio/voices.go | 26 +++++ internal/audio/voices_test.go | 23 +++++ internal/cli/command.go | 9 +- internal/cli/command_test.go | 26 +++-- internal/cli/flags.go | 6 +- internal/cli/flags_test.go | 5 +- internal/gui/app.go | 12 +-- internal/gui/app_test.go | 26 +++-- internal/gui/audio_player.go | 85 ++++++++++----- internal/gui/audio_player_test.go | 60 +++++++++++ internal/gui/generator.go | 177 +++++++++++++++++++++----------- internal/gui/generator_test.go | 87 +++++++++++++--- internal/phonetic/fetcher.go | 80 ++++++++++++--- internal/phonetic/fetcher_test.go | 75 +++++++++++++- internal/processor/processor.go | 117 ++++++++++++++------- internal/processor/processor_test.go | 125 +++++++++++++++++----- internal/translation/translator.go | 10 +- internal/translation/translator_test.go | 20 ++-- internal/version.go | 2 +- 23 files changed, 840 insertions(+), 246 deletions(-) create mode 100644 internal/gui/audio_player_test.go (limited to 'internal') diff --git a/internal/audio/gemini_provider.go b/internal/audio/gemini_provider.go index 5662454..2893278 100644 --- a/internal/audio/gemini_provider.go +++ b/internal/audio/gemini_provider.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" "strings" @@ -21,6 +22,11 @@ const ( geminiTTSBitsPerSample = 16 ) +var ErrGeminiNoAudioData = errors.New("no audio data returned from Gemini") + +var execLookPath = exec.LookPath +var execCommand = exec.Command + // GeminiProvider implements Provider interface for Gemini TTS. type GeminiProvider struct { client *genai.Client @@ -173,7 +179,12 @@ func extractAudioData(response *genai.GenerateContentResponse) ([]byte, string, } } - return nil, "", errors.New("no audio data returned from Gemini") + return nil, "", ErrGeminiNoAudioData +} + +// IsGeminiNoAudioDataError reports whether the error means Gemini returned no audio payload. +func IsGeminiNoAudioDataError(err error) bool { + return errors.Is(err, ErrGeminiNoAudioData) } func writeGeminiAudioFile(outputFile string, audioData []byte, mimeType string) error { @@ -182,20 +193,22 @@ func writeGeminiAudioFile(outputFile string, audioData []byte, mimeType string) } ext := strings.ToLower(filepath.Ext(outputFile)) - if ext != ".wav" { - return fmt.Errorf("gemini TTS only supports .wav output files, got %q", outputFile) - } - encoded, err := encodePCMAsWAV(audioData) if err != nil { return err } - if err := os.WriteFile(outputFile, encoded, 0644); err != nil { - return fmt.Errorf("failed to write output file: %w", err) + switch ext { + case ".wav": + if err := os.WriteFile(outputFile, encoded, 0644); err != nil { + return fmt.Errorf("failed to write output file: %w", err) + } + return nil + case ".mp3": + return transcodeWAVToMP3(encoded, outputFile) + default: + return fmt.Errorf("gemini TTS only supports .wav and .mp3 output files, got %q", outputFile) } - - return nil } func ensureOutputDirectory(outputFile string) error { @@ -263,3 +276,35 @@ func encodePCMAsWAV(pcmData []byte) ([]byte, error) { return buffer.Bytes(), nil } + +func transcodeWAVToMP3(wavData []byte, outputFile string) error { + ffmpegPath, err := execLookPath("ffmpeg") + if err != nil { + return fmt.Errorf("ffmpeg is required to convert Gemini audio to mp3: %w", err) + } + + cmd := execCommand( + ffmpegPath, + "-nostdin", + "-hide_banner", + "-loglevel", "error", + "-y", + "-f", "wav", + "-i", "pipe:0", + "-codec:a", "libmp3lame", + "-q:a", "4", + outputFile, + ) + cmd.Stdin = bytes.NewReader(wavData) + + output, err := cmd.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + message = err.Error() + } + return fmt.Errorf("failed to convert Gemini audio to mp3: %s", message) + } + + return nil +} diff --git a/internal/audio/gemini_provider_test.go b/internal/audio/gemini_provider_test.go index ff0b2f5..66f0a74 100644 --- a/internal/audio/gemini_provider_test.go +++ b/internal/audio/gemini_provider_test.go @@ -219,16 +219,50 @@ func TestWriteGeminiAudioFileWritesWAV(t *testing.T) { } } -func TestWriteGeminiAudioFileRejectsUnsupportedFormats(t *testing.T) { +func TestWriteGeminiAudioFileWritesMP3ViaFFmpeg(t *testing.T) { dir := t.TempDir() outputFile := filepath.Join(dir, "output.mp3") + ffmpegScript := filepath.Join(dir, "ffmpeg") + script := "#!/bin/sh\nout=\"\"\nfor arg in \"$@\"; do out=\"$arg\"; done\ncat >/dev/null\nprintf 'mp3' > \"$out\"\n" + if err := os.WriteFile(ffmpegScript, []byte(script), 0755); err != nil { + t.Fatalf("failed to write fake ffmpeg script: %v", err) + } + + originalLookPath := execLookPath + execLookPath = func(file string) (string, error) { + if file == "ffmpeg" { + return ffmpegScript, nil + } + return originalLookPath(file) + } + t.Cleanup(func() { + execLookPath = originalLookPath + }) + + if err := writeGeminiAudioFile(outputFile, []byte{0x11, 0x22}, "audio/pcm"); err != nil { + t.Fatalf("writeGeminiAudioFile() unexpected error: %v", err) + } + + fileData, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("ReadFile() unexpected error: %v", err) + } + if string(fileData) != "mp3" { + t.Fatalf("output file = %q, want fake mp3 payload", string(fileData)) + } +} + +func TestWriteGeminiAudioFileRejectsUnsupportedFormats(t *testing.T) { + dir := t.TempDir() + outputFile := filepath.Join(dir, "output.flac") + err := writeGeminiAudioFile(outputFile, []byte{0x11, 0x22}, "audio/pcm") if err == nil { - t.Fatal("writeGeminiAudioFile() expected error for non-wav output") + t.Fatal("writeGeminiAudioFile() expected error for unsupported output") } - if !strings.Contains(err.Error(), "only supports .wav output files") { + if !strings.Contains(err.Error(), "only supports .wav and .mp3 output files") { t.Fatalf("writeGeminiAudioFile() error = %v, want unsupported-format message", err) } diff --git a/internal/audio/provider.go b/internal/audio/provider.go index b7f6bd9..4fdccfb 100644 --- a/internal/audio/provider.go +++ b/internal/audio/provider.go @@ -33,7 +33,7 @@ type Config struct { // Gemini-specific settings GoogleAPIKey string GeminiTTSModel string // "gemini-2.5-flash-preview-tts" - GeminiVoice string // One of GeminiVoices, or empty for the model default. + GeminiVoice string // One of GeminiVoices; empty lets the caller choose a random voice. GeminiSpeed float64 // Prompt hint for desired speech speed } @@ -42,7 +42,7 @@ func DefaultProviderConfig() *Config { return &Config{ Provider: "gemini", OutputDir: "./", - OutputFormat: "wav", + OutputFormat: "mp3", OpenAIModel: "gpt-4o-mini-tts", // New model with voice instructions support OpenAIVoice: "alloy", OpenAISpeed: 1.0, diff --git a/internal/audio/provider_test.go b/internal/audio/provider_test.go index c13e823..a08b7a6 100644 --- a/internal/audio/provider_test.go +++ b/internal/audio/provider_test.go @@ -36,8 +36,8 @@ func TestDefaultProviderConfig(t *testing.T) { t.Errorf("Expected provider 'gemini', got '%s'", config.Provider) } - if config.OutputFormat != "wav" { - t.Errorf("Expected output format 'wav', got '%s'", config.OutputFormat) + if config.OutputFormat != "mp3" { + t.Errorf("Expected output format 'mp3', got '%s'", config.OutputFormat) } if config.OpenAIModel != "gpt-4o-mini-tts" { @@ -73,8 +73,8 @@ func TestDefaultProviderConfigIsGeminiCompatible(t *testing.T) { } outputFile := filepath.Join(t.TempDir(), "audio."+config.OutputFormat) - if filepath.Ext(outputFile) != ".wav" { - t.Fatalf("DefaultProviderConfig() output file %q is incompatible with Gemini TTS", outputFile) + if filepath.Ext(outputFile) != ".mp3" { + t.Fatalf("DefaultProviderConfig() output file %q does not use the default mp3 extension", outputFile) } if !strings.HasSuffix(config.GeminiTTSModel, "-tts") { diff --git a/internal/audio/voices.go b/internal/audio/voices.go index 9a96c76..2f6b5aa 100644 --- a/internal/audio/voices.go +++ b/internal/audio/voices.go @@ -1,5 +1,7 @@ package audio +import "strings" + // OpenAIVoices lists the OpenAI voices supported by the app. var OpenAIVoices = []string{ "alloy", @@ -44,3 +46,27 @@ var GeminiVoices = []string{ "Vindemiatrix", "Zubenelgenubi", } + +// GeminiVoiceFallbacks returns the selected voice first, followed by the remaining known Gemini voices. +func GeminiVoiceFallbacks(selected string) []string { + selected = strings.TrimSpace(selected) + if selected == "" { + return append([]string(nil), GeminiVoices...) + } + + fallbacks := []string{selected} + seen := map[string]struct{}{selected: {}} + for _, voice := range GeminiVoices { + voice = strings.TrimSpace(voice) + if voice == "" { + continue + } + if _, ok := seen[voice]; ok { + continue + } + fallbacks = append(fallbacks, voice) + seen[voice] = struct{}{} + } + + return fallbacks +} diff --git a/internal/audio/voices_test.go b/internal/audio/voices_test.go index 121f328..ea0f797 100644 --- a/internal/audio/voices_test.go +++ b/internal/audio/voices_test.go @@ -34,3 +34,26 @@ func TestVoiceLists(t *testing.T) { }) } } + +func TestGeminiVoiceFallbacks(t *testing.T) { + t.Parallel() + + t.Run("selected voice comes first", func(t *testing.T) { + t.Parallel() + + got := GeminiVoiceFallbacks("Kore") + wantPrefix := []string{"Kore", "Zephyr", "Puck", "Charon"} + if !reflect.DeepEqual(got[:len(wantPrefix)], wantPrefix) { + t.Fatalf("GeminiVoiceFallbacks() prefix mismatch\nwant: %#v\ngot: %#v", wantPrefix, got[:len(wantPrefix)]) + } + }) + + t.Run("empty selection returns known voices", func(t *testing.T) { + t.Parallel() + + got := GeminiVoiceFallbacks("") + if !reflect.DeepEqual(got, GeminiVoices) { + t.Fatalf("GeminiVoiceFallbacks() mismatch\nwant: %#v\ngot: %#v", GeminiVoices, got) + } + }) +} diff --git a/internal/cli/command.go b/internal/cli/command.go index 621d43a..2c7ee04 100644 --- a/internal/cli/command.go +++ b/internal/cli/command.go @@ -22,7 +22,7 @@ func CreateRootCommand(flags *Flags) *cobra.Command { Long: `totalrecall generates Anki flashcard materials from Bulgarian words. It creates audio pronunciation files using Gemini TTS by default and downloads -representative images. Launching with no arguments opens the interactive GUI, which uses Nano Banana for images by default. Explicit CLI runs can use OpenAI or Nano Banana via --image-api, and audio can be switched between Gemini and OpenAI with --audio-provider. +representative images. Launching with no arguments opens the interactive GUI, which uses Nano Banana for images by default. Explicit CLI and batch runs also use Nano Banana by default, and can be switched to OpenAI via --image-api openai. Audio can be switched between Gemini and OpenAI with --audio-provider. Gemini audio model and voice flags are available for Gemini TTS generation. @@ -58,8 +58,8 @@ func setupFlags(cmd *cobra.Command, flags *Flags) { // Local flags cmd.Flags().StringVarP(&flags.OutputDir, "output", "o", defaultOutputDir, "Output directory") - cmd.Flags().StringVarP(&flags.AudioFormat, "format", "f", flags.AudioFormat, "Audio format (wav or mp3; Gemini TTS always writes wav)") - cmd.Flags().StringVar(&flags.ImageAPI, "image-api", flags.ImageAPI, "Image source for explicit CLI runs (OpenAI or Nano Banana; config file image.provider also applies when unset)") + cmd.Flags().StringVarP(&flags.AudioFormat, "format", "f", flags.AudioFormat, "Audio format (wav or mp3; Gemini TTS writes wav natively and auto-converts to mp3 with ffmpeg, which is now the default)") + cmd.Flags().StringVar(&flags.ImageAPI, "image-api", flags.ImageAPI, "Image source for explicit CLI runs (default: Nano Banana; use openai to switch, config file image.provider also applies when unset)") cmd.Flags().StringVar(&flags.BatchFile, "batch", "", "Process words from file (one per line)") cmd.Flags().BoolVar(&flags.SkipAudio, "skip-audio", false, "Skip audio generation") cmd.Flags().BoolVar(&flags.SkipImages, "skip-images", false, "Skip image download") @@ -100,6 +100,7 @@ func setupFlags(cmd *cobra.Command, flags *Flags) { // MarkExplicitFlagValues records which CLI flags were explicitly set by the user. func MarkExplicitFlagValues(cmd *cobra.Command, flags *Flags) { + flags.AudioFormatSpecified = cmd.Flags().Changed("format") flags.ImageAPISpecified = cmd.Flags().Changed("image-api") flags.NanoBananaModelSpecified = cmd.Flags().Changed("nanobanana-model") flags.NanoBananaTextModelSpecified = cmd.Flags().Changed("nanobanana-text-model") @@ -203,5 +204,5 @@ func openAIVoiceUsage() string { } func geminiVoiceUsage() string { - return "Gemini voice: " + strings.Join(audio.GeminiVoices, ", ") + " (default: model default)" + return "Gemini voice: " + strings.Join(audio.GeminiVoices, ", ") + " (default: random)" } diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index 212c549..afc1e18 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -28,8 +28,8 @@ func TestCreateRootCommand(t *testing.T) { if !strings.Contains(cmd.Long, "uses Nano Banana for images by default") { t.Errorf("Expected Long description to describe the Nano Banana GUI default") } - if !strings.Contains(cmd.Long, "Explicit CLI runs can use OpenAI or Nano Banana via --image-api") { - t.Errorf("Expected Long description to describe explicit CLI Nano Banana support") + if !strings.Contains(cmd.Long, "Explicit CLI and batch runs also use Nano Banana by default") { + t.Errorf("Expected Long description to describe the CLI and batch Nano Banana default") } // Test that flags are set up @@ -111,11 +111,11 @@ func TestSetupFlags(t *testing.T) { if imageAPIFlag == nil { t.Fatal("image-api flag not found") } - if imageAPIFlag.DefValue != "openai" { - t.Errorf("Expected default image-api to be openai, got %s", imageAPIFlag.DefValue) + if imageAPIFlag.DefValue != "nanobanana" { + t.Errorf("Expected default image-api to be nanobanana, got %s", imageAPIFlag.DefValue) } - if imageAPIFlag.Usage != "Image source for explicit CLI runs (OpenAI or Nano Banana; config file image.provider also applies when unset)" { - t.Errorf("Expected image-api help to describe CLI Nano Banana support and config fallback, got %q", imageAPIFlag.Usage) + if imageAPIFlag.Usage != "Image source for explicit CLI runs (default: Nano Banana; use openai to switch, config file image.provider also applies when unset)" { + t.Errorf("Expected image-api help to describe the CLI Nano Banana default and config fallback, got %q", imageAPIFlag.Usage) } openAIVoiceFlag := cmd.Flags().Lookup("openai-voice") @@ -150,8 +150,8 @@ func TestSetupFlags(t *testing.T) { if geminiVoiceFlag.DefValue != "" { t.Errorf("Expected default gemini-voice to be empty, got %q", geminiVoiceFlag.DefValue) } - if !strings.Contains(geminiVoiceFlag.Usage, "default: model default") { - t.Errorf("Expected gemini-voice help to describe the model default voice, got %q", geminiVoiceFlag.Usage) + if !strings.Contains(geminiVoiceFlag.Usage, "default: random") { + t.Errorf("Expected gemini-voice help to describe the random default voice, got %q", geminiVoiceFlag.Usage) } nanoBananaModelFlag := cmd.Flags().Lookup("nanobanana-model") @@ -490,8 +490,8 @@ func TestBindFlagsToViper(t *testing.T) { if viper.GetString("image.nanobanana_text_model") != "gemini-2.5-flash" { t.Errorf("Expected image.nanobanana_text_model to be gemini-2.5-flash, got %s", viper.GetString("image.nanobanana_text_model")) } - if viper.GetString("image.provider") != "openai" { - t.Errorf("Expected image.provider to be openai by default, got %s", viper.GetString("image.provider")) + if viper.GetString("image.provider") != "nanobanana" { + t.Errorf("Expected image.provider to be nanobanana by default, got %s", viper.GetString("image.provider")) } } @@ -503,6 +503,9 @@ func TestMarkExplicitFlagValues(t *testing.T) { if err := cmd.Flags().Set("image-api", "nanobanana"); err != nil { t.Fatalf("Failed to set image-api flag: %v", err) } + if err := cmd.Flags().Set("format", "mp3"); err != nil { + t.Fatalf("Failed to set format flag: %v", err) + } if err := cmd.Flags().Set("nanobanana-model", defaultNanoBananaModel); err != nil { t.Fatalf("Failed to set nanobanana-model flag: %v", err) } @@ -512,6 +515,9 @@ func TestMarkExplicitFlagValues(t *testing.T) { MarkExplicitFlagValues(cmd, flags) + if !flags.AudioFormatSpecified { + t.Error("Expected AudioFormatSpecified to be true") + } if !flags.ImageAPISpecified { t.Error("Expected ImageAPISpecified to be true") } diff --git a/internal/cli/flags.go b/internal/cli/flags.go index ac47628..0dad679 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -13,6 +13,8 @@ type Flags struct { CfgFile string OutputDir string AudioFormat string + // AudioFormatSpecified records whether the audio format was explicitly set on the CLI. + AudioFormatSpecified bool // AudioProvider selects the text-to-speech backend ("gemini" or "openai"). AudioProvider string ImageAPI string @@ -43,7 +45,7 @@ type Flags struct { // Gemini audio flags // GeminiTTSModel is the Gemini TTS model used when Gemini audio is selected. 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 // NanoBananaModel is the Gemini image model used for Nano Banana generation. @@ -63,7 +65,7 @@ func NewFlags() *Flags { return &Flags{ AudioFormat: defaults.OutputFormat, AudioProvider: defaults.Provider, - ImageAPI: "openai", + ImageAPI: "nanobanana", DeckName: "Bulgarian Vocabulary", OpenAIModel: "gpt-4o-mini-tts", OpenAISpeed: 0.9, diff --git a/internal/cli/flags_test.go b/internal/cli/flags_test.go index 79838c4..4b59bee 100644 --- a/internal/cli/flags_test.go +++ b/internal/cli/flags_test.go @@ -17,8 +17,9 @@ func TestNewFlags(t *testing.T) { expected interface{} }{ {"AudioFormat", flags.AudioFormat, audio.DefaultProviderConfig().OutputFormat}, + {"AudioFormatSpecified", flags.AudioFormatSpecified, false}, {"AudioProvider", flags.AudioProvider, audio.DefaultProviderConfig().Provider}, - {"ImageAPI", flags.ImageAPI, "openai"}, + {"ImageAPI", flags.ImageAPI, "nanobanana"}, {"ImageAPISpecified", flags.ImageAPISpecified, false}, {"NanoBananaModelSpecified", flags.NanoBananaModelSpecified, false}, {"NanoBananaTextModelSpecified", flags.NanoBananaTextModelSpecified, false}, @@ -92,7 +93,7 @@ func TestFlagsStructure(t *testing.T) { flagsType := reflect.TypeOf(*flags) expectedFields := []string{ - "CfgFile", "OutputDir", "AudioFormat", "AudioProvider", "ImageAPI", "ImageAPISpecified", "BatchFile", + "CfgFile", "OutputDir", "AudioFormat", "AudioFormatSpecified", "AudioProvider", "ImageAPI", "ImageAPISpecified", "BatchFile", "SkipAudio", "SkipImages", "GenerateAnki", "AnkiCSV", "DeckName", "ListModels", "AllVoices", "NoAutoPlay", "OpenAIModel", "OpenAIVoice", "OpenAISpeed", "OpenAIInstruction", 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() { diff --git a/internal/phonetic/fetcher.go b/internal/phonetic/fetcher.go index 5d39fc3..0346213 100644 --- a/internal/phonetic/fetcher.go +++ b/internal/phonetic/fetcher.go @@ -2,9 +2,11 @@ package phonetic import ( "context" + "errors" "fmt" "os" "path/filepath" + "regexp" "strings" "time" @@ -21,11 +23,15 @@ const ( defaultGeminiModel = "gemini-2.5-flash" defaultOpenAIModel = openai.GPT4o phoneticTimeout = 30 * time.Second + phoneticRetryCount = 3 phoneticTemperature = 0.3 phoneticMaxTokens = 50 phoneticSystemPrompt = "You are a Bulgarian language expert. Provide only the IPA (International Phonetic Alphabet) transcription for Bulgarian words. Return ONLY the IPA transcription in square brackets, nothing else. No explanations, no word labels, just the IPA." ) +var geminiIPAPattern = regexp.MustCompile(`\[[^\[\]\n]+\]`) +var errNoGeminiPhoneticResponse = errors.New("no response from Gemini") + // Provider selects the phonetic backend. type Provider string @@ -81,22 +87,19 @@ var fetchOpenAIPhonetic = func(ctx context.Context, client *openai.Client, word var fetchGeminiPhonetic = func(ctx context.Context, client *genai.Client, word string) (string, error) { temp := float32(phoneticTemperature) resp, err := client.Models.GenerateContent(ctx, defaultGeminiModel, []*genai.Content{ - genai.NewContentFromText(word, genai.RoleUser), + genai.NewContentFromText(buildGeminiPhoneticPrompt(word), genai.RoleUser), }, &genai.GenerateContentConfig{ - SystemInstruction: genai.NewContentFromText(phoneticSystemPrompt, genai.RoleUser), - Temperature: &temp, - MaxOutputTokens: phoneticMaxTokens, + SystemInstruction: &genai.Content{ + Parts: []*genai.Part{{Text: phoneticSystemPrompt}}, + }, + Temperature: &temp, + MaxOutputTokens: phoneticMaxTokens, }) if err != nil { return "", fmt.Errorf("gemini API error: %w", err) } - phoneticInfo := strings.TrimSpace(resp.Text()) - if phoneticInfo == "" { - return "", fmt.Errorf("no response from Gemini") - } - - return phoneticInfo, nil + return normalizeGeminiPhoneticResponse(resp.Text()) } // NewFetcher creates a new phonetic information fetcher. @@ -190,12 +193,63 @@ func (f *Fetcher) fetchWithGemini(ctx context.Context, word string) (string, err return "", fmt.Errorf("gemini client not initialized") } - return fetchGeminiPhonetic(ctx, f.geminiClient, word) + var lastErr error + for attempt := 0; attempt < phoneticRetryCount; attempt++ { + phoneticInfo, err := fetchGeminiPhonetic(ctx, f.geminiClient, word) + if err == nil { + return phoneticInfo, nil + } + if !errors.Is(err, errNoGeminiPhoneticResponse) { + return "", err + } + + lastErr = err + } + + if lastErr != nil { + return "", lastErr + } + + return "", errNoGeminiPhoneticResponse +} + +func buildGeminiPhoneticPrompt(word string) string { + return fmt.Sprintf("Bulgarian text or phrase:\n%s\n\nReturn only its IPA transcription in square brackets.", strings.TrimSpace(word)) +} + +func normalizeGeminiPhoneticResponse(raw string) (string, error) { + trimmed := stripMarkdownCodeFence(strings.TrimSpace(raw)) + if trimmed == "" { + return "", errNoGeminiPhoneticResponse + } + + if match := geminiIPAPattern.FindString(trimmed); match != "" { + return match, nil + } + + return trimmed, nil +} + +func stripMarkdownCodeFence(raw string) string { + trimmed := strings.TrimSpace(raw) + if !strings.HasPrefix(trimmed, "```") { + return trimmed + } + + trimmed = strings.TrimPrefix(trimmed, "```") + if newline := strings.Index(trimmed, "\n"); newline >= 0 { + trimmed = trimmed[newline+1:] + } + if closing := strings.LastIndex(trimmed, "```"); closing >= 0 { + trimmed = trimmed[:closing] + } + + return strings.TrimSpace(trimmed) } func normalizeConfig(config *Config) Config { normalized := Config{ - Provider: ProviderOpenAI, + Provider: ProviderGemini, OpenAIKey: "", GoogleAPIKey: "", } @@ -214,7 +268,7 @@ func normalizeConfig(config *Config) Config { func normalizeProvider(provider Provider) Provider { normalized := Provider(strings.ToLower(strings.TrimSpace(string(provider)))) if normalized == "" { - return ProviderOpenAI + return ProviderGemini } return normalized diff --git a/internal/phonetic/fetcher_test.go b/internal/phonetic/fetcher_test.go index 7fa74e8..0b92657 100644 --- a/internal/phonetic/fetcher_test.go +++ b/internal/phonetic/fetcher_test.go @@ -2,6 +2,7 @@ package phonetic import ( "context" + "errors" "os" "path/filepath" "testing" @@ -10,15 +11,15 @@ import ( "google.golang.org/genai" ) -func TestNewFetcher_DefaultsToOpenAI(t *testing.T) { +func TestNewFetcher_DefaultsToGemini(t *testing.T) { fetcher := NewFetcher(nil) if fetcher == nil { t.Fatal("NewFetcher returned nil") } - if got := fetcher.Provider(); got != ProviderOpenAI { - t.Fatalf("expected default provider %q, got %q", ProviderOpenAI, got) + if got := fetcher.Provider(); got != ProviderGemini { + t.Fatalf("expected default provider %q, got %q", ProviderGemini, got) } } @@ -241,3 +242,71 @@ func TestFetchAndSave_GeminiAPIFailure(t *testing.T) { t.Fatalf("unexpected Gemini API error: %v", err) } } + +func TestNormalizeGeminiPhoneticResponse(t *testing.T) { + t.Run("extracts bracketed ipa from prose", func(t *testing.T) { + got, err := normalizeGeminiPhoneticResponse("IPA: [ˈkotka]") + if err != nil { + t.Fatalf("normalizeGeminiPhoneticResponse() unexpected error: %v", err) + } + if got != "[ˈkotka]" { + t.Fatalf("normalizeGeminiPhoneticResponse() = %q, want %q", got, "[ˈkotka]") + } + }) + + t.Run("strips markdown fences", func(t *testing.T) { + got, err := normalizeGeminiPhoneticResponse("```text\n[ˈjabəɫkɐ]\n```") + if err != nil { + t.Fatalf("normalizeGeminiPhoneticResponse() unexpected error: %v", err) + } + if got != "[ˈjabəɫkɐ]" { + t.Fatalf("normalizeGeminiPhoneticResponse() = %q, want %q", got, "[ˈjabəɫkɐ]") + } + }) + + t.Run("empty response is retryable", func(t *testing.T) { + _, err := normalizeGeminiPhoneticResponse(" ") + if !errors.Is(err, errNoGeminiPhoneticResponse) { + t.Fatalf("normalizeGeminiPhoneticResponse() error = %v, want %v", err, errNoGeminiPhoneticResponse) + } + }) +} + +func TestFetch_GeminiProviderRetriesEmptyResponse(t *testing.T) { + originalFetch := fetchGeminiPhonetic + attempts := 0 + fetchGeminiPhonetic = func(context.Context, *genai.Client, string) (string, error) { + attempts++ + if attempts < 3 { + return "", errNoGeminiPhoneticResponse + } + return "[ˈkotka]", nil + } + t.Cleanup(func() { + fetchGeminiPhonetic = originalFetch + }) + + originalNewGeminiClient := newGeminiClient + newGeminiClient = func(context.Context, *genai.ClientConfig) (*genai.Client, error) { + return &genai.Client{}, nil + } + t.Cleanup(func() { + newGeminiClient = originalNewGeminiClient + }) + + fetcher := NewFetcher(&Config{ + Provider: ProviderGemini, + GoogleAPIKey: "test-google-key", + }) + + got, err := fetcher.Fetch("котка") + if err != nil { + t.Fatalf("Fetch failed: %v", err) + } + if got != "[ˈkotka]" { + t.Fatalf("unexpected phonetic content %q", got) + } + if attempts != phoneticRetryCount { + t.Fatalf("attempt count = %d, want %d", attempts, phoneticRetryCount) + } +} 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("c