diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-06 10:47:33 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-06 10:47:33 +0300 |
| commit | 95dd36d28d18615ad3f8dd7122a404850dcb39f8 (patch) | |
| tree | 14c56ec1252d8bfe81faa46ea512d9aff8308da5 /internal/gui/generator.go | |
| parent | 05bddac137607102f12c1c464db34a1e10707af6 (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.go | 496 |
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) } |
