summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-02 16:36:25 +0300
committerPaul Buetow <paul@buetow.org>2026-04-02 16:36:25 +0300
commit6bd23a588bacee2e8c75f477150b7e2d345002ff (patch)
tree67bfa78afe206e7560350bdd52c60e0e80ec3243 /internal
parent6ce9123de04ffff961cbf1da73648679216ff637 (diff)
Release v0.9.0v0.9.0
Diffstat (limited to 'internal')
-rw-r--r--internal/audio/gemini_provider.go63
-rw-r--r--internal/audio/gemini_provider_test.go40
-rw-r--r--internal/audio/provider.go4
-rw-r--r--internal/audio/provider_test.go8
-rw-r--r--internal/audio/voices.go26
-rw-r--r--internal/audio/voices_test.go23
-rw-r--r--internal/cli/command.go9
-rw-r--r--internal/cli/command_test.go26
-rw-r--r--internal/cli/flags.go6
-rw-r--r--internal/cli/flags_test.go5
-rw-r--r--internal/gui/app.go12
-rw-r--r--internal/gui/app_test.go26
-rw-r--r--internal/gui/audio_player.go85
-rw-r--r--internal/gui/audio_player_test.go60
-rw-r--r--internal/gui/generator.go177
-rw-r--r--internal/gui/generator_test.go87
-rw-r--r--internal/phonetic/fetcher.go80
-rw-r--r--internal/phonetic/fetcher_test.go75
-rw-r--r--internal/processor/processor.go117
-rw-r--r--internal/processor/processor_test.go125
-rw-r--r--internal/translation/translator.go10
-rw-r--r--internal/translation/translator_test.go20
-rw-r--r--internal/version.go2
23 files changed, 840 insertions, 246 deletions
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.GeminiVoiceFallba