summaryrefslogtreecommitdiff
path: root/internal/gui
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-08 09:50:33 +0300
committerPaul Buetow <paul@buetow.org>2026-04-08 09:50:33 +0300
commit7e7d34bfc0aec2aa8dff4cefa720e4a9b42d95d8 (patch)
tree145cb52c8dcdd127e25cf62383fe59d74fe3e131 /internal/gui
parent246881b00c3af3834ad612a64422dff265027234 (diff)
refactor(gui): extract AudioConfigResolver, VoiceSelector, ParallelRunner
Split orchestrator audio resolution, voice selection, and parallel material generation into dedicated types in separate files. GenerationOrchestrator wires AudioConfigResolver and VoiceSelector from NewGenerationOrchestrator and delegates GenerateMaterials to ParallelRunner; public behaviour unchanged. Made-with: Cursor
Diffstat (limited to 'internal/gui')
-rw-r--r--internal/gui/audio_config_resolver.go94
-rw-r--r--internal/gui/orchestrator.go235
-rw-r--r--internal/gui/parallel_runner.go130
-rw-r--r--internal/gui/voice_selector.go48
4 files changed, 304 insertions, 203 deletions
diff --git a/internal/gui/audio_config_resolver.go b/internal/gui/audio_config_resolver.go
new file mode 100644
index 0000000..c9dd9e6
--- /dev/null
+++ b/internal/gui/audio_config_resolver.go
@@ -0,0 +1,94 @@
+package gui
+
+import (
+ "strings"
+
+ "codeberg.org/snonux/totalrecall/internal/audio"
+)
+
+// AudioConfigResolver derives effective TTS provider settings from the GUI
+// config and the shared audio.Config. It centralizes provider name, voice list,
+// output format, and per-run audio.Config construction so orchestration code
+// does not repeat string handling and defaults.
+type AudioConfigResolver struct {
+ guiConfig *Config
+ audioConfig *audio.Config
+}
+
+// NewAudioConfigResolver builds a resolver for the given GUI and audio
+// settings. Either pointer may be nil; defaults match the previous
+// GenerationOrchestrator behaviour.
+func NewAudioConfigResolver(guiConfig *Config, audioConfig *audio.Config) *AudioConfigResolver {
+ return &AudioConfigResolver{
+ guiConfig: guiConfig,
+ audioConfig: audioConfig,
+ }
+}
+
+// ProviderName returns the lowercase provider name from config, defaulting to
+// the shared audio default when none is set.
+func (r *AudioConfigResolver) ProviderName() string {
+ if r.audioConfig != nil {
+ if provider := strings.ToLower(strings.TrimSpace(r.audioConfig.Provider)); provider != "" {
+ return provider
+ }
+ }
+ return audio.DefaultProviderConfig().Provider
+}
+
+// Voices returns the configured provider's voice list.
+func (r *AudioConfigResolver) Voices() []string {
+ return audio.VoicesFor(r.ProviderName())
+}
+
+// OutputFormat resolves the effective output format (e.g. "mp3" or "wav").
+func (r *AudioConfigResolver) OutputFormat() string {
+ if r.guiConfig != nil && strings.TrimSpace(r.guiConfig.AudioFormat) != "" {
+ return r.guiConfig.AudioFormat
+ }
+
+ if r.audioConfig != nil && strings.TrimSpace(r.audioConfig.OutputFormat) != "" {
+ return r.audioConfig.OutputFormat
+ }
+
+ return audio.DefaultProviderConfig().OutputFormat
+}
+
+// ConfigForGeneration builds an audio.Config for a single generation call,
+// overriding the voice and speed with the values selected for this run.
+func (r *AudioConfigResolver) ConfigForGeneration(voice string, speed float64) audio.Config {
+ audioConfig := audio.Config{}
+ if r.audioConfig != nil {
+ audioConfig = *r.audioConfig
+ }
+
+ audioConfig.Provider = r.ProviderName()
+ if r.guiConfig != nil {
+ audioConfig.OutputDir = r.guiConfig.OutputDir
+ }
+ audioConfig.OutputFormat = r.OutputFormat()
+
+ 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
+}
+
+// BaseConfigForAttribution returns the configured audio.Config pointer, or the
+// package default when unset — matching how attribution sidecars resolve the
+// base parameters before per-run voice/speed overrides.
+func (r *AudioConfigResolver) BaseConfigForAttribution() *audio.Config {
+ if r.audioConfig != nil {
+ return r.audioConfig
+ }
+ return audio.DefaultProviderConfig()
+}
diff --git a/internal/gui/orchestrator.go b/internal/gui/orchestrator.go
index 2861c4f..191e919 100644
--- a/internal/gui/orchestrator.go
+++ b/internal/gui/orchestrator.go
@@ -5,7 +5,6 @@ import (
"fmt"
"os"
"path/filepath"
- "strings"
"time"
"fyne.io/fyne/v2"
@@ -22,10 +21,15 @@ import (
// image.ClientFactories groups the two image-factory functions so the field
// definitions are not duplicated between this type and processor.Processor.
type GenerationOrchestrator struct {
- config *Config
- audioConfig *audio.Config
- phonetics *phonetic.Fetcher
- translator *translation.Translator
+ config *Config
+
+ // audioResolver derives effective TTS settings from GUI + audio.Config.
+ audioResolver *AudioConfigResolver
+ // voiceSelector picks voice and speed per generation run.
+ voiceSelector *VoiceSelector
+
+ phonetics *phonetic.Fetcher
+ translator *translation.Translator
// imageFactories groups the two image-provider construction functions.
// Production code uses image.DefaultClientFactories(); tests replace fields.
@@ -34,6 +38,8 @@ type GenerationOrchestrator struct {
// newAudioProvider constructs an audio.Provider from a Config.
// Production code uses audio.NewProvider; tests replace it with a fake.
newAudioProvider audio.ProviderFactory
+
+ parallelRunner ParallelRunner
}
// NewGenerationOrchestrator constructs an orchestrator wired to the given app
@@ -48,9 +54,11 @@ func NewGenerationOrchestrator(
imageFactories image.ClientFactories,
newAudio audio.ProviderFactory,
) *GenerationOrchestrator {
+ resolver := NewAudioConfigResolver(config, audioCfg)
return &GenerationOrchestrator{
config: config,
- audioConfig: audioCfg,
+ audioResolver: resolver,
+ voiceSelector: NewVoiceSelector(resolver),
phonetics: phonetics,
translator: translator,
imageFactories: imageFactories,
@@ -79,98 +87,21 @@ func (o *GenerationOrchestrator) TranslateEnglishToBulgarian(word string) (strin
// --- Audio provider helpers ---
// audioProviderName returns the lowercase provider name from config, defaulting
-// to the shared audio default when none is set.
+// to the shared audio default when none is set. Delegates to AudioConfigResolver
+// so Application and tests keep a stable method on GenerationOrchestrator.
func (o *GenerationOrchestrator) audioProviderName() string {
- if o.audioConfig != nil {
- if provider := strings.ToLower(strings.TrimSpace(o.audioConfig.Provider)); provider != "" {
- return provider
- }
- }
- return audio.DefaultProviderConfig().Provider
-}
-
-// audioVoices returns the configured provider's voice list.
-func (o *GenerationOrchestrator) audioVoices() []string {
- return audio.VoicesFor(o.audioProviderName())
-}
-
-// audioVoiceAndSpeed selects the voice and speed for a generation run.
-// For Gemini with a pinned voice the configured voice is used; otherwise a
-// random voice is selected from the available list.
-func (o *GenerationOrchestrator) audioVoiceAndSpeed() (string, float64) {
- switch o.audioProviderName() {
- case "gemini":
- if o.audioConfig != nil {
- if voice := strings.TrimSpace(o.audioConfig.GeminiVoice); voice != "" {
- return voice, o.geminiSpeed()
- }
- }
- return randomVoice(o.audioVoices()), o.geminiSpeed()
- default:
- return randomVoice(o.audioVoices()), randomOpenAISpeed()
- }
-}
-
-// geminiSpeed returns the configured Gemini TTS speed or the default.
-func (o *GenerationOrchestrator) geminiSpeed() float64 {
- if o.audioConfig != nil && o.audioConfig.GeminiSpeed > 0 {
- return o.audioConfig.GeminiSpeed
- }
- return audio.DefaultProviderConfig().GeminiSpeed
-}
-
-// geminiVoicePinned returns true when a specific Gemini voice is locked in
-// config, meaning fallback voice selection should be skipped.
-func (o *GenerationOrchestrator) geminiVoicePinned() bool {
- return o.audioConfig != nil && strings.TrimSpace(o.audioConfig.GeminiVoice) != ""
+ return o.audioResolver.ProviderName()
}
// audioOutputFormat resolves the effective output format (e.g. "mp3" or "wav").
func (o *GenerationOrchestrator) audioOutputFormat() string {
- if o.config != nil && strings.TrimSpace(o.config.AudioFormat) != "" {
- return o.config.AudioFormat
- }
-
- if o.audioConfig != nil && strings.TrimSpace(o.audioConfig.OutputFormat) != "" {
- return o.audioConfig.OutputFormat
- }
-
- return audio.DefaultProviderConfig().OutputFormat
-}
-
-// audioConfigForGeneration builds an audio.Config for a single generation call,
-// overriding the voice and speed with the values selected for this run.
-func (o *GenerationOrchestrator) audioConfigForGeneration(voice string, speed float64) audio.Config {
- audioConfig := audio.Config{}
- if o.audioConfig != nil {
- audioConfig = *o.audioConfig
- }
-
- audioConfig.Provider = o.audioProviderName()
- if o.config != nil {
- audioConfig.OutputDir = o.config.OutputDir
- }
- audioConfig.OutputFormat = o.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
+ return o.audioResolver.OutputFormat()
}
// generateAudioFile generates a single audio file for text using the given
// voice and speed. It is the lowest-level generation call.
func (o *GenerationOrchestrator) generateAudioFile(ctx context.Context, text, outputFile, voice string, speed float64) error {
- audioConfig := o.audioConfigForGeneration(voice, speed)
+ audioConfig := o.audioResolver.ConfigForGeneration(voice, speed)
provider, err := o.newAudioProvider(&audioConfig)
if err != nil {
@@ -196,7 +127,7 @@ func (o *GenerationOrchestrator) GenerateAudio(ctx context.Context, word, cardDi
isRegeneration = true
}
- voice, speed := o.audioVoiceAndSpeed()
+ voice, speed := o.voiceSelector.VoiceAndSpeed()
if isRegeneration {
fmt.Printf("Regenerating audio for '%s' with voice: %s, speed: %.2f\n", word, voice, speed)
@@ -209,7 +140,7 @@ func (o *GenerationOrchestrator) GenerateAudio(ctx context.Context, word, cardDi
return "", err
}
- audioCfg := o.audioConfigForGeneration(finalVoice, speed)
+ audioCfg := o.audioResolver.ConfigForGeneration(finalVoice, speed)
if err := o.saveAudioAttribution(word, audioFile, finalVoice, speed); err != nil {
fmt.Printf("Warning: Failed to save audio attribution: %v\n", err)
@@ -228,7 +159,7 @@ func (o *GenerationOrchestrator) GenerateAudioFront(ctx context.Context, word, c
return "", fmt.Errorf("card directory not provided")
}
- voice, speed := o.audioVoiceAndSpeed()
+ voice, speed := o.voiceSelector.VoiceAndSpeed()
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", o.audioOutputFormat()))
@@ -237,7 +168,7 @@ func (o *GenerationOrchestrator) GenerateAudioFront(ctx context.Context, word, c
return "", fmt.Errorf("failed to generate front audio: %w", err)
}
- audioCfg := o.audioConfigForGeneration(finalVoice, speed)
+ audioCfg := o.audioResolver.ConfigForGeneration(finalVoice, speed)
if err := o.saveAudioAttribution(word, frontFile, finalVoice, speed); err != nil {
fmt.Printf("Warning: Failed to save audio attribution: %v\n", err)
@@ -258,7 +189,7 @@ func (o *GenerationOrchestrator) GenerateAudioBack(ctx context.Context, text, ca
return "", fmt.Errorf("card directory not provided")
}
- voice, speed := o.audioVoiceAndSpeed()
+ voice, speed := o.voiceSelector.VoiceAndSpeed()
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", o.audioOutputFormat()))
@@ -267,7 +198,7 @@ func (o *GenerationOrchestrator) GenerateAudioBack(ctx context.Context, text, ca
return "", fmt.Errorf("failed to generate back audio: %w", err)
}
- audioCfg := o.audioConfigForGeneration(finalVoice, speed)
+ audioCfg := o.audioResolver.ConfigForGeneration(finalVoice, speed)
if err := o.saveAudioAttribution(text, backFile, finalVoice, speed); err != nil {
fmt.Printf("Warning: Failed to save audio attribution: %v\n", err)
@@ -289,7 +220,7 @@ func (o *GenerationOrchestrator) GenerateAudioBgBg(ctx context.Context, front, b
return "", "", fmt.Errorf("card directory not provided")
}
- voice, speed := o.audioVoiceAndSpeed()
+ voice, speed := o.voiceSelector.VoiceAndSpeed()
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", o.audioOutputFormat()))
@@ -312,7 +243,7 @@ func (o *GenerationOrchestrator) GenerateAudioBgBg(ctx context.Context, front, b
return "", "", err
}
- audioCfg := o.audioConfigForGeneration(finalVoice, speed)
+ audioCfg := o.audioResolver.ConfigForGeneration(finalVoice, speed)
if err := o.saveAudioAttribution(front, frontFile, finalVoice, speed); err != nil {
fmt.Printf("Warning: Failed to save audio attribution: %v\n", err)
@@ -331,7 +262,7 @@ func (o *GenerationOrchestrator) GenerateAudioBgBg(ctx context.Context, front, b
// runAudioWithFallbacks runs a single-file audio generation with Gemini voice
// fallback support. Returns the voice that was ultimately used.
func (o *GenerationOrchestrator) runAudioWithFallbacks(ctx context.Context, text, outputFile, voice string, speed float64) (string, error) {
- if o.audioProviderName() == "gemini" && !o.geminiVoicePinned() {
+ if o.audioResolver.ProviderName() == "gemini" && !o.voiceSelector.GeminiVoicePinned() {
return audio.RunWithVoiceFallbacks(voice, func(candidate string) error {
if candidate != voice {
fmt.Printf("Retrying Gemini audio with voice: %s\n", candidate)
@@ -346,7 +277,7 @@ func (o *GenerationOrchestrator) runAudioWithFallbacks(ctx context.Context, text
// runPairWithFallbacks runs a pair-generation function with Gemini voice
// fallback support. Returns the voice that was ultimately used.
func (o *GenerationOrchestrator) runPairWithFallbacks(voice string, runPair func(string) error) (string, error) {
- if o.audioProviderName() == "gemini" && !o.geminiVoicePinned() {
+ if o.audioResolver.ProviderName() == "gemini" && !o.voiceSelector.GeminiVoicePinned() {
return audio.RunWithVoiceFallbacks(voice, func(candidate string) error {
if candidate != voice {
fmt.Printf("Retrying Gemini audio with voice: %s\n", candidate)
@@ -362,12 +293,9 @@ func (o *GenerationOrchestrator) runPairWithFallbacks(voice string, runPair func
// Uses BuildAttributionFor so no switch on provider name is needed here.
func (o *GenerationOrchestrator) saveAudioAttribution(word, audioFile, voice string, speed float64) error {
processedText := audio.ProcessedTextForWord(word)
- providerName := o.audioProviderName()
+ providerName := o.audioResolver.ProviderName()
- cfg := o.audioConfig
- if cfg == nil {
- cfg = audio.DefaultProviderConfig()
- }
+ cfg := o.audioResolver.BaseConfigForAttribution()
// Override voice and speed with the values used for this specific generation.
cfgCopy := *cfg
@@ -544,33 +472,6 @@ func (o *GenerationOrchestrator) GetPhoneticInfo(word string) (string, error) {
// --- Parallel generation (orchestration) ---
-// GenerateResult holds the outcome of a parallel generation run.
-type GenerateResult struct {
- AudioFile string
- AudioFileBack string
- ImageFile string
- PhoneticInfo string
-}
-
-// audioGenResult is an internal channel payload for audio goroutines.
-type audioGenResult struct {
- file string
- fileBack string
- err error
-}
-
-// imageGenResult is an internal channel payload for image goroutines.
-type imageGenResult struct {
- file string
- err error
-}
-
-// phoneticGenResult is an internal channel payload for phonetic goroutines.
-type phoneticGenResult struct {
- info string
- err error
-}
-
// GenerateMaterials generates audio, image, and phonetics in parallel for a
// word. translation is the existing translation (may be empty). isBgBg flags
// bg-bg card type. imagePrompt is an optional custom prompt; imageTranslation
@@ -585,67 +486,7 @@ func (o *GenerationOrchestrator) GenerateMaterials(
imagePrompt string,
promptUI func(prompt string),
) (GenerateResult, error) {
- audioChan := make(chan audioGenResult, 1)
- imageChan := make(chan imageGenResult, 1)
- phoneticChan := make(chan phoneticGenResult, 1)
-
- // 1. Audio generation
- go func() {
- var audioFile, audioFileBack string
- var err error
-
- if isBgBg && translation != "" {
- audioFile, audioFileBack, err = o.GenerateAudioBgBg(ctx, word, translation, cardDir)
- } else {
- audioFile, err = o.GenerateAudio(ctx, word, cardDir)
- }
-
- audioChan <- audioGenResult{file: audioFile, fileBack: audioFileBack, err: err}
- }()
-
- // 2. Image generation (includes scene description from the AI)
- go func() {
- imageFile, err := o.generateImagesWithPromptAndNotify(ctx, word, imagePrompt, translation, cardDir, promptUI)
- imageChan <- imageGenResult{file: imageFile, err: err}
- }()
-
- // 3. Phonetic information fetching
- go func() {
- phoneticInfo, err := o.GetPhoneticInfo(word)
- if err != nil {
- fmt.Printf("Warning: Failed to get phonetic info: %v\n", err)
- phoneticInfo = "Failed to fetch phonetic information"
- } else {
- fmt.Printf("Successfully fetched phonetic info for '%s': %s\n", word, phoneticInfo)
- }
-
- savePhoneticIfValid(phoneticInfo, cardDir, word)
- phoneticChan <- phoneticGenResult{info: phoneticInfo}
- }()
-
- // Collect results.
- audioRes := <-audioChan
- if audioRes.err != nil {
- // Drain remaining channels to avoid goroutine leaks.
- <-imageChan
- <-phoneticChan
- return GenerateResult{}, fmt.Errorf("audio generation failed: %w", audioRes.err)
- }
-
- imageRes := <-imageChan
- if imageRes.err != nil {
- <-phoneticChan
- return GenerateResult{}, fmt.Errorf("image download failed: %w", imageRes.err)
- }
-
- phoneticRes := <-phoneticChan
-
- return GenerateResult{
- AudioFile: audioRes.file,
- AudioFileBack: audioRes.fileBack,
- ImageFile: imageRes.file,
- PhoneticInfo: phoneticRes.info,
- }, nil
+ return o.parallelRunner.GenerateMaterials(o, ctx, word, translation, cardDir, isBgBg, imagePrompt, promptUI)
}
// generateImagesWithPromptAndNotify is a thin wrapper around
@@ -699,15 +540,3 @@ func (o *GenerationOrchestrator) generateImagesWithPromptAndNotify(
_, path, err := downloader.DownloadBestMatchWithOptions(ctx, searchOpts)
return path, err
}
-
-// savePhoneticIfValid saves phonetic info to disk when the info is valid.
-func savePhoneticIfValid(phoneticInfo, cardDir, word string) {
- if phoneticInfo == "" || phoneticInfo == "Failed to fetch phonetic information" {
- return
- }
-
- phoneticFile := filepath.Join(cardDir, "phonetic.txt")
- if err := os.WriteFile(phoneticFile, []byte(phoneticInfo), 0644); err != nil {
- fmt.Printf("Warning: Failed to save phonetic info for '%s': %v\n", word, err)
- }
-}
diff --git a/internal/gui/parallel_runner.go b/internal/gui/parallel_runner.go
new file mode 100644
index 0000000..2567304
--- /dev/null
+++ b/internal/gui/parallel_runner.go
@@ -0,0 +1,130 @@
+package gui
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// GenerateResult holds the outcome of a parallel generation run.
+type GenerateResult struct {
+ AudioFile string
+ AudioFileBack string
+ ImageFile string
+ PhoneticInfo string
+}
+
+// audioGenResult is an internal channel payload for audio goroutines.
+type audioGenResult struct {
+ file string
+ fileBack string
+ err error
+}
+
+// imageGenResult is an internal channel payload for image goroutines.
+type imageGenResult struct {
+ file string
+ err error
+}
+
+// phoneticGenResult is an internal channel payload for phonetic goroutines.
+type phoneticGenResult struct {
+ info string
+ err error
+}
+
+// ParallelRunner coordinates parallel audio, image, and phonetics work for
+// material generation. It is stateless; the orchestrator supplies all behaviour
+// via the first method argument.
+type ParallelRunner struct{}
+
+// GenerateMaterials generates audio, image, and phonetics in parallel for a
+// word. translation is the existing translation (may be empty). isBgBg flags
+// bg-bg card type. imagePrompt is an optional custom prompt; imageTranslation
+// is the translation hint for image prompts.
+// The promptUI callback is called on the generating goroutine when the image
+// prompt becomes known so callers can update the UI.
+// Returns a GenerateResult or an error if any mandatory step fails.
+func (ParallelRunner) GenerateMaterials(
+ o *GenerationOrchestrator,
+ ctx context.Context,
+ word, translation, cardDir string,
+ isBgBg bool,
+ imagePrompt string,
+ promptUI func(prompt string),
+) (GenerateResult, error) {
+ audioChan := make(chan audioGenResult, 1)
+ imageChan := make(chan imageGenResult, 1)
+ phoneticChan := make(chan phoneticGenResult, 1)
+
+ // 1. Audio generation
+ go func() {
+ var audioFile, audioFileBack string
+ var err error
+
+ if isBgBg && translation != "" {
+ audioFile, audioFileBack, err = o.GenerateAudioBgBg(ctx, word, translation, cardDir)
+ } else {
+ audioFile, err = o.GenerateAudio(ctx, word, cardDir)
+ }
+
+ audioChan <- audioGenResult{file: audioFile, fileBack: audioFileBack, err: err}
+ }()
+
+ // 2. Image generation (includes scene description from the AI)
+ go func() {
+ imageFile, err := o.generateImagesWithPromptAndNotify(ctx, word, imagePrompt, translation, cardDir, promptUI)
+ imageChan <- imageGenResult{file: imageFile, err: err}
+ }()
+
+ // 3. Phonetic information fetching
+ go func() {
+ phoneticInfo, err := o.GetPhoneticInfo(word)
+ if err != nil {
+ fmt.Printf("Warning: Failed to get phonetic info: %v\n", err)
+ phoneticInfo = "Failed to fetch phonetic information"
+ } else {
+ fmt.Printf("Successfully fetched phonetic info for '%s': %s\n", word, phoneticInfo)
+ }
+
+ savePhoneticIfValid(phoneticInfo, cardDir, word)
+ phoneticChan <- phoneticGenResult{info: phoneticInfo}
+ }()
+
+ // Collect results.
+ audioRes := <-audioChan
+ if audioRes.err != nil {
+ // Drain remaining channels to avoid goroutine leaks.
+ <-imageChan
+ <-phoneticChan
+ return GenerateResult{}, fmt.Errorf("audio generation failed: %w", audioRes.err)
+ }
+
+ imageRes := <-imageChan
+ if imageRes.err != nil {
+ <-phoneticChan
+ return GenerateResult{}, fmt.Errorf("image download failed: %w", imageRes.err)
+ }
+
+ phoneticRes := <-phoneticChan
+
+ return GenerateResult{
+ AudioFile: audioRes.file,
+ AudioFileBack: audioRes.fileBack,
+ ImageFile: imageRes.file,
+ PhoneticInfo: phoneticRes.info,
+ }, nil
+}
+
+// savePhoneticIfValid saves phonetic info to disk when the info is valid.
+func savePhoneticIfValid(phoneticInfo, cardDir, word string) {
+ if phoneticInfo == "" || phoneticInfo == "Failed to fetch phonetic information" {
+ return
+ }
+
+ phoneticFile := filepath.Join(cardDir, "phonetic.txt")
+ if err := os.WriteFile(phoneticFile, []byte(phoneticInfo), 0644); err != nil {
+ fmt.Printf("Warning: Failed to save phonetic info for '%s': %v\n", word, err)
+ }
+}
diff --git a/internal/gui/voice_selector.go b/internal/gui/voice_selector.go
new file mode 100644
index 0000000..7c7d4c7
--- /dev/null
+++ b/internal/gui/voice_selector.go
@@ -0,0 +1,48 @@
+package gui
+
+import (
+ "strings"
+
+ "codeberg.org/snonux/totalrecall/internal/audio"
+)
+
+// VoiceSelector picks voice and speed for a generation run from an
+// AudioConfigResolver. For Gemini with a pinned voice the configured voice is
+// used; otherwise a random voice is selected from the available list.
+type VoiceSelector struct {
+ resolver *AudioConfigResolver
+}
+
+// NewVoiceSelector constructs a VoiceSelector backed by the given resolver.
+func NewVoiceSelector(resolver *AudioConfigResolver) *VoiceSelector {
+ return &VoiceSelector{resolver: resolver}
+}
+
+// VoiceAndSpeed selects the voice and speed for a generation run.
+func (v *VoiceSelector) VoiceAndSpeed() (string, float64) {
+ switch v.resolver.ProviderName() {
+ case "gemini":
+ if v.resolver.audioConfig != nil {
+ if voice := strings.TrimSpace(v.resolver.audioConfig.GeminiVoice); voice != "" {
+ return voice, v.GeminiSpeed()
+ }
+ }
+ return randomVoice(v.resolver.Voices()), v.GeminiSpeed()
+ default:
+ return randomVoice(v.resolver.Voices()), randomOpenAISpeed()
+ }
+}
+
+// GeminiSpeed returns the configured Gemini TTS speed or the default.
+func (v *VoiceSelector) GeminiSpeed() float64 {
+ if v.resolver.audioConfig != nil && v.resolver.audioConfig.GeminiSpeed > 0 {
+ return v.resolver.audioConfig.GeminiSpeed
+ }
+ return audio.DefaultProviderConfig().GeminiSpeed
+}
+
+// GeminiVoicePinned reports whether a specific Gemini voice is locked in
+// config, meaning fallback voice selection should be skipped.
+func (v *VoiceSelector) GeminiVoicePinned() bool {
+ return v.resolver.audioConfig != nil && strings.TrimSpace(v.resolver.audioConfig.GeminiVoice) != ""
+}