From faa2465955445ac1cb1461eb70a98280d32fbf9a Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 1 Apr 2026 22:31:06 +0300 Subject: Wire Gemini audio into GUI --- internal/gui/app.go | 86 +++++++++++++++---- internal/gui/app_test.go | 11 +++ internal/gui/generator.go | 182 +++++++++++++++++++++++++++++++---------- internal/gui/generator_test.go | 78 ++++++++++++++++++ 4 files changed, 296 insertions(+), 61 deletions(-) (limited to 'internal/gui') diff --git a/internal/gui/app.go b/internal/gui/app.go index 8c9d6f4..a96c627 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -107,11 +107,17 @@ type Application struct { // Config holds GUI application configuration type Config struct { - OutputDir string - AudioFormat string - ImageProvider string - OpenAIKey string - GoogleAPIKey string + OutputDir string + AudioFormat string + // AudioProvider selects the TTS backend used by the GUI. + AudioProvider string + ImageProvider string + OpenAIKey string + GoogleAPIKey string + // GeminiTTSModel selects the Gemini TTS model when Gemini audio is active. + GeminiTTSModel string + // GeminiVoice selects a specific Gemini voice; empty uses a random shared voice. + GeminiVoice string TranslationProvider translation.Provider PhoneticProvider phonetic.Provider AutoPlay bool // Whether to automatically play audio when generated or navigated to @@ -127,10 +133,13 @@ func DefaultConfig() *Config { homeDir, _ := os.UserHomeDir() // Use XDG Base Directory specification for state data outputDir := filepath.Join(homeDir, ".local", "state", "totalrecall", "cards") + audioDefaults := audio.DefaultProviderConfig() return &Config{ OutputDir: outputDir, - AudioFormat: "mp3", + AudioFormat: audioDefaults.OutputFormat, + AudioProvider: audioDefaults.Provider, + GeminiTTSModel: audioDefaults.GeminiTTSModel, ImageProvider: imageProviderNanoBanana, TranslationProvider: translation.ProviderOpenAI, PhoneticProvider: phonetic.ProviderOpenAI, @@ -145,15 +154,25 @@ func New(config *Config) *Application { } else { // Fill in missing fields with defaults defaults := DefaultConfig() + if config.AudioProvider == "" { + config.AudioProvider = defaults.AudioProvider + } if config.OutputDir == "" { config.OutputDir = defaults.OutputDir } if config.AudioFormat == "" { - config.AudioFormat = defaults.AudioFormat + if strings.EqualFold(config.AudioProvider, "gemini") { + config.AudioFormat = defaults.AudioFormat + } else { + config.AudioFormat = "mp3" + } } if config.ImageProvider == "" { config.ImageProvider = defaults.ImageProvider } + if config.GeminiTTSModel == "" { + config.GeminiTTSModel = defaults.GeminiTTSModel + } // Don't override AutoPlay if it's explicitly set to false // (since bool zero value is false, we can't distinguish between unset and false) } @@ -184,16 +203,7 @@ func New(config *Config) *Application { app.queue.SetCallbacks(app.onQueueStatusUpdate, app.onJobComplete) // Set up audio configuration - app.audioConfig = &audio.Config{ - Provider: "openai", - OutputDir: config.OutputDir, - OutputFormat: config.AudioFormat, - OpenAIKey: config.OpenAIKey, - OpenAIModel: "gpt-4o-mini-tts", - OpenAIVoice: "nova", - OpenAISpeed: 0.9, - OpenAIInstruction: "You are speaking Bulgarian language (български език). Pronounce the Bulgarian text with authentic Bulgarian phonetics, not Russian. Speak slowly and clearly for language learners.", - } + app.audioConfig = audioConfigForApp(config) app.phoneticFetcher = phonetic.NewFetcher(&phonetic.Config{ Provider: config.PhoneticProvider, OpenAIKey: config.OpenAIKey, @@ -232,6 +242,48 @@ func translationConfigForApp(config *Config) *translation.Config { } } +// audioConfigForApp normalizes the GUI audio settings using the shared audio defaults. +func audioConfigForApp(config *Config) *audio.Config { + if config == nil { + config = DefaultConfig() + } + + defaults := audio.DefaultProviderConfig() + provider := strings.ToLower(strings.TrimSpace(config.AudioProvider)) + if provider == "" { + provider = defaults.Provider + } + outputFormat := strings.TrimSpace(config.AudioFormat) + if outputFormat == "" { + if provider == "gemini" { + outputFormat = defaults.OutputFormat + } else { + outputFormat = "mp3" + } + } + + audioConfig := &audio.Config{ + Provider: provider, + OutputDir: config.OutputDir, + OutputFormat: outputFormat, + OpenAIKey: config.OpenAIKey, + GoogleAPIKey: config.GoogleAPIKey, + OpenAIModel: defaults.OpenAIModel, + OpenAIVoice: defaults.OpenAIVoice, + OpenAISpeed: defaults.OpenAISpeed, + OpenAIInstruction: defaults.OpenAIInstruction, + GeminiTTSModel: defaults.GeminiTTSModel, + GeminiVoice: config.GeminiVoice, + GeminiSpeed: defaults.GeminiSpeed, + } + + if config.GeminiTTSModel != "" { + audioConfig.GeminiTTSModel = config.GeminiTTSModel + } + + return audioConfig +} + // setupUI creates the main user interface func (a *Application) setupUI() { a.window = a.app.NewWindow("TotalRecall") diff --git a/internal/gui/app_test.go b/internal/gui/app_test.go index c5a30c8..06525b2 100644 --- a/internal/gui/app_test.go +++ b/internal/gui/app_test.go @@ -3,11 +3,13 @@ package gui import ( "testing" + "codeberg.org/snonux/totalrecall/internal/audio" "codeberg.org/snonux/totalrecall/internal/translation" ) func TestDefaultConfigUsesOpenAITranslationProvider(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) @@ -15,6 +17,15 @@ func TestDefaultConfigUsesOpenAITranslationProvider(t *testing.T) { if config.ImageProvider != imageProviderNanoBanana { t.Fatalf("DefaultConfig() image provider = %q, want %q", config.ImageProvider, imageProviderNanoBanana) } + if config.AudioProvider != audioDefaults.Provider { + t.Fatalf("DefaultConfig() audio provider = %q, want %q", config.AudioProvider, audioDefaults.Provider) + } + if config.AudioFormat != audioDefaults.OutputFormat { + t.Fatalf("DefaultConfig() audio format = %q, want %q", config.AudioFormat, audioDefaults.OutputFormat) + } + if config.GeminiTTSModel != audioDefaults.GeminiTTSModel { + t.Fatalf("DefaultConfig() GeminiTTSModel = %q, want %q", config.GeminiTTSModel, audioDefaults.GeminiTTSModel) + } } func TestTranslationConfigForApp(t *testing.T) { diff --git a/internal/gui/generator.go b/internal/gui/generator.go index 34e63fc..6d1c883 100644 --- a/internal/gui/generator.go +++ b/internal/gui/generator.go @@ -6,6 +6,7 @@ import ( "math/rand" "os" "path/filepath" + "strings" "time" "fyne.io/fyne/v2" @@ -29,11 +30,96 @@ var newNanoBananaImageClient = func(config *image.NanoBananaConfig) promptAwareI var newAudioProvider = audio.NewProvider -func randomVoiceAndSpeed(voices []string) (string, float64) { +func randomVoice(voices []string) string { rng := rand.New(rand.NewSource(time.Now().UnixNano())) - voice := voices[rng.Intn(len(voices))] - speed := 0.90 + rng.Float64()*0.10 - return voice, speed + return voices[rng.Intn(len(voices))] +} + +func randomOpenAISpeed() float64 { + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + return 0.90 + rng.Float64()*0.10 +} + +func (a *Application) audioProviderName() string { + if a != nil && a.audioConfig != nil { + if provider := strings.ToLower(strings.TrimSpace(a.audioConfig.Provider)); provider != "" { + return provider + } + } + return audio.DefaultProviderConfig().Provider +} + +func (a *Application) audioVoices() []string { + switch a.audioProviderName() { + case "gemini": + return audio.GeminiVoices + default: + return audio.OpenAIVoices + } +} + +func (a *Application) audioVoiceAndSpeed() (string, float64) { + switch a.audioProviderName() { + case "gemini": + if a.audioConfig != nil { + if voice := strings.TrimSpace(a.audioConfig.GeminiVoice); voice != "" { + return voice, a.geminiSpeed() + } + } + return randomVoice(a.audioVoices()), a.geminiSpeed() + default: + return randomVoice(a.audioVoices()), randomOpenAISpeed() + } +} + +func (a *Application) geminiSpeed() float64 { + if a != nil && a.audioConfig != nil && a.audioConfig.GeminiSpeed > 0 { + return a.audioConfig.GeminiSpeed + } + return audio.DefaultProviderConfig().GeminiSpeed +} + +func (a *Application) audioOutputFormat() string { + if a.audioProviderName() == "gemini" { + return "wav" + } + + if a != nil && a.config != nil && strings.TrimSpace(a.config.AudioFormat) != "" { + return a.config.AudioFormat + } + + if a != nil && a.audioConfig != nil && strings.TrimSpace(a.audioConfig.OutputFormat) != "" { + return a.audioConfig.OutputFormat + } + + return audio.DefaultProviderConfig().OutputFormat +} + +func (a *Application) audioConfigForGeneration(voice string, speed float64) audio.Config { + audioConfig := audio.Config{} + if a != nil && a.audioConfig != nil { + audioConfig = *a.audioConfig + } + + audioConfig.Provider = a.audioProviderName() + if a != nil && a.config != nil { + audioConfig.OutputDir = a.config.OutputDir + } + audioConfig.OutputFormat = a.audioOutputFormat() + + switch audioConfig.Provider { + case "gemini": + audioConfig.GeminiVoice = voice + audioConfig.GeminiSpeed = speed + if strings.TrimSpace(audioConfig.GeminiTTSModel) == "" { + audioConfig.GeminiTTSModel = audio.DefaultProviderConfig().GeminiTTSModel + } + default: + audioConfig.OpenAIVoice = voice + audioConfig.OpenAISpeed = speed + } + + return audioConfig } // translateWord translates a Bulgarian word to English @@ -59,20 +145,14 @@ func (a *Application) generateAudio(ctx context.Context, word string, cardDir st // Check if this is a regeneration by looking for existing audio file isRegeneration := false if cardDir != "" { - audioFile := filepath.Join(cardDir, fmt.Sprintf("audio.%s", a.config.AudioFormat)) + audioFile := filepath.Join(cardDir, fmt.Sprintf("audio.%s", a.audioOutputFormat())) if _, err := os.Stat(audioFile); err == nil { isRegeneration = true } } - // Select a random voice - voice, speed := randomVoiceAndSpeed(audio.OpenAIVoices) - - // Create a copy of audio config with selected voice and speed - audioConfig := *a.audioConfig - audioConfig.OpenAIVoice = voice - audioConfig.OpenAISpeed = speed - audioConfig.OutputDir = a.config.OutputDir // Ensure correct output directory + voice, speed := a.audioVoiceAndSpeed() + audioConfig := a.audioConfigForGeneration(voice, speed) // Log the audio generation details if isRegeneration { @@ -93,7 +173,7 @@ func (a *Application) generateAudio(ctx context.Context, word string, cardDir st } // Generate filename in subdirectory - outputFile := filepath.Join(cardDir, fmt.Sprintf("audio.%s", a.config.AudioFormat)) + outputFile := filepath.Join(cardDir, fmt.Sprintf("audio.%s", audioConfig.OutputFormat)) // Generate audio err = provider.GenerateAudio(ctx, word, outputFile) @@ -126,12 +206,8 @@ func (a *Application) generateAudioFront(ctx context.Context, word string, cardD return "", fmt.Errorf("card directory not provided") } - voice, speed := randomVoiceAndSpeed(audio.OpenAIVoices) - - audioConfig := *a.audioConfig - audioConfig.OpenAIVoice = voice - audioConfig.OpenAISpeed = speed - audioConfig.OutputDir = a.config.OutputDir + voice, speed := a.audioVoiceAndSpeed() + audioConfig := a.audioConfigForGeneration(voice, speed) provider, err := newAudioProvider(&audioConfig) if err != nil { @@ -141,7 +217,7 @@ func (a *Application) generateAudioFront(ctx context.Context, word string, cardD 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", a.config.AudioFormat)) + frontFile := filepath.Join(cardDir, fmt.Sprintf("audio_front.%s", audioConfig.OutputFormat)) fmt.Printf("DEBUG (generateAudioFront): Will write to: %s\n", frontFile) if err := provider.GenerateAudio(ctx, word, frontFile); err != nil { return "", fmt.Errorf("failed to generate front audio: %w", err) @@ -167,12 +243,8 @@ func (a *Application) generateAudioBack(ctx context.Context, text string, cardDi return "", fmt.Errorf("card directory not provided") } - voice, speed := randomVoiceAndSpeed(audio.OpenAIVoices) - - audioConfig := *a.audioConfig - audioConfig.OpenAIVoice = voice - audioConfig.OpenAISpeed = speed - audioConfig.OutputDir = a.config.OutputDir + voice, speed := a.audioVoiceAndSpeed() + audioConfig := a.audioConfigForGeneration(voice, speed) provider, err := newAudioProvider(&audioConfig) if err != nil { @@ -182,7 +254,7 @@ func (a *Application) generateAudioBack(ctx context.Context, text string, cardDi 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", a.config.AudioFormat)) + backFile := filepath.Join(cardDir, fmt.Sprintf("audio_back.%s", audioConfig.OutputFormat)) fmt.Printf("DEBUG (generateAudioBack): Will write to: %s\n", backFile) if err := provider.GenerateAudio(ctx, text, backFile); err != nil { return "", fmt.Errorf("failed to generate back audio: %w", err) @@ -198,12 +270,8 @@ func (a *Application) generateAudioBgBg(ctx context.Context, front, back, cardDi return "", "", fmt.Errorf("card directory not provided") } - voice, speed := randomVoiceAndSpeed(audio.OpenAIVoices) - - audioConfig := *a.audioConfig - audioConfig.OpenAIVoice = voice - audioConfig.OpenAISpeed = speed - audioConfig.OutputDir = a.config.OutputDir + voice, speed := a.audioVoiceAndSpeed() + audioConfig := a.audioConfigForGeneration(voice, speed) provider, err := newAudioProvider(&audioConfig) if err != nil { @@ -212,14 +280,14 @@ func (a *Application) generateAudioBgBg(ctx context.Context, front, back, cardDi // 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", a.config.AudioFormat)) + 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) } // 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", a.config.AudioFormat)) + 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) } @@ -339,14 +407,40 @@ func (a *Application) imagePromptCallback(cardDir, word string) func(prompt stri // saveAudioAttribution saves attribution info for generated audio func (a *Application) saveAudioAttribution(word, audioFile, voice string, speed float64) error { - attribution := audio.BuildOpenAIAttribution(audio.AttributionParams{ - Word: word, - Model: a.audioConfig.OpenAIModel, - Voice: voice, - Speed: speed, - Instruction: a.audioConfig.OpenAIInstruction, - GeneratedAt: time.Now(), - }) + var attribution string + switch a.audioProviderName() { + case "gemini": + model := audio.DefaultProviderConfig().GeminiTTSModel + if a.audioConfig != nil && strings.TrimSpace(a.audioConfig.GeminiTTSModel) != "" { + model = a.audioConfig.GeminiTTSModel + } + attribution = audio.BuildGeminiAttribution(audio.AttributionParams{ + Word: word, + Model: model, + Voice: voice, + Speed: speed, + GeneratedAt: time.Now(), + }) + default: + model := audio.DefaultProviderConfig().OpenAIModel + instruction := audio.DefaultProviderConfig().OpenAIInstruction + if a.audioConfig != nil { + if strings.TrimSpace(a.audioConfig.OpenAIModel) != "" { + model = a.audioConfig.OpenAIModel + } + if strings.TrimSpace(a.audioConfig.OpenAIInstruction) != "" { + instruction = a.audioConfig.OpenAIInstruction + } + } + attribution = audio.BuildOpenAIAttribution(audio.AttributionParams{ + Word: word, + Model: model, + Voice: voice, + Speed: speed, + Instruction: instruction, + GeneratedAt: time.Now(), + }) + } // Save to file attrPath := audio.AttributionPath(audioFile) diff --git a/internal/gui/generator_test.go b/internal/gui/generator_test.go index 7c1e96b..635b990 100644 --- a/internal/gui/generator_test.go +++ b/internal/gui/generator_test.go @@ -220,6 +220,84 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) { } } +func TestGenerateAudioUsesSharedGeminiVoicesAndAttribution(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{"sentinel-gemini-voice"} + + fakeProvider := &fakeAudioProvider{} + var capturedConfig *audio.Config + newAudioProvider = func(config *audio.Config) (audio.Provider, error) { + copyConfig := *config + capturedConfig = ©Config + return fakeProvider, nil + } + + tempDir := t.TempDir() + cardDir := filepath.Join(tempDir, "card") + if err := os.MkdirAll(cardDir, 0755); err != nil { + t.Fatalf("failed to create card dir: %v", err) + } + + 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", + }, + } + + outputPath, err := app.generateAudio(context.Background(), "ябълка", cardDir) + if err != nil { + t.Fatalf("generateAudio() unexpected error: %v", err) + } + + if capturedConfig == nil { + t.Fatal("expected audio provider config to be captured") + } + if capturedConfig.Provider != "gemini" { + t.Fatalf("captured Provider = %q, want %q", capturedConfig.Provider, "gemini") + } + 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 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) + } + + attrPath := audio.AttributionPath(outputPath) + attributionData, err := os.ReadFile(attrPath) + if err != nil { + t.Fatalf("expected attribution file %q: %v", attrPath, err) + } + attribution := string(attributionData) + if !strings.Contains(attribution, "Audio generated by Google Gemini TTS") { + t.Fatalf("gemini attribution missing header: %q", attribution) + } + if !strings.Contains(attribution, "Voice: sentinel-gemini-voice") { + t.Fatalf("gemini attribution missing voice: %q", attribution) + } +} + func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) { originalFactory := newAudioProvider t.Cleanup(func() { -- cgit v1.2.3