2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-24 09:49:16 +02:00
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// NoProvider is the provider name ActiveProvider reports when no tier of
|
|
|
|
|
// the chain is available (status display).
|
|
|
|
|
const NoProvider = "none"
|
2026-10-24 09:49:16 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-24 09:49:16 +02:00
|
|
|
type Request struct {
|
|
|
|
|
Text string
|
2026-10-24 11:15:35 +02:00
|
|
|
Lang string // de | en; empty = langdetect
|
|
|
|
|
Provider string // provider pin; empty = config pin or default chain
|
|
|
|
|
Voice string // voice ID pin; empty = mapping/defaults
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-24 09:49:16 +02:00
|
|
|
type Engine struct {
|
|
|
|
|
cfg config.Config
|
|
|
|
|
sink audio.Sink
|
|
|
|
|
getenv func(string) string
|
|
|
|
|
log io.Writer
|
2026-10-24 11:15:35 +02:00
|
|
|
base *provider.Chain // unpinned chain: availability resolution for status
|
2026-10-24 09:49:16 +02:00
|
|
|
|
|
|
|
|
mu sync.Mutex
|
2026-10-24 11:15:35 +02:00
|
|
|
last string // most recently used provider (status when idle)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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).
|
2026-10-24 09:49:16 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-24 10:08:44 +02:00
|
|
|
func (e *Engine) Speak(ctx context.Context, req Request) (provider.Outcome, error) {
|
2026-10-24 09:49:16 +02:00
|
|
|
lang := req.Lang
|
|
|
|
|
if lang == "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
lang = langdetect.Detect(req.Text, e.cfg.Lang)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
chain, err := provider.NewChain(chainConfig(e.cfg, req.Provider, req.Voice, e.getenv, e.sink, e.log))
|
|
|
|
|
if err != nil {
|
2026-10-24 10:08:44 +02:00
|
|
|
return provider.Outcome{}, err
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
2026-10-24 10:08:44 +02:00
|
|
|
out, err := chain.SpeakOutcome(ctx, req.Text, lang)
|
|
|
|
|
if err == nil {
|
|
|
|
|
e.setLast(out.Provider)
|
|
|
|
|
}
|
|
|
|
|
return out, err
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Chain returns the unpinned base chain (diagnostics: tiers and their
|
|
|
|
|
// availability).
|
2026-10-24 10:08:44 +02:00
|
|
|
func (e *Engine) Chain() *provider.Chain { return e.base }
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-24 09:49:16 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// LastProvider returns the provider of the most recently started
|
|
|
|
|
// announcement; empty if nothing has been spoken yet.
|
2026-10-24 09:49:16 +02:00
|
|
|
func (e *Engine) LastProvider() string {
|
|
|
|
|
e.mu.Lock()
|
|
|
|
|
defer e.mu.Unlock()
|
|
|
|
|
return e.last
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// SinkName returns the name of the audio sink (oto | null).
|
2026-10-24 09:49:16 +02:00
|
|
|
func (e *Engine) SinkName() string { return e.sink.Name() }
|
|
|
|
|
|
|
|
|
|
func (e *Engine) setLast(name string) {
|
|
|
|
|
e.mu.Lock()
|
|
|
|
|
e.last = name
|
|
|
|
|
e.mu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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>].
|
2026-10-24 09:49:16 +02:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|