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.. mapping from config.toml. Voices map[string]map[string]string // Endpoints are the file endpoints [providers.].endpoint; // TALK__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__ENDPOINT (env) > [providers.].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, "; ") }