// Package speak is talk's single speech core: it assembles config, audio // sink and provider chain into an announcement. The daemon calls it from // the queue worker, the CLI only in the emergency path without a daemon // (directly, without a queue); both go through exactly this path so there // is no second assembly of the chain that could drift apart. package speak import ( "context" "io" "sync" "code.m3ta.dev/m3tam3re/talk/internal/audio" "code.m3ta.dev/m3tam3re/talk/internal/config" "code.m3ta.dev/m3tam3re/talk/internal/langdetect" "code.m3ta.dev/m3tam3re/talk/internal/provider" ) // NoProvider is the provider name ActiveProvider reports when no tier of // the chain is available (status display). const NoProvider = "none" // Request is an announcement: text plus optional pins. Empty pins mean // "default": the language is detected, the chain runs in config order, the // voice comes from the voice mapping. type Request struct { Text string Lang string // de | en; empty = langdetect Provider string // provider pin; empty = config pin or default chain Voice string // voice ID pin; empty = mapping/defaults } // Engine holds sink and configuration and builds the chain per announcement // (pin and voice are properties of the individual request). It is safe for // concurrent use; the daemon speaks serially anyway. type Engine struct { cfg config.Config sink audio.Sink getenv func(string) string log io.Writer base *provider.Chain // unpinned chain: availability resolution for status mu sync.Mutex last string // most recently used provider (status when idle) } // New builds the speech core: sink per cfg.Audio.Sink, base chain per // config. getenv supplies keys and endpoint overrides (nil = empty), log // receives chain and sink output (nil = silent). func New(cfg config.Config, getenv func(string) string, log io.Writer) (*Engine, error) { if getenv == nil { getenv = func(string) string { return "" } } sink, err := audio.NewSink(cfg.Audio.Sink, log) if err != nil { return nil, err } base, err := provider.NewChain(chainConfig(cfg, "", "", getenv, nil, log)) if err != nil { return nil, err } return &Engine{cfg: cfg, sink: sink, getenv: getenv, log: log, base: base}, nil } // Speak speaks a request: detect the language (unless pinned), build the // chain, synthesize with fallback, play through the sink. Blocks until // playback ends or ctx is cancelled. The outcome names the tier that // actually spoke and the timings. func (e *Engine) Speak(ctx context.Context, req Request) (provider.Outcome, error) { lang := req.Lang if lang == "" { lang = langdetect.Detect(req.Text, e.cfg.Lang) } chain, err := provider.NewChain(chainConfig(e.cfg, req.Provider, req.Voice, e.getenv, e.sink, e.log)) if err != nil { return provider.Outcome{}, err } out, err := chain.SpeakOutcome(ctx, req.Text, lang) if err == nil { e.setLast(out.Provider) } return out, err } // Lang resolves the language of a request: the pin, or detection with the // configured fallback. func (e *Engine) Lang(req Request) string { if req.Lang != "" { return req.Lang } return langdetect.Detect(req.Text, e.cfg.Lang) } // Chain returns the unpinned base chain (diagnostics: tiers and their // availability). func (e *Engine) Chain() *provider.Chain { return e.base } // ActiveProvider resolves the "active provider" for a request: the pin, // otherwise the first available tier of the base chain (Available is cheap: // key/binary check without network), otherwise NoProvider. func (e *Engine) ActiveProvider(pin string) string { if pin != "" { return pin } for _, p := range e.base.Tiers() { if p.Available(context.Background()) { return p.Name() } } return NoProvider } // LastProvider returns the provider of the most recently started // announcement; empty if nothing has been spoken yet. func (e *Engine) LastProvider() string { e.mu.Lock() defer e.mu.Unlock() return e.last } // SinkName returns the name of the audio sink (oto | null). func (e *Engine) SinkName() string { return e.sink.Name() } func (e *Engine) setLast(name string) { e.mu.Lock() e.last = name e.mu.Unlock() } // chainConfig turns config plus request pins into the ChainConfig: the // request pin beats the config pin `provider = "..."`; endpoints and the // OpenAI model come from [providers.]. func chainConfig(cfg config.Config, pin, voice string, getenv func(string) string, player provider.Player, log io.Writer) provider.ChainConfig { if pin == "" { pin = cfg.Provider } endpoints := make(map[string]string, len(cfg.Providers)) for name, settings := range cfg.Providers { if settings.Endpoint != "" { endpoints[name] = settings.Endpoint } } return provider.ChainConfig{ Pin: pin, Voice: voice, Voices: cfg.Voice, Endpoints: endpoints, Model: cfg.Providers["openai"].Model, Getenv: getenv, Player: player, Log: log, } }