summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-02 22:26:43 +0300
committerPaul Buetow <paul@buetow.org>2026-04-02 22:26:43 +0300
commit5a1a6b863c3adaa8ec9d087ba130f7676fc9575b (patch)
tree31494a962d15f46bed22f8ba090afe7511dc54c7
parent842ce63eda42a5dfa1599bf69b5ea22b332a954d (diff)
task zz: add Voices()/BuildAttribution() to Provider interface [OCP]
Extend audio.Provider with Voices() []string and BuildAttribution() string so all provider-specific behaviour is encapsulated in the implementation rather than scattered as switch-cases across callers. Add package-level VoicesFor(name) and BuildAttributionFor(name, params) for callers (processor, GUI) that need these before constructing a Provider instance. Add AttributionParamsFrom(config, word, ...) so callers can build AttributionParams from the flat Config without a manual provider switch. Implement both new interface methods in OpenAIProvider and GeminiProvider. Update all Provider mock/fake types in tests. Migrate audioVoicesForProvider() and saveAudioAttribution() in both processor.go and gui/generator.go to use the new package-level helpers, replacing the 10+ duplicated switch blocks. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--internal/audio/attribution.go28
-rw-r--r--internal/audio/gemini_provider.go10
-rw-r--r--internal/audio/openai_provider.go10
-rw-r--r--internal/audio/provider.go36
-rw-r--r--internal/audio/provider_test.go8
-rw-r--r--internal/gui/generator.go70
-rw-r--r--internal/gui/generator_test.go8
-rw-r--r--internal/processor/processor.go35
-rw-r--r--internal/processor/processor_test.go8
9 files changed, 136 insertions, 77 deletions
diff --git a/internal/audio/attribution.go b/internal/audio/attribution.go
index 7c3cda2..57f8c1d 100644
--- a/internal/audio/attribution.go
+++ b/internal/audio/attribution.go
@@ -18,6 +18,34 @@ type AttributionParams struct {
GeneratedAt time.Time
}
+// AttributionParamsFrom builds an AttributionParams from a flat Config and the
+// word being transcribed, reading provider-appropriate sub-config fields to
+// eliminate the need for callers to switch on config.Provider themselves.
+func AttributionParamsFrom(config *Config, word, instruction, processedText string, generatedAt time.Time) AttributionParams {
+ base := AttributionParams{
+ Word: word,
+ Instruction: instruction,
+ ProcessedText: processedText,
+ GeneratedAt: generatedAt,
+ }
+ if config == nil {
+ return base
+ }
+ switch strings.ToLower(strings.TrimSpace(config.Provider)) {
+ case "gemini":
+ g := geminiAudioConfigFrom(config)
+ base.Model = g.TTSModel
+ base.Voice = g.Voice
+ base.Speed = g.Speed
+ default:
+ o := openAIAudioConfigFrom(config)
+ base.Model = o.Model
+ base.Voice = o.Voice
+ base.Speed = o.Speed
+ }
+ return base
+}
+
// AttributionPath returns the sidecar attribution file path for a generated audio file.
func AttributionPath(audioFile string) string {
return strings.TrimSuffix(audioFile, filepath.Ext(audioFile)) + "_attribution.txt"
diff --git a/internal/audio/gemini_provider.go b/internal/audio/gemini_provider.go
index bda01f5..5100c5e 100644
--- a/internal/audio/gemini_provider.go
+++ b/internal/audio/gemini_provider.go
@@ -97,6 +97,16 @@ func (p *GeminiProvider) Name() string {
return "gemini"
}
+// Voices returns the list of Gemini voices supported by the app.
+func (p *GeminiProvider) Voices() []string {
+ return GeminiVoices
+}
+
+// BuildAttribution returns the Gemini attribution text for a generated audio file.
+func (p *GeminiProvider) BuildAttribution(params AttributionParams) string {
+ return BuildGeminiAttribution(params)
+}
+
// IsAvailable checks if the Google API key is configured.
func (p *GeminiProvider) IsAvailable() error {
if p == nil || strings.TrimSpace(p.config.APIKey) == "" {
diff --git a/internal/audio/openai_provider.go b/internal/audio/openai_provider.go
index 9c6d3a4..e6212f8 100644
--- a/internal/audio/openai_provider.go
+++ b/internal/audio/openai_provider.go
@@ -147,6 +147,16 @@ func (p *OpenAIProvider) IsAvailable() error {
return nil
}
+// Voices returns the list of OpenAI voices supported by the app.
+func (p *OpenAIProvider) Voices() []string {
+ return OpenAIVoices
+}
+
+// BuildAttribution returns the OpenAI attribution text for a generated audio file.
+func (p *OpenAIProvider) BuildAttribution(params AttributionParams) string {
+ return BuildOpenAIAttribution(params)
+}
+
// preprocessBulgarianText prepares Bulgarian text for clearer TTS pronunciation
func (p *OpenAIProvider) preprocessBulgarianText(text string) string {
return openAIProcessedText(text)
diff --git a/internal/audio/provider.go b/internal/audio/provider.go
index fac0cd9..60be848 100644
--- a/internal/audio/provider.go
+++ b/internal/audio/provider.go
@@ -3,18 +3,27 @@ package audio
import (
"context"
"fmt"
+ "strings"
)
-// Provider defines the interface for text-to-speech providers
+// Provider defines the interface for text-to-speech providers.
+// All provider-specific behavior (voices, attribution) is encapsulated here
+// so callers never need to switch on the provider name (OCP).
type Provider interface {
- // GenerateAudio generates audio from text and saves it to the specified file
+ // GenerateAudio generates audio from text and saves it to the specified file.
GenerateAudio(ctx context.Context, text string, outputFile string) error
- // Name returns the provider name
+ // Name returns the provider name.
Name() string
- // IsAvailable checks if the provider is properly configured and available
+ // IsAvailable checks if the provider is properly configured and available.
IsAvailable() error
+
+ // Voices returns the list of voice names supported by this provider.
+ Voices() []string
+
+ // BuildAttribution returns the attribution text for a generated audio file.
+ BuildAttribution(params AttributionParams) string
}
// OpenAIAudioConfig holds settings specific to the OpenAI TTS backend.
@@ -58,6 +67,25 @@ type Config struct {
GeminiSpeed float64 // Prompt hint for desired speech speed
}
+// VoicesFor returns the voice list for the named provider. This is a
+// convenience for callers that need voices before constructing a Provider.
+func VoicesFor(providerName string) []string {
+ if strings.ToLower(strings.TrimSpace(providerName)) == "gemini" {
+ return GeminiVoices
+ }
+ return OpenAIVoices
+}
+
+// BuildAttributionFor builds the attribution text for the named provider
+// without requiring a Provider instance. Use Provider.BuildAttribution when
+// you already have an instance.
+func BuildAttributionFor(providerName string, params AttributionParams) string {
+ if strings.ToLower(strings.TrimSpace(providerName)) == "gemini" {
+ return BuildGeminiAttribution(params)
+ }
+ return BuildOpenAIAttribution(params)
+}
+
// openAIAudioConfigFrom extracts the OpenAI-specific sub-config from the flat Config.
// A nil Config produces a zero-value OpenAIAudioConfig.
func openAIAudioConfigFrom(c *Config) OpenAIAudioConfig {
diff --git a/internal/audio/provider_test.go b/internal/audio/provider_test.go
index 5702016..9b8037c 100644
--- a/internal/audio/provider_test.go
+++ b/internal/audio/provider_test.go
@@ -28,6 +28,14 @@ func (m *mockProvider) IsAvailable() error {
return m.availableErr
}
+func (m *mockProvider) Voices() []string {
+ return nil
+}
+
+func (m *mockProvider) BuildAttribution(params AttributionParams) string {
+ return ""
+}
+
func TestDefaultProviderConfig(t *testing.T) {
config := DefaultProviderConfig()
diff --git a/internal/gui/generator.go b/internal/gui/generator.go
index 72c7c51..4763149 100644
--- a/internal/gui/generator.go
+++ b/internal/gui/generator.go
@@ -15,8 +15,10 @@ import (
"codeberg.org/snonux/totalrecall/internal/image"
)
+// promptAwareImageClient extends ImageClient with prompt-callback support
+// used by the GUI to capture and display the last generated image prompt.
type promptAwareImageClient interface {
- image.ImageSearcher
+ image.ImageClient
SetPromptCallback(func(prompt string))
}
@@ -49,13 +51,9 @@ func (a *Application) audioProviderName() string {
return audio.DefaultProviderConfig().Provider
}
+// audioVoices returns the voice list for the configured provider.
func (a *Application) audioVoices() []string {
- switch a.audioProviderName() {
- case "gemini":
- return audio.GeminiVoices
- default:
- return audio.OpenAIVoices
- }
+ return audio.VoicesFor(a.audioProviderName())
}
func (a *Application) audioVoiceAndSpeed() (string, float64) {
@@ -455,45 +453,29 @@ func (a *Application) imagePromptCallback(cardDir, word string) func(prompt stri
}
}
-// saveAudioAttribution saves attribution info for generated audio
+// 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)
- 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,
- ProcessedText: processedText,
- 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,
- ProcessedText: processedText,
- GeneratedAt: time.Now(),
- })
- }
+ 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)
diff --git a/internal/gui/generator_test.go b/internal/gui/generator_test.go
index 6892fb1..4e4e3cc 100644
--- a/internal/gui/generator_test.go
+++ b/internal/gui/generator_test.go
@@ -84,6 +84,14 @@ func (f *fakeAudioProvider) IsAvailable() error {
return nil
}
+func (f *fakeAudioProvider) Voices() []string {
+ return nil
+}
+
+func (f *fakeAudioProvider) BuildAttribution(params audio.AttributionParams) string {
+ return ""
+}
+
func TestGenerateImagesWithPromptUsesNanoBananaProvider(t *testing.T) {
originalNanoBananaClient := newNanoBananaImageClient
originalOpenAIClient := newOpenAIImageClient
diff --git a/internal/processor/processor.go b/internal/processor/processor.go
index 40487b9..41d6858 100644
--- a/internal/processor/processor.go
+++ b/internal/processor/processor.go
@@ -311,13 +311,10 @@ func (p *Processor) openAIVoice() string {
return ""
}
+// audioVoicesForProvider returns the voice list for the configured provider
+// without needing a Provider instance (uses the package-level VoicesFor).
func (p *Processor) audioVoicesForProvider() []string {
- switch p.audioProviderName() {
- case "gemini":
- return audio.GeminiVoices
- default:
- return audio.OpenAIVoices
- }
+ return audio.VoicesFor(p.audioProviderName())
}
func (p *Processor) audioVoiceForProvider() string {
@@ -1008,29 +1005,9 @@ func (p *Processor) saveAudioAttribution(word, audioFile string, config *audio.C
processedText := audio.ProcessedTextForProvider(config.Provider, word)
instruction := audio.InstructionForProvider(config.Provider, config)
- var attribution string
- switch strings.ToLower(strings.TrimSpace(config.Provider)) {
- case "gemini":
- attribution = audio.BuildGeminiAttribution(audio.AttributionParams{
- Word: word,
- Model: config.GeminiTTSModel,
- Voice: config.GeminiVoice,
- Speed: config.GeminiSpeed,
- Instruction: instruction,
- ProcessedText: processedText,
- GeneratedAt: time.Now(),
- })
- default:
- attribution = audio.BuildOpenAIAttribution(audio.AttributionParams{
- Word: word,
- Model: config.OpenAIModel,
- Voice: config.OpenAIVoice,
- Speed: config.OpenAISpeed,
- Instruction: instruction,
- ProcessedText: processedText,
- GeneratedAt: time.Now(),
- })
- }
+ // Build params from the provider-specific sub-config to avoid a manual switch.
+ params := audio.AttributionParamsFrom(config, word, instruction, processedText, time.Now())
+ attribution := audio.BuildAttributionFor(config.Provider, params)
// Save to file
attrPath := audio.AttributionPath(audioFile)
diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go
index cfa4545..34cc844 100644
--- a/internal/processor/processor_test.go
+++ b/internal/processor/processor_test.go
@@ -101,6 +101,14 @@ func (f *fakeAudioProvider) IsAvailable() error {
return nil
}
+func (f *fakeAudioProvider) Voices() []string {
+ return nil
+}
+
+func (f *fakeAudioProvider) BuildAttribution(params audio.AttributionParams) string {
+ return ""
+}
+
func captureStdout(t *testing.T, fn func()) (output string) {
t.Helper()