summaryrefslogtreecommitdiff
path: root/internal/gui/generator.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-06 10:47:33 +0300
committerPaul Buetow <paul@buetow.org>2026-04-06 10:47:33 +0300
commit95dd36d28d18615ad3f8dd7122a404850dcb39f8 (patch)
tree14c56ec1252d8bfe81faa46ea512d9aff8308da5 /internal/gui/generator.go
parent05bddac137607102f12c1c464db34a1e10707af6 (diff)
refactor: decompose gui.Application god object into focused services
Extract CardService (file I/O, persistence, card directory management) and GenerationOrchestrator (audio/image/phonetics generation) from the 2852-line Application struct. Application is now thin UI event-wiring. Also split all functions over 50 lines into focused helpers throughout app.go. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/gui/generator.go')
-rw-r--r--internal/gui/generator.go496
1 files changed, 51 insertions, 445 deletions
diff --git a/internal/gui/generator.go b/internal/gui/generator.go
index 4b55608..312baba 100644
--- a/internal/gui/generator.go
+++ b/internal/gui/generator.go
@@ -2,15 +2,9 @@ package gui
import (
"context"
- "fmt"
"math/rand"
- "os"
- "path/filepath"
- "strings"
"time"
- "fyne.io/fyne/v2"
-
"codeberg.org/snonux/totalrecall/internal/audio"
"codeberg.org/snonux/totalrecall/internal/image"
)
@@ -22,489 +16,101 @@ type promptAwareImageClient interface {
SetPromptCallback(func(prompt string))
}
+// randomVoice picks a random voice from the provided list.
+// Used by GenerationOrchestrator for both OpenAI and Gemini voice selection.
func randomVoice(voices []string) string {
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
return voices[rng.Intn(len(voices))]
}
+// randomOpenAISpeed picks a random speed in [0.90, 1.00) for OpenAI TTS to
+// add slight variation across generations.
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
-}
-
-// audioVoices returns the voice list for the configured provider.
-func (a *Application) audioVoices() []string {
- return audio.VoicesFor(a.audioProviderName())
-}
-
-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
-}
+// --- Application delegation methods ---
+// Each method delegates to getOrchestrator() so tests that create Application
+// directly (setting newAudioProvider / audioConfig / config) continue to work
+// without modification, while production code uses the pre-built orchestrator.
-func (a *Application) geminiVoicePinned() bool {
- return a != nil && a.audioConfig != nil && strings.TrimSpace(a.audioConfig.GeminiVoice) != ""
+// audioProviderName returns the lowercase TTS provider name.
+func (a *Application) audioProviderName() string {
+ return a.getOrchestrator().audioProviderName()
}
+// audioOutputFormat resolves the effective audio output format.
func (a *Application) audioOutputFormat() string {
- 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
+ return a.getOrchestrator().audioOutputFormat()
}
-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
-}
-
-func (a *Application) generateAudioFile(ctx context.Context, text, outputFile, voice string, speed float64) error {
- audioConfig := a.audioConfigForGeneration(voice, speed)
-
- provider, err := a.newAudioProvider(&audioConfig)
- if err != nil {
- return err
- }
-
- return provider.GenerateAudio(ctx, text, outputFile)
-}
-
-// translateWord translates a Bulgarian word to English
+// translateWord translates a Bulgarian word to English.
func (a *Application) translateWord(word string) (string, error) {
- if a.translator == nil {
- return "", fmt.Errorf("translation service not configured")
- }
-
- return a.translator.TranslateWord(word)
+ return a.getOrchestrator().TranslateWord(word)
}
-// translateEnglishToBulgarian translates an English word to Bulgarian
+// translateEnglishToBulgarian translates an English word to Bulgarian.
func (a *Application) translateEnglishToBulgarian(word string) (string, error) {
- if a.translator == nil {
- return "", fmt.Errorf("translation service not configured")
- }
-
- return a.translator.TranslateEnglishToBulgarian(word)
+ return a.getOrchestrator().TranslateEnglishToBulgarian(word)
}
-// generateAudio generates audio for a word
-func (a *Application) generateAudio(ctx context.Context, word string, cardDir string) (string, error) {
- // 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.audioOutputFormat()))
- if _, err := os.Stat(audioFile); err == nil {
- isRegeneration = true
- }
- }
-
- voice, speed := a.audioVoiceAndSpeed()
-
- // Log the audio generation details
- if isRegeneration {
- fmt.Printf("Regenerating audio for '%s' with voice: %s, speed: %.2f\n", word, voice, speed)
- } else {
- fmt.Printf("Generating audio for '%s' with voice: %s, speed: %.2f\n", word, voice, speed)
- }
-
- // Use the provided card directory
- if cardDir == "" {
- return "", fmt.Errorf("card directory not provided")
- }
-
- // Generate filename in subdirectory
- outputFile := filepath.Join(cardDir, fmt.Sprintf("audio.%s", a.audioOutputFormat()))
-
- finalVoice := voice
- var err error
- if a.audioProviderName() == "gemini" && !a.geminiVoicePinned() {
- finalVoice, err = audio.RunWithVoiceFallbacks(voice, func(candidate string) error {
- if candidate != voice {
- fmt.Printf("Retrying Gemini audio with voice: %s\n", candidate)
- }
- return a.generateAudioFile(ctx, word, outputFile, candidate, speed)
- }, nil)
- } else {
- err = a.generateAudioFile(ctx, word, outputFile, voice, speed)
- }
- if err != nil {
- return "", err
- }
-
- audioConfig := a.audioConfigForGeneration(finalVoice, speed)
-
- // Save audio attribution
- if err := a.saveAudioAttribution(word, outputFile, finalVoice, speed); err != nil {
- // Non-fatal error, just log it
- fmt.Printf("Warning: Failed to save audio attribution: %v\n", err)
- }
-
- // Save voice metadata for GUI display
- if err := a.saveAudioMetadata(cardDir, audioConfig, finalVoice, speed, "en-bg", outputFile, ""); err != nil {
- fmt.Printf("Warning: Failed to save audio metadata: %v\n", err)
- }
-
- return outputFile, nil
+// generateAudio generates audio for an en-bg card's single audio file.
+func (a *Application) generateAudio(ctx context.Context, word, cardDir string) (string, error) {
+ return a.getOrchestrator().GenerateAudio(ctx, word, cardDir)
}
-// generateAudioFront generates front audio for a bg-bg card
-func (a *Application) generateAudioFront(ctx context.Context, word string, cardDir string) (string, error) {
- if cardDir == "" {
- return "", fmt.Errorf("card directory not provided")
- }
-
- voice, speed := a.audioVoiceAndSpeed()
- 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.audioOutputFormat()))
-
- finalVoice := voice
- var err error
- if a.audioProviderName() == "gemini" && !a.geminiVoicePinned() {
- finalVoice, err = audio.RunWithVoiceFallbacks(voice, func(candidate string) error {
- if candidate != voice {
- fmt.Printf("Retrying Gemini audio with voice: %s\n", candidate)
- }
- return a.generateAudioFile(ctx, word, frontFile, candidate, speed)
- }, nil)
- } else {
- err = a.generateAudioFile(ctx, word, frontFile, voice, speed)
- }
- if err != nil {
- return "", fmt.Errorf("failed to generate front audio: %w", err)
- }
-
- audioConfig := a.audioConfigForGeneration(finalVoice, speed)
-
- if err := a.saveAudioAttribution(word, frontFile, finalVoice, speed); err != nil {
- fmt.Printf("Warning: Failed to save audio attribution: %v\n", err)
- }
-
- // Update metadata
- if err := a.saveAudioMetadata(cardDir, audioConfig, finalVoice, speed, "bg-bg", frontFile, a.currentAudioFileBack); err != nil {
- fmt.Printf("Warning: Failed to save audio metadata: %v\n", err)
- }
-
- return frontFile, nil
+// generateAudioFront generates the front audio file for a bg-bg card.
+func (a *Application) generateAudioFront(ctx context.Context, word, cardDir string) (string, error) {
+ return a.getOrchestrator().GenerateAudioFront(ctx, word, cardDir)
}
-// generateAudioBack generates back audio for a bg-bg card
-func (a *Application) generateAudioBack(ctx context.Context, text string, cardDir string) (string, error) {
- if cardDir == "" {
- return "", fmt.Errorf("card directory not provided")
- }
-
- voice, speed := a.audioVoiceAndSpeed()
- 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.audioOutputFormat()))
-
- finalVoice := voice
- var err error
- if a.audioProviderName() == "gemini" && !a.geminiVoicePinned() {
- finalVoice, err = audio.RunWithVoiceFallbacks(voice, func(candidate string) error {
- if candidate != voice {
- fmt.Printf("Retrying Gemini audio with voice: %s\n", candidate)
- }
- return a.generateAudioFile(ctx, text, backFile, candidate, speed)
- }, nil)
- } else {
- err = a.generateAudioFile(ctx, text, backFile, voice, speed)
- }
- if err != nil {
- return "", fmt.Errorf("failed to generate back audio: %w", err)
- }
-
- audioConfig := a.audioConfigForGeneration(finalVoice, speed)
-
- if err := a.saveAudioAttribution(text, backFile, finalVoice, speed); err != nil {
- fmt.Printf("Warning: Failed to save audio attribution: %v\n", err)
- }
-
- // Update metadata
- if err := a.saveAudioMetadata(cardDir, audioConfig, finalVoice, speed, "bg-bg", a.currentAudioFile, backFile); err != nil {
- fmt.Printf("Warning: Failed to save audio metadata: %v\n", err)
- }
-
- return backFile, nil
+// generateAudioBack generates the back audio file for a bg-bg card.
+func (a *Application) generateAudioBack(ctx context.Context, text, cardDir string) (string, error) {
+ return a.getOrchestrator().GenerateAudioBack(ctx, text, cardDir)
}
-// generateAudioBgBg generates audio for both sides of a bg-bg card
+// generateAudioBgBg generates audio for both sides of a bg-bg card.
func (a *Application) generateAudioBgBg(ctx context.Context, front, back, cardDir string) (string, string, error) {
- if cardDir == "" {
- return "", "", fmt.Errorf("card directory not provided")
- }
-
- voice, speed := a.audioVoiceAndSpeed()
-
- // 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.audioOutputFormat()))
- backFile := filepath.Join(cardDir, fmt.Sprintf("audio_back.%s", a.audioOutputFormat()))
-
- runPair := func(candidate string) error {
- if err := a.generateAudioFile(ctx, front, frontFile, candidate, speed); err != nil {
- return fmt.Errorf("failed to generate front audio: %w", err)
- }
-
- fmt.Printf("Generating back audio for '%s' with voice: %s, speed: %.2f\n", back, candidate, speed)
- if err := a.generateAudioFile(ctx, back, backFile, candidate, speed); err != nil {
- return fmt.Errorf("failed to generate back audio: %w", err)
- }
-
- return nil
- }
-
- finalVoice := voice
- var err error
- if a.audioProviderName() == "gemini" && !a.geminiVoicePinned() {
- finalVoice, err = audio.RunWithVoiceFallbacks(voice, func(candidate string) error {
- if candidate != voice {
- fmt.Printf("Retrying Gemini audio with voice: %s\n", candidate)
- }
- return runPair(candidate)
- }, nil)
- } else {
- err = runPair(voice)
- }
- if err != nil {
- return "", "", err
- }
-
- audioConfig := a.audioConfigForGeneration(finalVoice, speed)
-
- // Save audio attribution
- if err := a.saveAudioAttribution(front, frontFile, finalVoice, speed); err != nil {
- fmt.Printf("Warning: Failed to save audio attribution: %v\n", err)
- }
- if err := a.saveAudioAttribution(back, backFile, finalVoice, speed); err != nil {
- fmt.Printf("Warning: Failed to save audio attribution: %v\n", err)
- }
-
- // Save metadata for both sides
- if err := a.saveAudioMetadata(cardDir, audioConfig, finalVoice, speed, "bg-bg", frontFile, backFile); err != nil {
- fmt.Printf("Warning: Failed to save audio metadata: %v\n", err)
- }
-
- return frontFile, backFile, nil
-}
-
-// generateImagesWithPrompt downloads a single image for a word with optional custom prompt and translation
-func (a *Application) generateImagesWithPrompt(ctx context.Context, word string, customPrompt string, translation string, cardDir string) (string, error) {
- searcher, err := a.newImageSearcher()
- if err != nil {
- return "", err
- }
-
- // Use the provided card directory
- if cardDir == "" {
- return "", fmt.Errorf("card directory not provided")
- }
-
- // Create downloader
- downloadOpts := &image.DownloadOptions{
- OutputDir: cardDir,
- OverwriteExisting: true,
- CreateDir: true,
- FileNamePattern: "image",
- MaxSizeBytes: 5 * 1024 * 1024, // 5MB
- }
-
- downloader := image.NewDownloader(searcher, downloadOpts)
-
- // Set up a prompt callback so the GUI and on-disk metadata update as soon as the prompt exists.
- searcher.SetPromptCallback(a.imagePromptCallback(cardDir, word))
-
- // Create search options with custom prompt and translation if provided
- searchOpts := image.DefaultSearchOptions(word)
- if customPrompt != "" {
- searchOpts.CustomPrompt = customPrompt
- }
- if translation != "" {
- searchOpts.Translation = translation
- }
-
- // Download single image
- _, path, err := downloader.DownloadBestMatchWithOptions(ctx, searchOpts)
- if err != nil {
- return "", err
- }
-
- // The prompt has already been saved and UI updated via the callback
-
- return path, nil
+ return a.getOrchestrator().GenerateAudioBgBg(ctx, front, back, cardDir)
}
-func (a *Application) newImageSearcher() (promptAwareImageClient, error) {
- switch a.config.ImageProvider {
- case imageProviderOpenAI:
- if a.config.OpenAIKey == "" {
- return nil, fmt.Errorf("OpenAI API key is required for image generation")
- }
-
- openaiConfig := &image.OpenAIConfig{
- APIKey: a.config.OpenAIKey,
- Model: "dall-e-2", // DALL-E 2 supports 512x512
- Size: "512x512", // Half of 1024x1024
- Quality: "standard",
- Style: "natural",
- }
-
- return a.newOpenAIImageClient(openaiConfig), nil
- case imageProviderNanoBanana:
- config := a.config
- if config == nil {
- config = DefaultConfig()
- }
- if config.GoogleAPIKey == "" {
- return nil, fmt.Errorf("google API key is required for image generation")
- }
-
- nanoBananaConfig := &image.NanoBananaConfig{
- APIKey: config.GoogleAPIKey,
- Model: config.NanoBananaModel,
- TextModel: config.NanoBananaTextModel,
- }
-
- return a.newNanoBananaImageClient(nanoBananaConfig), nil
- default:
- return nil, fmt.Errorf("unknown image provider: %s", a.config.ImageProvider)
- }
+// generateAudioFile generates a single audio file using the given voice/speed.
+// Kept as a thin wrapper so the fallback tests in generator_test.go can call it.
+func (a *Application) generateAudioFile(ctx context.Context, text, outputFile, voice string, speed float64) error {
+ return a.getOrchestrator().generateAudioFile(ctx, text, outputFile, voice, speed)
}
-func (a *Application) imagePromptCallback(cardDir, word string) func(prompt string) {
- return func(prompt string) {
- // Save the prompt to disk immediately for this word.
- promptFile := filepath.Join(cardDir, "image_prompt.txt")
- if err := os.WriteFile(promptFile, []byte(prompt), 0644); err != nil {
- fmt.Printf("Warning: Failed to save prompt for '%s': %v\n", word, err)
- }
+// generateImagesWithPrompt downloads a single image for a word with optional
+// custom prompt and translation hint.
+func (a *Application) generateImagesWithPrompt(ctx context.Context, word, customPrompt, translation, cardDir string) (string, error) {
+ o := a.getOrchestrator()
- // Only update UI if this word is still the current word.
+ // Wrap with a UI update callback for the current word's image prompt entry.
+ promptUI := func(prompt string) {
a.mu.Lock()
isCurrentWord := a.currentWord == word
a.mu.Unlock()
if isCurrentWord && a.imagePromptEntry != nil {
- fyne.Do(func() {
- a.imagePromptEntry.SetText(prompt)
- })
+ a.imagePromptEntry.SetText(prompt)
}
}
-}
-
-// saveAudioAttribution saves attribution info for generated audio.
-// Uses BuildAttributionFor so no switch on provider name is needed here.
-func (a *Application) saveAudioAttribution(word, audioFile, voice string, speed float64) error {
- processedText := audio.ProcessedTextForWord(word)
- providerName := a.audioProviderName()
-
- // Build an ephemeral Config from the current audioConfig so we can use
- // AttributionParamsFrom to read provider-specific fields without a switch.
- cfg := a.audioConfig
- if cfg == nil {
- cfg = audio.DefaultProviderConfig()
- }
- // Override voice and speed with the values used for this specific generation.
- cfgCopy := *cfg
- cfgCopy.Provider = providerName
- cfgCopy.GeminiVoice = voice
- cfgCopy.GeminiSpeed = speed
- cfgCopy.OpenAIVoice = voice
- cfgCopy.OpenAISpeed = speed
-
- instruction := audio.InstructionForProvider(providerName, &cfgCopy)
- params := audio.AttributionParamsFrom(&cfgCopy, word, instruction, processedText, time.Now())
- attribution := audio.BuildAttributionFor(providerName, params)
-
- // Save to file
- attrPath := audio.AttributionPath(audioFile)
- if err := os.WriteFile(attrPath, []byte(attribution), 0644); err != nil {
- return fmt.Errorf("failed to write audio attribution file: %w", err)
- }
- return nil
+ return o.generateImagesWithPromptAndNotify(ctx, word, customPrompt, translation, cardDir, promptUI)
}
-func (a *Application) saveAudioMetadata(cardDir string, audioConfig audio.Config, voice string, speed float64, cardType string, audioFile string, audioFileBack string) error {
- metadataFile := filepath.Join(cardDir, "audio_metadata.txt")
- if cardType == "bg-bg" {
- if audioFile == "" {
- audioFile, _ = a.resolveBgBgAudioFiles(cardDir)
- }
- if audioFileBack == "" {
- _, audioFileBack = a.resolveBgBgAudioFiles(cardDir)
- }
- }
-
- metadata := audio.BuildSidecarMetadata(audio.SidecarMetadataParams{
- Provider: audioConfig.Provider,
- OutputFormat: audioConfig.OutputFormat,
- CardType: cardType,
- AudioFile: audioFile,
- AudioFileBack: audioFileBack,
- OpenAIModel: audioConfig.OpenAIModel,
- OpenAIVoice: voice,
- OpenAISpeed: speed,
- OpenAIInstruction: audioConfig.OpenAIInstruction,
- GeminiTTSModel: audioConfig.GeminiTTSModel,
- GeminiVoice: voice,
- GeminiSpeed: speed,
- })
+// getPhoneticInfo fetches phonetic information for a Bulgarian word.
+func (a *Application) getPhoneticInfo(word string) (string, error) {
+ return a.getOrchestrator().GetPhoneticInfo(word)
+}
- if err := os.WriteFile(metadataFile, []byte(metadata), 0644); err != nil {
- return fmt.Errorf("failed to write audio metadata file: %w", err)
- }
+// saveAudioAttribution saves attribution metadata for a generated audio file.
+func (a *Application) saveAudioAttribution(word, audioFile, voice string, speed float64) error {
+ return a.getOrchestrator().saveAudioAttribution(word, audioFile, voice, speed)
+}
- return nil
+// saveAudioMetadata writes the sidecar metadata file for a generated audio file.
+func (a *Application) saveAudioMetadata(cardDir string, audioCfg audio.Config, voice string, speed float64, cardType, audioFile, audioFileBack string) error {
+ return a.getOrchestrator().saveAudioMetadata(cardDir, audioCfg, voice, speed, cardType, audioFile, audioFileBack)
}