package audio import ( "context" "fmt" "strings" "codeberg.org/snonux/totalrecall/internal/config" "codeberg.org/snonux/totalrecall/internal/registry" ) // 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(ctx context.Context, text string, outputFile string) error // Name returns the provider name. Name() string // 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. // Callers that only use Gemini never need to populate these fields. type OpenAIAudioConfig struct { Key string Model string // "tts-1", "tts-1-hd", or "gpt-4o-mini-tts" Voice string // One of OpenAIVoices. Speed float64 // 0.25 to 4.0 Instruction string // Voice instructions for gpt-4o-mini-tts model } // GeminiAudioConfig holds settings specific to the Gemini TTS backend. // Callers that only use OpenAI never need to populate these fields. type GeminiAudioConfig struct { APIKey string TTSModel string // "gemini-2.5-flash-preview-tts" Voice string // One of GeminiVoices; empty lets the caller choose a random voice. Speed float64 // Prompt hint for desired speech speed } // Config holds common configuration for audio providers. Provider-specific // settings are grouped into OpenAI and Gemini sub-configs so callers and // implementations only see the fields relevant to their backend. type Config struct { Provider string // Provider name: "openai" or "gemini" OutputDir string // Directory for output files OutputFormat string // Output format: "mp3" or "wav" // OpenAI-specific settings — ignored when Provider == "gemini". OpenAIKey string OpenAIModel string // "tts-1", "tts-1-hd", or "gpt-4o-mini-tts" OpenAIVoice string // One of OpenAIVoices. OpenAISpeed float64 // 0.25 to 4.0 OpenAIInstruction string // Voice instructions for gpt-4o-mini-tts model // Gemini-specific settings — ignored when Provider == "openai". GoogleAPIKey string GeminiTTSModel string // "gemini-2.5-flash-preview-tts" GeminiVoice string // One of GeminiVoices; empty lets the caller choose a random voice. 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 { if c == nil { return OpenAIAudioConfig{} } return OpenAIAudioConfig{ Key: c.OpenAIKey, Model: c.OpenAIModel, Voice: c.OpenAIVoice, Speed: c.OpenAISpeed, Instruction: c.OpenAIInstruction, } } // geminiAudioConfigFrom extracts the Gemini-specific sub-config from the flat Config. // A nil Config produces a zero-value GeminiAudioConfig. func geminiAudioConfigFrom(c *Config) GeminiAudioConfig { if c == nil { return GeminiAudioConfig{} } return GeminiAudioConfig{ APIKey: c.GoogleAPIKey, TTSModel: c.GeminiTTSModel, Voice: c.GeminiVoice, Speed: c.GeminiSpeed, } } // DefaultProviderConfig returns default configuration (shared literals live in // internal/config/defaults.go). func DefaultProviderConfig() *Config { return &Config{ Provider: config.ProviderGemini, OutputDir: "./", OutputFormat: config.DefaultAudioOutputFormat, OpenAIModel: config.DefaultOpenAIAudioModel, OpenAIVoice: config.DefaultOpenAIVoice, OpenAISpeed: config.DefaultOpenAIAudioSpeed, OpenAIInstruction: config.DefaultOpenAIAudioInstruction, GeminiTTSModel: config.DefaultGeminiTTSModel, GeminiSpeed: config.DefaultGeminiAudioSpeed, } } // ProviderFactory is the canonical type for functions that construct an audio // Provider from a Config. Using a named type avoids duplicating the raw function // signature in every package that needs to inject or replace the factory // (processor, gui). The production default is audio.NewProvider itself. type ProviderFactory func(*Config) (Provider, error) // defaultAudioProviders maps provider name to constructor. New providers are // registered here so NewProvider does not grow a new switch branch each time. var defaultAudioProviders = func() *registry.Registry[string, func(*Config) (Provider, error)] { r := registry.New[string, func(*Config) (Provider, error)]() r.Register("openai", newOpenAIProviderFromConfig) r.Register("gemini", newGeminiProviderFromConfig) return r }() func newOpenAIProviderFromConfig(config *Config) (Provider, error) { if config.OpenAIKey == "" { return nil, fmt.Errorf("OpenAI API key is required") } return NewOpenAIProvider(openAIAudioConfigFrom(config), config.OutputFormat) } func newGeminiProviderFromConfig(config *Config) (Provider, error) { if config.GoogleAPIKey == "" { return nil, fmt.Errorf("google API key is required") } return NewGeminiProvider(geminiAudioConfigFrom(config), config.OutputFormat) } // NewProvider creates the appropriate audio provider based on configuration. // It extracts provider-specific sub-configs so each implementation only // receives the fields it needs (ISP). func NewProvider(config *Config) (Provider, error) { if config == nil { config = DefaultProviderConfig() } name := strings.ToLower(strings.TrimSpace(config.Provider)) fn, ok := defaultAudioProviders.Get(name) if !ok { return nil, fmt.Errorf("unknown audio provider: %s", config.Provider) } return fn(config) }