Files
talk/internal/speak/engine.go
T

144 lines
4.6 KiB
Go
Raw Normal View History

// 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
}
// 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.<name>].
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,
}
}