talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.
Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.
Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
145 lines
4.6 KiB
Go
145 lines
4.6 KiB
Go
// 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,
|
|
}
|
|
}
|