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>
305 lines
11 KiB
Go
305 lines
11 KiB
Go
package provider
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// DefaultOrder is the binding default fallback order of the chain
|
|
// (spec: ElevenLabs → OpenAI TTS → Edge TTS → OS built-in).
|
|
var DefaultOrder = []string{"elevenlabs", "openai", "edge", "osbuiltin"}
|
|
|
|
// NullPlayerName is the documented name of the discarding sink
|
|
// (spec: audio.sink = "oto" | "null"). The chain needs it to decide
|
|
// whether a direct announcement (DirectSpeaker) must be discarded; the
|
|
// null sink must never produce sound.
|
|
const NullPlayerName = "null"
|
|
|
|
// Player plays audio bytes the chain has produced. The contract is
|
|
// deliberately structurally identical to audio.Sink (Play + Name): CLI and
|
|
// daemon pass the result of audio.NewSink(name, log) unchanged, which keeps
|
|
// the chain testable and reusable without importing the audio package.
|
|
type Player interface {
|
|
Play(ctx context.Context, data []byte) error
|
|
Name() string
|
|
}
|
|
|
|
// ChainConfig is everything the chain needs for construction. CLI and
|
|
// daemon fill it from config + flags; the chain itself reads no files and
|
|
// no process environment. Getenv is the injectable seam (tests and daemon
|
|
// thus work without real keys).
|
|
type ChainConfig struct {
|
|
// Pin reduces the chain to exactly one provider (CLI flag --provider
|
|
// beats config `provider = "…"`). Empty = DefaultOrder.
|
|
Pin string
|
|
|
|
// Voice is an explicit voice ID (CLI flag --voice); it is pinned on all
|
|
// tiers via VoicePinner and beats mapping + defaults.
|
|
Voice string
|
|
|
|
// Voices is the voice.<provider>.<lang> mapping from config.toml.
|
|
Voices map[string]map[string]string
|
|
|
|
// Endpoints are the file endpoints [providers.<name>].endpoint;
|
|
// TALK_<NAME>_ENDPOINT (via Getenv) beats them, they beat the built-in
|
|
// defaults (see endpointFrom).
|
|
Endpoints map[string]string
|
|
|
|
// Model is [providers.openai].model (empty = "tts-1").
|
|
Model string
|
|
|
|
// Getenv returns environment variables (keys, endpoint overrides). Nil
|
|
// is replaced at construction by a function that always returns "".
|
|
Getenv func(string) string
|
|
|
|
// Player plays successful syntheses; nil = the chain only returns bytes
|
|
// (Synthesize) and Speak reports a clear error.
|
|
Player Player
|
|
|
|
// Log receives the fallback log lines (CLI: stderr). Fallbacks are never
|
|
// silent: every skipped or failed tier is reported here, even when a
|
|
// later tier succeeds.
|
|
Log io.Writer
|
|
}
|
|
|
|
// endpointFrom resolves a provider endpoint:
|
|
// TALK_<NAME>_ENDPOINT (env) > [providers.<name>].endpoint (file) >
|
|
// built-in default. Reading env overrides in the provider layer keeps the
|
|
// config package free of provider names and allows process-boundary tests
|
|
// without a config file.
|
|
func endpointFrom(cfg ChainConfig, name, envName, def string) string {
|
|
if cfg.Getenv != nil {
|
|
if v := cfg.Getenv(envName); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
if v := cfg.Endpoints[name]; v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
// Chain is the ordered fallback chain over providers. It implements
|
|
// Provider itself (Synthesize returns the bytes of the first successful
|
|
// tier) AND DirectSpeaker (last-resort tier for platforms without
|
|
// byte-capable TTS), so CLI and daemon can swap it for any single provider.
|
|
//
|
|
// Speak is the complete announcement orchestration: byte synthesis with
|
|
// fallback, playback via the Player, and as the last tier the direct
|
|
// announcement (osbuiltin via spd-say). With a null player the direct
|
|
// announcement is discarded silently (CI/server) instead of producing
|
|
// sound.
|
|
type Chain struct {
|
|
cfg ChainConfig
|
|
tiers []Provider
|
|
}
|
|
|
|
// NewChain builds the chain: pinned (Pin) or in DefaultOrder. Unknown names
|
|
// are a clear error that lists the available names. The --voice pin is
|
|
// applied to all tiers implementing VoicePinner (tiers without a notion of
|
|
// voice get a logged note).
|
|
func NewChain(cfg ChainConfig) (*Chain, error) {
|
|
if cfg.Getenv == nil {
|
|
cfg.Getenv = func(string) string { return "" }
|
|
}
|
|
names := DefaultOrder
|
|
if cfg.Pin != "" {
|
|
names = []string{cfg.Pin}
|
|
}
|
|
tiers := make([]Provider, 0, len(names))
|
|
for _, name := range names {
|
|
p, err := New(name, cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cfg.Voice != "" {
|
|
if vp, ok := p.(VoicePinner); ok {
|
|
vp.PinVoice(cfg.Voice)
|
|
} else {
|
|
chainLogf(cfg, "chain: --voice ignored for tier %s (it has no voice IDs)\n", name)
|
|
}
|
|
}
|
|
tiers = append(tiers, p)
|
|
}
|
|
return &Chain{cfg: cfg, tiers: tiers}, nil
|
|
}
|
|
|
|
// Name implements Provider.
|
|
func (*Chain) Name() string { return "chain" }
|
|
|
|
// Tiers returns the tiers in chain order (diagnosis/doctor).
|
|
func (c *Chain) Tiers() []Provider { return c.tiers }
|
|
|
|
// Available implements Provider: true as soon as one tier is available.
|
|
func (c *Chain) Available(ctx context.Context) bool {
|
|
for _, p := range c.tiers {
|
|
if p.Available(ctx) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// walkResult is the internal result of a chain walk.
|
|
type walkResult struct {
|
|
data []byte // bytes of the first successful tier (nil = none)
|
|
used Provider // the successful tier
|
|
direct DirectSpeaker // first tier that can only speak directly
|
|
directName string
|
|
failed []string // "name: error" per failed tier
|
|
skipped []string // names of unavailable tiers
|
|
}
|
|
|
|
// walk runs the chain in order: unavailable tiers are skipped (logged),
|
|
// failed ones are logged and passed over. A tier returning ErrNoByteOutput
|
|
// that implements DirectSpeaker is remembered as the last resort (the walk
|
|
// continues; a later tier might still deliver bytes).
|
|
func (c *Chain) walk(ctx context.Context, text, lang string) walkResult {
|
|
var r walkResult
|
|
for _, p := range c.tiers {
|
|
if !p.Available(ctx) {
|
|
r.skipped = append(r.skipped, p.Name())
|
|
chainLogf(c.cfg, "chain: tier %s skipped (not available)\n", p.Name())
|
|
continue
|
|
}
|
|
data, err := p.Synthesize(ctx, text, lang)
|
|
switch {
|
|
case err == nil:
|
|
r.data, r.used = data, p
|
|
chainLogf(c.cfg, "chain: spoken via %s\n", p.Name())
|
|
return r
|
|
case errors.Is(err, ErrNoByteOutput) && r.direct == nil:
|
|
if ds, ok := p.(DirectSpeaker); ok {
|
|
r.direct, r.directName = ds, p.Name()
|
|
chainLogf(c.cfg, "chain: tier %s yields no bytes but can speak directly (kept as last resort)\n", p.Name())
|
|
continue
|
|
}
|
|
r.failed = append(r.failed, p.Name()+": "+err.Error())
|
|
chainLogf(c.cfg, "chain: tier %s failed: %v; falling back to the next tier\n", p.Name(), err)
|
|
default:
|
|
r.failed = append(r.failed, p.Name()+": "+err.Error())
|
|
chainLogf(c.cfg, "chain: tier %s failed: %v; falling back to the next tier\n", p.Name(), err)
|
|
}
|
|
}
|
|
return r
|
|
}
|
|
|
|
// Synthesize implements Provider: bytes of the first successful tier. If
|
|
// only a direct tier can speak, the error is ErrNoByteOutput (the chain thus
|
|
// honours the DirectSpeaker contract towards the caller). If everything
|
|
// fails, the error names the tier errors; never silent.
|
|
func (c *Chain) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
|
|
r := c.walk(ctx, text, lang)
|
|
if r.data != nil {
|
|
return r.data, nil
|
|
}
|
|
if r.direct != nil {
|
|
if len(r.failed) > 0 {
|
|
return nil, fmt.Errorf("%w (tier %s speaks directly only; also failed: %s)",
|
|
ErrNoByteOutput, r.directName, strings.Join(r.failed, "; "))
|
|
}
|
|
return nil, fmt.Errorf("%w (tier %s speaks directly only)", ErrNoByteOutput, r.directName)
|
|
}
|
|
return nil, r.noTierErr()
|
|
}
|
|
|
|
// SpeakDirect implements DirectSpeaker: direct announcement via the tier
|
|
// that can only speak directly (typically osbuiltin/spd-say on Linux
|
|
// without espeak). If a byte-capable tier exists, SpeakDirect is a clear
|
|
// error (the caller should use Synthesize + Player).
|
|
func (c *Chain) SpeakDirect(ctx context.Context, text, lang string) error {
|
|
r := c.walk(ctx, text, lang)
|
|
if r.direct != nil {
|
|
return r.direct.SpeakDirect(ctx, text, lang)
|
|
}
|
|
if r.data != nil {
|
|
return fmt.Errorf("chain: tier %s yields bytes; play them via Synthesize and the player, not directly", r.used.Name())
|
|
}
|
|
return fmt.Errorf("chain: no tier can speak directly (not available: %s; failed: %s)",
|
|
joinOr(r.skipped, "none"), joinOr(r.failed, "none"))
|
|
}
|
|
|
|
// Speak is the complete announcement in ONE chain walk: synthesis with
|
|
// fallback → playback via the Player; if the chain falls back to the direct
|
|
// tier, the player name decides between sound (anything but "null") and
|
|
// silent discarding (null: CI/server stay error- and sound-free, the
|
|
// discard is logged). The walk deliberately happens exactly once: failed
|
|
// cloud tiers are not requested a second time (no duplicate POSTs to paid
|
|
// APIs, no duplicate log lines), and bytes from this walk are played
|
|
// instead of being thrown away and re-run. Only a failure of all tiers or a
|
|
// failed playback are errors.
|
|
func (c *Chain) Speak(ctx context.Context, text, lang string) error {
|
|
_, err := c.SpeakOutcome(ctx, text, lang)
|
|
return err
|
|
}
|
|
|
|
// Outcome describes a successful announcement: which tier spoke, how long
|
|
// synthesis (chain walk up to the bytes) and playback took, and whether it
|
|
// was a direct announcement (no bytes, the provider plays itself; then
|
|
// Synthesis is the total time and Playback is 0). Discarded reports the
|
|
// silent discarding of a direct announcement on the null sink.
|
|
type Outcome struct {
|
|
Provider string
|
|
Direct bool
|
|
Discarded bool
|
|
Bytes int
|
|
Synthesis time.Duration
|
|
Playback time.Duration
|
|
}
|
|
|
|
// SpeakOutcome is Speak with a report (diagnosis: `talk test`, daemon log).
|
|
func (c *Chain) SpeakOutcome(ctx context.Context, text, lang string) (Outcome, error) {
|
|
start := time.Now()
|
|
r := c.walk(ctx, text, lang)
|
|
synth := time.Since(start)
|
|
switch {
|
|
case r.data != nil:
|
|
if c.cfg.Player == nil {
|
|
return Outcome{}, fmt.Errorf("chain: no player configured; bytes cannot be played")
|
|
}
|
|
playStart := time.Now()
|
|
if err := c.cfg.Player.Play(ctx, r.data); err != nil {
|
|
return Outcome{}, fmt.Errorf("chain: playback via %s failed: %w", c.cfg.Player.Name(), err)
|
|
}
|
|
return Outcome{Provider: r.used.Name(), Bytes: len(r.data), Synthesis: synth, Playback: time.Since(playStart)}, nil
|
|
case r.direct != nil:
|
|
if c.cfg.Player != nil && c.cfg.Player.Name() == NullPlayerName {
|
|
chainLogf(c.cfg, "chain: direct speech discarded (audio.sink=null)\n")
|
|
return Outcome{Provider: r.directName, Direct: true, Discarded: true, Synthesis: synth}, nil
|
|
}
|
|
if err := r.direct.SpeakDirect(ctx, text, lang); err != nil {
|
|
return Outcome{}, err
|
|
}
|
|
return Outcome{Provider: r.directName, Direct: true, Synthesis: time.Since(start)}, nil
|
|
default:
|
|
return Outcome{}, r.noTierErr()
|
|
}
|
|
}
|
|
|
|
// noTierErr bundles the no-result error of a walk: no tier delivered bytes
|
|
// or a direct announcement; skipped (unavailable) and failed tiers are
|
|
// named, never silent.
|
|
func (r walkResult) noTierErr() error {
|
|
return fmt.Errorf("chain: no tier could speak (not available: %s; failed: %s)",
|
|
joinOr(r.skipped, "none"), joinOr(r.failed, "none"))
|
|
}
|
|
|
|
// chainLogf writes a log line if a log is set.
|
|
func chainLogf(cfg ChainConfig, format string, args ...any) {
|
|
if cfg.Log != nil {
|
|
fmt.Fprintf(cfg.Log, format, args...)
|
|
}
|
|
}
|
|
|
|
// joinOr joins items with "; "; if the list is empty, fallback is returned.
|
|
func joinOr(items []string, fallback string) string {
|
|
if len(items) == 0 {
|
|
return fallback
|
|
}
|
|
return strings.Join(items, "; ")
|
|
}
|