summaryrefslogtreecommitdiff
path: root/internal/gui
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-01 22:31:06 +0300
committerPaul Buetow <paul@buetow.org>2026-04-01 22:31:06 +0300
commitfaa2465955445ac1cb1461eb70a98280d32fbf9a (patch)
tree2481479f774e0c5f117bb311cd189a217d7a6439 /internal/gui
parente5623423c85b3cbe5c96943e94b07ccafa04368a (diff)
Wire Gemini audio into GUI
Diffstat (limited to 'internal/gui')
-rw-r--r--internal/gui/app.go86
-rw-r--r--internal/gui/app_test.go11
-rw-r--r--internal/gui/generator.go182
-rw-r--r--internal/gui/generator_test.go78
4 files changed, 296 insertions, 61 deletions
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 = &copyConfig
+ 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() {