diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-02 16:36:25 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-02 16:36:25 +0300 |
| commit | 6bd23a588bacee2e8c75f477150b7e2d345002ff (patch) | |
| tree | 67bfa78afe206e7560350bdd52c60e0e80ec3243 | |
| parent | 6ce9123de04ffff961cbf1da73648679216ff637 (diff) | |
Release v0.9.0v0.9.0
25 files changed, 865 insertions, 265 deletions
@@ -8,7 +8,7 @@ It has mainly been vibe coded using Claude Code CLI. -⚠️ **Important:** Audio generation uses OpenAI services. The no-arg GUI image path uses Google Gemini Nano Banana by default, and explicit CLI runs can also use Nano Banana through `--image-api nanobanana` or `image.provider: nanobanana`. See [Quick Start](#quick-start) for setup instructions. +⚠️ **Important:** TotalRecall now uses Google Gemini APIs by default for audio, image generation, translation, and phonetic lookup. Optional OpenAI paths are still available when explicitly selected. See [Quick Start](#quick-start) for setup instructions. [<img src="assets/totalrecall.png" alt="TotalRecall screenshot">](assets/totalrecall.png) [🔊 Computer / Компютър audio example](assets/audio.mp3) (Download raw file, and then play locally) @@ -19,16 +19,19 @@ It has mainly been vibe coded using Claude Code CLI. ## Features -- Audio generation using **OpenAI TTS**: High-quality, natural-sounding voices (requires API key) - - Random voice selection by default for variety - - Option to generate in all 11 available voices +- Audio generation using **Google Gemini TTS** by default + - Uses a random Gemini voice by default unless you select a specific one + - Option to generate in all available voices + - Defaults to `mp3` output and auto-converts Gemini audio with `ffmpeg` to save space - Phoenetic pronunciation: - Fetches IPA (International Phonetic Alphabet) for each word + - Uses Gemini by default - Automatic Bulgarian to English translation - Saves translations to separate text files + - Uses Gemini by default - Image generation: - - **Google Gemini Nano Banana**: Default image path for the no-arg GUI launch - - **OpenAI DALL-E**: Explicit CLI image generation path + - **Google Gemini Nano Banana**: Default image path for GUI, CLI, and batch runs + - **OpenAI DALL-E**: Optional explicit CLI image generation path - **Config-driven selection**: Set `image.provider` to `openai` or `nanobanana` - Scene generation creates memorable contexts for each word - Batch processing of multiple words @@ -39,12 +42,15 @@ It has mainly been vibe coded using Claude Code CLI. ### Prerequisites -1. **For OpenAI TTS** (required for audio generation): +1. **For Gemini defaults** (audio, images, translation, phonetics): + - Create a Google AI Studio API key + - Set it with `export GOOGLE_API_KEY="..."` + - Install `ffmpeg` if you want the default space-saving `mp3` audio output + +2. **For optional OpenAI paths**: - Create an account at https://platform.openai.com - Generate an API key at https://platform.openai.com/api-keys - - Set the key using one of these methods: - - Environment variable: `export OPENAI_API_KEY="sk-..."` - - Configuration file: Add to `.totalrecall.yaml` + - Set it with `export OPENAI_API_KEY="sk-..."` ### Building from Source @@ -95,7 +101,7 @@ After installation, you may need to log out and log back in for the icon to appe ## Quick Start -**Note:** By default, totalrecall uses OpenAI for audio. The no-arg GUI image path uses Nano Banana, and explicit CLI runs can use either OpenAI or Nano Banana. Make sure to set your OpenAI API key and Google API key: +**Note:** By default, totalrecall uses Gemini TTS for audio, Nano Banana for images, and Gemini-backed translation/phonetic lookup. If you want the optional OpenAI paths, set your OpenAI API key too: ```bash export OPENAI_API_KEY="sk-..." export GOOGLE_API_KEY="..." @@ -118,7 +124,7 @@ Key features: ### CLI Mode -1. Generate materials for a single word. Explicit CLI image generation supports Nano Banana when selected via `--image-api nanobanana` or `image.provider: nanobanana`: +1. Generate materials for a single word. Explicit CLI image generation uses Nano Banana by default, and you can switch to OpenAI with `--image-api openai`: ```bash totalrecall ябълка ``` diff --git a/assets/config.yaml.example b/assets/config.yaml.example index dda16b6..9b4733f 100644 --- a/assets/config.yaml.example +++ b/assets/config.yaml.example @@ -6,8 +6,8 @@ audio: # Gemini is the default audio provider. Set this to openai to switch back. provider: gemini - # Gemini TTS writes WAV files. - format: wav + # Gemini TTS writes WAV natively and auto-converts to MP3 by default to save space. + format: mp3 # OpenAI TTS settings (used only when audio.provider is openai) openai_key: ${OPENAI_API_KEY} # Can also use environment variable @@ -17,19 +17,19 @@ audio: # Gemini TTS settings (used when audio.provider is gemini) gemini_tts_model: gemini-2.5-flash-preview-tts - gemini_voice: "" # Leave empty to use the model default voice + gemini_voice: "" # Leave empty to pick a random Gemini voice # Translation configuration translation: # Translation backend used by internal/translation/translator.go - # Supported values: openai (default) or gemini - provider: openai + # Supported values: gemini (default) or openai + provider: gemini # Phonetic configuration phonetic: # IPA phonetic backend used by internal/phonetic/fetcher.go - # Supported values: openai (default) or gemini - provider: openai + # Supported values: gemini (default) or openai + provider: gemini # Image configuration image: 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 {< |
