Files
talk/internal/provider/chain.go
T

304 lines
11 KiB
Go
Raw Permalink Normal View History

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, "; ")
}