2026-10-18 16:34:43 +02:00
|
|
|
package provider
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"strings"
|
2026-10-24 10:08:44 +02:00
|
|
|
"time"
|
2026-10-18 16:34:43 +02:00
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// DefaultOrder is the binding default fallback order of the chain
|
|
|
|
|
// (spec: ElevenLabs → OpenAI TTS → Edge TTS → OS built-in).
|
2026-10-18 16:34:43 +02:00
|
|
|
var DefaultOrder = []string{"elevenlabs", "openai", "edge", "osbuiltin"}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 16:34:43 +02:00
|
|
|
const NullPlayerName = "null"
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 16:34:43 +02:00
|
|
|
type Player interface {
|
|
|
|
|
Play(ctx context.Context, data []byte) error
|
|
|
|
|
Name() string
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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).
|
2026-10-18 16:34:43 +02:00
|
|
|
type ChainConfig struct {
|
2026-10-24 11:15:35 +02:00
|
|
|
// Pin reduces the chain to exactly one provider (CLI flag --provider
|
|
|
|
|
// beats config `provider = "…"`). Empty = DefaultOrder.
|
2026-10-18 16:34:43 +02:00
|
|
|
Pin string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Voice is an explicit voice ID (CLI flag --voice); it is pinned on all
|
|
|
|
|
// tiers via VoicePinner and beats mapping + defaults.
|
2026-10-18 16:34:43 +02:00
|
|
|
Voice string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Voices is the voice.<provider>.<lang> mapping from config.toml.
|
2026-10-18 16:34:43 +02:00
|
|
|
Voices map[string]map[string]string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Endpoints are the file endpoints [providers.<name>].endpoint;
|
|
|
|
|
// TALK_<NAME>_ENDPOINT (via Getenv) beats them, they beat the built-in
|
|
|
|
|
// defaults (see endpointFrom).
|
2026-10-18 16:34:43 +02:00
|
|
|
Endpoints map[string]string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Model is [providers.openai].model (empty = "tts-1").
|
2026-10-18 16:34:43 +02:00
|
|
|
Model string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Getenv returns environment variables (keys, endpoint overrides). Nil
|
|
|
|
|
// is replaced at construction by a function that always returns "".
|
2026-10-18 16:34:43 +02:00
|
|
|
Getenv func(string) string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Player plays successful syntheses; nil = the chain only returns bytes
|
|
|
|
|
// (Synthesize) and Speak reports a clear error.
|
2026-10-18 16:34:43 +02:00
|
|
|
Player Player
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 16:34:43 +02:00
|
|
|
Log io.Writer
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 16:34:43 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 16:34:43 +02:00
|
|
|
//
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 16:34:43 +02:00
|
|
|
type Chain struct {
|
|
|
|
|
cfg ChainConfig
|
|
|
|
|
tiers []Provider
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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).
|
2026-10-18 16:34:43 +02:00
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
chainLogf(cfg, "chain: --voice ignored for tier %s (it has no voice IDs)\n", name)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
tiers = append(tiers, p)
|
|
|
|
|
}
|
|
|
|
|
return &Chain{cfg: cfg, tiers: tiers}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Name implements Provider.
|
2026-10-18 16:34:43 +02:00
|
|
|
func (*Chain) Name() string { return "chain" }
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Tiers returns the tiers in chain order (diagnosis/doctor).
|
2026-10-18 16:34:43 +02:00
|
|
|
func (c *Chain) Tiers() []Provider { return c.tiers }
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Available implements Provider: true as soon as one tier is available.
|
2026-10-18 16:34:43 +02:00
|
|
|
func (c *Chain) Available(ctx context.Context) bool {
|
|
|
|
|
for _, p := range c.tiers {
|
|
|
|
|
if p.Available(ctx) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// walkResult is the internal result of a chain walk.
|
2026-10-18 16:34:43 +02:00
|
|
|
type walkResult struct {
|
2026-10-24 11:15:35 +02:00
|
|
|
data []byte // bytes of the first successful tier (nil = none)
|
|
|
|
|
used Provider // the successful tier
|
|
|
|
|
direct DirectSpeaker // first tier that can only speak directly
|
2026-10-18 16:34:43 +02:00
|
|
|
directName string
|
2026-10-24 11:15:35 +02:00
|
|
|
failed []string // "name: error" per failed tier
|
|
|
|
|
skipped []string // names of unavailable tiers
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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).
|
2026-10-18 16:34:43 +02:00
|
|
|
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())
|
2026-10-24 11:15:35 +02:00
|
|
|
chainLogf(c.cfg, "chain: tier %s skipped (not available)\n", p.Name())
|
2026-10-18 16:34:43 +02:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
data, err := p.Synthesize(ctx, text, lang)
|
|
|
|
|
switch {
|
|
|
|
|
case err == nil:
|
|
|
|
|
r.data, r.used = data, p
|
2026-10-24 11:15:35 +02:00
|
|
|
chainLogf(c.cfg, "chain: spoken via %s\n", p.Name())
|
2026-10-18 16:34:43 +02:00
|
|
|
return r
|
|
|
|
|
case errors.Is(err, ErrNoByteOutput) && r.direct == nil:
|
|
|
|
|
if ds, ok := p.(DirectSpeaker); ok {
|
|
|
|
|
r.direct, r.directName = ds, p.Name()
|
2026-10-24 11:15:35 +02:00
|
|
|
chainLogf(c.cfg, "chain: tier %s yields no bytes but can speak directly (kept as last resort)\n", p.Name())
|
2026-10-18 16:34:43 +02:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
r.failed = append(r.failed, p.Name()+": "+err.Error())
|
2026-10-24 11:15:35 +02:00
|
|
|
chainLogf(c.cfg, "chain: tier %s failed: %v; falling back to the next tier\n", p.Name(), err)
|
2026-10-18 16:34:43 +02:00
|
|
|
default:
|
|
|
|
|
r.failed = append(r.failed, p.Name()+": "+err.Error())
|
2026-10-24 11:15:35 +02:00
|
|
|
chainLogf(c.cfg, "chain: tier %s failed: %v; falling back to the next tier\n", p.Name(), err)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return r
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 16:34:43 +02:00
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("%w (tier %s speaks directly only; also failed: %s)",
|
2026-10-18 16:34:43 +02:00
|
|
|
ErrNoByteOutput, r.directName, strings.Join(r.failed, "; "))
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("%w (tier %s speaks directly only)", ErrNoByteOutput, r.directName)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
2026-10-18 16:41:23 +02:00
|
|
|
return nil, r.noTierErr()
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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).
|
2026-10-18 16:34:43 +02:00
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
return fmt.Errorf("chain: tier %s yields bytes; play them via Synthesize and the player, not directly", r.used.Name())
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
return fmt.Errorf("chain: no tier can speak directly (not available: %s; failed: %s)",
|
|
|
|
|
joinOr(r.skipped, "none"), joinOr(r.failed, "none"))
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 16:34:43 +02:00
|
|
|
func (c *Chain) Speak(ctx context.Context, text, lang string) error {
|
2026-10-24 10:08:44 +02:00
|
|
|
_, err := c.SpeakOutcome(ctx, text, lang)
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-24 10:08:44 +02:00
|
|
|
type Outcome struct {
|
|
|
|
|
Provider string
|
|
|
|
|
Direct bool
|
|
|
|
|
Discarded bool
|
|
|
|
|
Bytes int
|
|
|
|
|
Synthesis time.Duration
|
|
|
|
|
Playback time.Duration
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// SpeakOutcome is Speak with a report (diagnosis: `talk test`, daemon log).
|
2026-10-24 10:08:44 +02:00
|
|
|
func (c *Chain) SpeakOutcome(ctx context.Context, text, lang string) (Outcome, error) {
|
|
|
|
|
start := time.Now()
|
2026-10-18 16:41:23 +02:00
|
|
|
r := c.walk(ctx, text, lang)
|
2026-10-24 10:08:44 +02:00
|
|
|
synth := time.Since(start)
|
2026-10-18 16:34:43 +02:00
|
|
|
switch {
|
2026-10-18 16:41:23 +02:00
|
|
|
case r.data != nil:
|
2026-10-18 16:34:43 +02:00
|
|
|
if c.cfg.Player == nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
return Outcome{}, fmt.Errorf("chain: no player configured; bytes cannot be played")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
2026-10-24 10:08:44 +02:00
|
|
|
playStart := time.Now()
|
2026-10-18 16:41:23 +02:00
|
|
|
if err := c.cfg.Player.Play(ctx, r.data); err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
return Outcome{}, fmt.Errorf("chain: playback via %s failed: %w", c.cfg.Player.Name(), err)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
2026-10-24 10:08:44 +02:00
|
|
|
return Outcome{Provider: r.used.Name(), Bytes: len(r.data), Synthesis: synth, Playback: time.Since(playStart)}, nil
|
2026-10-18 16:41:23 +02:00
|
|
|
case r.direct != nil:
|
2026-10-18 16:34:43 +02:00
|
|
|
if c.cfg.Player != nil && c.cfg.Player.Name() == NullPlayerName {
|
2026-10-24 11:15:35 +02:00
|
|
|
chainLogf(c.cfg, "chain: direct speech discarded (audio.sink=null)\n")
|
2026-10-24 10:08:44 +02:00
|
|
|
return Outcome{Provider: r.directName, Direct: true, Discarded: true, Synthesis: synth}, nil
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
2026-10-24 10:08:44 +02:00
|
|
|
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
|
2026-10-18 16:34:43 +02:00
|
|
|
default:
|
2026-10-24 10:08:44 +02:00
|
|
|
return Outcome{}, r.noTierErr()
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 16:41:23 +02:00
|
|
|
func (r walkResult) noTierErr() error {
|
2026-10-24 11:15:35 +02:00
|
|
|
return fmt.Errorf("chain: no tier could speak (not available: %s; failed: %s)",
|
|
|
|
|
joinOr(r.skipped, "none"), joinOr(r.failed, "none"))
|
2026-10-18 16:41:23 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// chainLogf writes a log line if a log is set.
|
2026-10-18 16:34:43 +02:00
|
|
|
func chainLogf(cfg ChainConfig, format string, args ...any) {
|
|
|
|
|
if cfg.Log != nil {
|
|
|
|
|
fmt.Fprintf(cfg.Log, format, args...)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// joinOr joins items with "; "; if the list is empty, fallback is returned.
|
2026-10-18 16:34:43 +02:00
|
|
|
func joinOr(items []string, fallback string) string {
|
|
|
|
|
if len(items) == 0 {
|
|
|
|
|
return fallback
|
|
|
|
|
}
|
|
|
|
|
return strings.Join(items, "; ")
|
|
|
|
|
}
|