Files
talk/internal/provider/provider.go
T
m3ta-chironandClaude Fable 5.1 85fa64cfe6 refactor: switch the whole project to English
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>
2026-10-24 11:15:35 +02:00

107 lines
4.1 KiB
Go

package provider
import (
"context"
"errors"
"fmt"
"sort"
"strings"
)
// 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.
const DefaultName = "osbuiltin"
// 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).
var ErrNoByteOutput = errors.New(
"this platform cannot provide audio bytes (Linux without espeak; spd-say only speaks directly)")
// Provider is the contract of all talk TTS backends.
//
// 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).
type Provider interface {
// Name returns the unique provider name (CLI --provider, config).
Name() string
// 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.
Available(ctx context.Context) bool
// Synthesize produces audio bytes for text in language lang.
Synthesize(ctx context.Context, text, lang string) ([]byte, error)
}
// 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).
//
// 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.
type DirectSpeaker interface {
// SpeakDirect speaks text in language lang directly through the OS TTS
// and blocks until playback has finished (or ctx is cancelled).
SpeakDirect(ctx context.Context, text, lang string) error
}
// 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).
type VoicePinner interface {
PinVoice(id string)
}
// 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.
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{} },
}
// Names returns the registered provider names (sorted).
func Names() []string {
names := make([]string, 0, len(registry))
for name := range registry {
names = append(names, name)
}
sort.Strings(names)
return names
}
// 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.
func New(name string, cfg ChainConfig) (Provider, error) {
if name == "" {
name = DefaultName
}
ctor, ok := registry[name]
if !ok {
return nil, fmt.Errorf("unknown provider %q (available: %s)",
name, strings.Join(Names(), ", "))
}
if cfg.Getenv == nil {
cfg.Getenv = func(string) string { return "" }
}
return ctor(cfg), nil
}