2026-10-18 15:39:01 +02:00
|
|
|
package provider
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"sort"
|
|
|
|
|
"strings"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// DefaultName is the provider New("") returns (single construction, e.g.
|
|
|
|
|
// for doctor). The default announcement chain lives in DefaultOrder
|
|
|
|
|
// (chain.go): elevenlabs → openai → edge → osbuiltin.
|
2026-10-18 15:39:01 +02:00
|
|
|
const DefaultName = "osbuiltin"
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// ErrNoByteOutput reports that the platform cannot provide audio bytes via
|
|
|
|
|
// Synthesize; concretely Linux with speech-dispatcher (spd-say) but without
|
|
|
|
|
// espeak: spd-say can only play audio itself through the daemon, it cannot
|
|
|
|
|
// write it to a process. Callers should fall back to DirectSpeaker (see
|
|
|
|
|
// there).
|
2026-10-18 15:39:01 +02:00
|
|
|
var ErrNoByteOutput = errors.New(
|
2026-10-24 11:15:35 +02:00
|
|
|
"this platform cannot provide audio bytes (Linux without espeak; spd-say only speaks directly)")
|
2026-10-18 15:39:01 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Provider is the contract of all talk TTS backends.
|
2026-10-18 15:39:01 +02:00
|
|
|
//
|
2026-10-24 11:15:35 +02:00
|
|
|
// Synthesize returns decodable audio bytes; the format is documented by the
|
|
|
|
|
// implementation: osbuiltin returns RIFF/WAVE (PCM), the cloud providers
|
|
|
|
|
// elevenlabs, openai and edge return MP3. Both are understood by the audio
|
|
|
|
|
// package (oto sink).
|
2026-10-18 15:39:01 +02:00
|
|
|
type Provider interface {
|
2026-10-24 11:15:35 +02:00
|
|
|
// Name returns the unique provider name (CLI --provider, config).
|
2026-10-18 15:39:01 +02:00
|
|
|
Name() string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Available reports whether the provider is usable on this system at all
|
|
|
|
|
// (e.g. binary or key present). Must be cheap and side-effect free;
|
|
|
|
|
// false means: the chain should skip this tier.
|
2026-10-18 15:39:01 +02:00
|
|
|
Available(ctx context.Context) bool
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Synthesize produces audio bytes for text in language lang.
|
2026-10-18 15:39:01 +02:00
|
|
|
Synthesize(ctx context.Context, text, lang string) ([]byte, error)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// DirectSpeaker is a documented optional extension beyond the Provider
|
|
|
|
|
// interface (not part of the base contract): providers that can make text
|
|
|
|
|
// audible themselves without going through Synthesize bytes. Required on
|
|
|
|
|
// Linux systems whose OS TTS (speech-dispatcher) cannot hand audio bytes to
|
|
|
|
|
// processes; there osbuiltin.SpeakDirect plays via spd-say itself (blocking
|
|
|
|
|
// until the announcement has finished).
|
2026-10-18 15:39:01 +02:00
|
|
|
//
|
2026-10-24 11:15:35 +02:00
|
|
|
// In the chain (chain.go) SpeakDirect is the last tier when no provider can
|
|
|
|
|
// deliver bytes; Chain implements DirectSpeaker itself and forwards to the
|
|
|
|
|
// tier provider.
|
2026-10-18 15:39:01 +02:00
|
|
|
type DirectSpeaker interface {
|
2026-10-24 11:15:35 +02:00
|
|
|
// SpeakDirect speaks text in language lang directly through the OS TTS
|
|
|
|
|
// and blocks until playback has finished (or ctx is cancelled).
|
2026-10-18 15:39:01 +02:00
|
|
|
SpeakDirect(ctx context.Context, text, lang string) error
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// VoicePinner is a documented optional extension beyond the Provider
|
|
|
|
|
// interface: providers that can pin an explicit voice ID (the CLI flag
|
|
|
|
|
// --voice). The pinned voice beats the voice.<provider>.<lang> mapping of
|
|
|
|
|
// config.toml and the built-in neutral defaults. The chain calls PinVoice
|
|
|
|
|
// at construction on all tiers that implement the extension (tiers without
|
|
|
|
|
// a notion of voice, e.g. osbuiltin, log a note instead of silently
|
|
|
|
|
// ignoring it).
|
2026-10-18 16:34:43 +02:00
|
|
|
type VoicePinner interface {
|
|
|
|
|
PinVoice(id string)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// registry maps provider names to constructors; NewChain and New build
|
|
|
|
|
// through it. The constructors receive the ChainConfig (endpoints, voices,
|
|
|
|
|
// keys via Getenv) so that the chain and the daemon share one source.
|
2026-10-18 16:34:43 +02:00
|
|
|
var registry = map[string]func(ChainConfig) Provider{
|
|
|
|
|
"elevenlabs": func(cfg ChainConfig) Provider { return NewElevenLabs(cfg) },
|
|
|
|
|
"openai": func(cfg ChainConfig) Provider { return NewOpenAI(cfg) },
|
|
|
|
|
"edge": func(cfg ChainConfig) Provider { return NewEdge(cfg) },
|
|
|
|
|
"osbuiltin": func(ChainConfig) Provider { return OSBuiltin{} },
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Names returns the registered provider names (sorted).
|
2026-10-18 15:39:01 +02:00
|
|
|
func Names() []string {
|
|
|
|
|
names := make([]string, 0, len(registry))
|
|
|
|
|
for name := range registry {
|
|
|
|
|
names = append(names, name)
|
|
|
|
|
}
|
|
|
|
|
sort.Strings(names)
|
|
|
|
|
return names
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// New returns the provider with the given name (single construction); ""
|
|
|
|
|
// selects DefaultName. For the announcement chain (fallback) use NewChain.
|
|
|
|
|
// Unknown names are a clear error.
|
2026-10-18 16:34:43 +02:00
|
|
|
func New(name string, cfg ChainConfig) (Provider, error) {
|
2026-10-18 15:39:01 +02:00
|
|
|
if name == "" {
|
|
|
|
|
name = DefaultName
|
|
|
|
|
}
|
|
|
|
|
ctor, ok := registry[name]
|
|
|
|
|
if !ok {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("unknown provider %q (available: %s)",
|
2026-10-18 15:39:01 +02:00
|
|
|
name, strings.Join(Names(), ", "))
|
|
|
|
|
}
|
2026-10-18 16:34:43 +02:00
|
|
|
if cfg.Getenv == nil {
|
|
|
|
|
cfg.Getenv = func(string) string { return "" }
|
|
|
|
|
}
|
|
|
|
|
return ctor(cfg), nil
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|