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>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 11:15:35 +02:00
1 parent 4d30bd95ad
commit 85fa64cfe6
95 files changed
+4707 -4854

No files matched your search

+14 -14
View File
@@ -2,10 +2,10 @@ package provider
import "sort"
// Voice ist ein Eintrag des Stimmen-Katalogs: eine bekannte Stimme eines
// Providers für eine Sprache, mit lesbarem Namen. Der Katalog ist kuratiert
// (die gängigen Standardstimmen), nicht vollständig — jede Voice-ID des
// Providers funktioniert auch ohne Katalog-Eintrag (--voice, config).
// Voice is an entry of the voice catalog: a known voice of a provider for a
// language, with a readable name. The catalog is curated (the common
// standard voices), not complete; any voice ID of the provider also works
// without a catalog entry (--voice, config).
type Voice struct {
Provider string
Lang string // de | en
@@ -13,10 +13,10 @@ type Voice struct {
Name string
}
// catalog sind die bekannten Stimmen je Provider. ElevenLabs: öffentliche
// Premade-Stimmen (multilingual, daher für de UND en gelistet). OpenAI: die
// Stimmen der TTS-API (sprachunabhängig). Edge: Neural-Stimmen der
// jeweiligen Locale. osbuiltin kennt keine IDs (das OS wählt).
// catalog lists the known voices per provider. ElevenLabs: public premade
// voices (multilingual, hence listed for both de AND en). OpenAI: the voices
// of the TTS API (language independent). Edge: neural voices of the
// respective locale. osbuiltin has no IDs (the OS chooses).
var catalog = []Voice{
{"elevenlabs", "de", "21m00Tcm4TlvDq8ikWAM", "Rachel"},
{"elevenlabs", "de", "pNInz6obpgDQGcFmaJgB", "Adam"},
@@ -54,8 +54,8 @@ var catalog = []Voice{
{"edge", "en", "en-GB-RyanNeural", "Ryan (GB)"},
}
// Catalog liefert den Stimmen-Katalog in stabiler Reihenfolge
// (Ketten-Reihenfolge der Provider, dann de vor en, dann Katalog-Reihe).
// Catalog returns the voice catalog in stable order (chain order of the
// providers, then de before en, then catalog order).
func Catalog() []Voice {
out := make([]Voice, len(catalog))
copy(out, catalog)
@@ -72,7 +72,7 @@ func Catalog() []Voice {
return out
}
// VoiceName liefert den Katalog-Namen einer Voice-ID; leer, wenn unbekannt.
// VoiceName returns the catalog name of a voice ID; empty if unknown.
func VoiceName(provider, id string) string {
for _, v := range catalog {
if v.Provider == provider && v.ID == id {
@@ -82,9 +82,9 @@ func VoiceName(provider, id string) string {
return ""
}
// DefaultVoice liefert die effektive Default-Stimme eines Providers für
// eine Sprache: config-Mapping voice.<provider>.<lang> vor dem Repo-Default
// (ohne --voice-Pin). Leer bei osbuiltin (das OS wählt).
// DefaultVoice returns the effective default voice of a provider for a
// language: config mapping voice.<provider>.<lang> before the repo default
// (without a --voice pin). Empty for osbuiltin (the OS chooses).
func DefaultVoice(provider, lang string, cfgVoices map[string]map[string]string) string {
return resolveVoice(provider, lang, "", cfgVoices[provider])
}
+105 -108
View File
@@ -9,69 +9,68 @@ import (
"time"
)
// DefaultOrder ist die bindende Default-Fallback-Reihenfolge der Kette
// (Spec: ElevenLabs → OpenAI TTS → Edge-TTS → OS built-in).
// 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 ist der dokumentierte Name des verwerfenden Sinks
// (Spec: audio.sink = "oto" | "null"). Die Kette braucht ihn für die
// Entscheidung, ob eine Direktansage (DirectSpeaker) verworfen werden
// muss — der null-Sink darf niemals Ton erzeugen.
// 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 spielt Audio-Bytes ab, die die Kette geliefert hat. Der Vertrag
// ist absichtlich strukturell identisch zu audio.Sink (Play + Name): CLI
// und Daemon (Task 04) übergeben das Ergebnis von audio.NewSink(name, log)
// unverändert — dadurch bleibt die Kette ohne Import des audio-Pakets
// testbar und wiederverwendbar.
// 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 ist alles, was die Kette zum Bau braucht. CLI (Task 03) und
// Daemon (Task 04) füllen sie aus Config + Flags; die Kette selbst liest
// keine Dateien und keine Prozess-Env — Getenv ist die injizierbare Seam
// (Tests und Daemon kommen so ohne echte Keys aus).
// 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 reduziert die Kette auf genau einen Provider (CLI-Flag --provider
// schlägt config `provider = "…"`). Leer = DefaultOrder.
// Pin reduces the chain to exactly one provider (CLI flag --provider
// beats config `provider = "…"`). Empty = DefaultOrder.
Pin string
// Voice ist eine explizite Voice-ID (CLI-Flag --voice); sie wird über
// VoicePinner an alle Stufen gepinnt und schlägt Mapping + Defaults.
// Voice is an explicit voice ID (CLI flag --voice); it is pinned on all
// tiers via VoicePinner and beats mapping + defaults.
Voice string
// Voices ist das voice.<provider>.<lang>-Mapping aus der config.toml.
// Voices is the voice.<provider>.<lang> mapping from config.toml.
Voices map[string]map[string]string
// Endpoints sind die Datei-Endpunkte [providers.<name>].endpoint;
// TALK_<NAME>_ENDPOINT (via Getenv) schlägt sie, sie schlägt die
// eingebauten Defaults (siehe endpointFrom).
// 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 ist [providers.openai].model (leer = "tts-1").
// Model is [providers.openai].model (empty = "tts-1").
Model string
// Getenv liefert Umgebungsvariablen (Keys, Endpunkt-Overrides). Nil
// wird beim Bau durch eine Funktion ersetzt, die immer "" liefert.
// Getenv returns environment variables (keys, endpoint overrides). Nil
// is replaced at construction by a function that always returns "".
Getenv func(string) string
// Player gibt erfolgreiche Synthesen aus; nil = die Kette liefert nur
// Bytes (Synthesize) und Speak meldet einen klaren Fehler.
// Player plays successful syntheses; nil = the chain only returns bytes
// (Synthesize) and Speak reports a clear error.
Player Player
// Log empfängt die Fallback-Protokoll-Zeilen (CLI: stderr). Fallbacks
// sind nie still: Jede übersprungene oder fehlgeschlagene Stufe wird
// hier gemeldet — auch wenn am Ende noch eine Stufe Erfolg hat.
// 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 löst einen Provider-Endpunkt auf:
// TALK_<NAME>_ENDPOINT (Env) > [providers.<name>].endpoint (Datei) >
// eingebauter Default. Env-Overrides in der Provider-Schicht zu lesen hält
// das config-Paket frei von Provider-Namen und erlaubt Prozessgrenzen-
// Tests ohne Config-Datei.
// 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 != "" {
@@ -84,25 +83,25 @@ func endpointFrom(cfg ChainConfig, name, envName, def string) string {
return def
}
// Chain ist die geordnete Fallback-Kette über Provider. Sie implementiert
// selbst Provider (Synthesize liefert die Bytes der ersten erfolgreichen
// Stufe) UND DirectSpeaker (letzter Stufen-Ausweg für Plattformen ohne
// Byte-fähiges TTS) — damit ist sie für CLI und Daemon (Task 04)
// austauschbar gegen jeden einzelnen Provider.
// 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 ist die komplette Ansage-Orchestrierung: Bytes-Synthese mit
// Fallback, Wiedergabe über den Player, und als letzte Stufe die
// Direktansage (osbuiltin über spd-say). Bei einem null-Player wird die
// Direktansage tonlos verworfen (CI/Server) statt Ton zu erzeugen.
// 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 baut die Kette: gepinnt (Pin) oder in DefaultOrder. Unbekannte
// Namen sind ein klarer Fehler, der die verfügbaren Namen nennt. Das
// --voice-Pin wird auf alle Stufen angewandt, die VoicePinner implementieren
// (Stufen ohne Voice-Begriff bekommen einen protokollierten Hinweis).
// 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 "" }
@@ -121,7 +120,7 @@ func NewChain(cfg ChainConfig) (*Chain, error) {
if vp, ok := p.(VoicePinner); ok {
vp.PinVoice(cfg.Voice)
} else {
chainLogf(cfg, "kette: --voice für Stufe %s ignoriert (kennt keine Voice-IDs)\n", name)
chainLogf(cfg, "chain: --voice ignored for tier %s (it has no voice IDs)\n", name)
}
}
tiers = append(tiers, p)
@@ -129,13 +128,13 @@ func NewChain(cfg ChainConfig) (*Chain, error) {
return &Chain{cfg: cfg, tiers: tiers}, nil
}
// Name implementiert Provider.
// Name implements Provider.
func (*Chain) Name() string { return "chain" }
// Tiers liefert die Stufen in Ketten-Reihenfolge (Diagnose/doctor).
// Tiers returns the tiers in chain order (diagnosis/doctor).
func (c *Chain) Tiers() []Provider { return c.tiers }
// Available implementiert Provider: true, sobald eine Stunde verfügbar ist.
// 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) {
@@ -145,55 +144,54 @@ func (c *Chain) Available(ctx context.Context) bool {
return false
}
// walkResult ist das interne Ergebnis des Ketten-Durchlaufs.
// walkResult is the internal result of a chain walk.
type walkResult struct {
data []byte // Bytes der ersten erfolgreichen Stufe (nil = keine)
used Provider // die erfolgreiche Stufe
direct DirectSpeaker // erste Stufe, die nur direkt sprechen kann
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: fehler" je fehlgeschlagener Stufe
skipped []string // Namen nicht verfügbarer Stufen
failed []string // "name: error" per failed tier
skipped []string // names of unavailable tiers
}
// walk läuft die Kette in Ordnung ab: nicht verfügbare Stufen werden
// übersprungen (protokolliert), fehlgeschlagene protokolliert und
// überwunden. Eine Stufe mit ErrNoByteOutput, die DirectSpeaker
// implementiert, wird als letzter Ausweg gemerkt (der Durchlauf geht
// weiter — eine spätere Stufe könnte noch Bytes liefern).
// 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, "kette: Stufe %s übersprungen (nicht verfügbar)\n", 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, "kette: Ansage über %s\n", p.Name())
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, "kette: Stufe %s liefert keine Bytes, kann aber direkt sprechen (als letzte Stufe gemerkt)\n", 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, "kette: Stufe %s fehlgeschlagen: %v — Fallback auf nächste Stufe\n", p.Name(), err)
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, "kette: Stufe %s fehlgeschlagen: %v — Fallback auf nächste Stufe\n", p.Name(), err)
chainLogf(c.cfg, "chain: tier %s failed: %v; falling back to the next tier\n", p.Name(), err)
}
}
return r
}
// Synthesize implementiert Provider: Bytes der ersten erfolgreichen Stufe.
// Kann nur eine Direkt-Stufe sprechen, ist der Fehler ErrNoByteOutput (die
// Kette erfüllt damit den DirectSpeaker-Vertrag am Aufrufer). Scheitert
// alles, nennt der Fehler die letzte Stufen-Fehler — nie still.
// 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 {
@@ -201,50 +199,49 @@ func (c *Chain) Synthesize(ctx context.Context, text, lang string) ([]byte, erro
}
if r.direct != nil {
if len(r.failed) > 0 {
return nil, fmt.Errorf("%w (Stufe %s spricht nur direkt; daneben fehlgeschlagen: %s)",
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 (Stufe %s spricht nur direkt)", ErrNoByteOutput, r.directName)
return nil, fmt.Errorf("%w (tier %s speaks directly only)", ErrNoByteOutput, r.directName)
}
return nil, r.noTierErr()
}
// SpeakDirect implementiert DirectSpeaker: Direktansage über die Stufe,
// die nur direkt sprechen kann (typischerweise osbuiltin/spd-say auf Linux
// ohne espeak). Gibt es eine Byte-fähige Stufe, ist SpeakDirect ein klarer
// Fehler (der Aufrufer soll Synthesize + Player nutzen).
// 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("kette: Stufe %s liefert Bytes — über Synthesize und den Player abspielen, nicht direkt", r.used.Name())
return fmt.Errorf("chain: tier %s yields bytes; play them via Synthesize and the player, not directly", r.used.Name())
}
return fmt.Errorf("kette: keine Stufe kann direkt sprechen (nicht verfügbar: %s; fehlgeschlagen: %s)",
joinOr(r.skipped, "keine"), joinOr(r.failed, "keine"))
return fmt.Errorf("chain: no tier can speak directly (not available: %s; failed: %s)",
joinOr(r.skipped, "none"), joinOr(r.failed, "none"))
}
// Speak ist die komplette Ansage in EINEM Ketten-Durchlauf: Synthese mit
// Fallback → Wiedergabe über den Player; fällt die Kette auf die Direkt-
// Stufe zurück, entscheidet der Player-Name über Ton (alles außer „null")
// oder tonloses Verwerfen (null: CI/Server bleiben fehler- und tonfrei,
// der Verwurf wird protokolliert). Der Durchlauf findet bewusst genau
// einmal statt: fehlgeschlagene Cloud-Stufen werden nicht ein zweites Mal
// angefragt (keine doppelten POSTs auf bezahlten APIs, keine doppelten
// Protokoll-Zeilen), und Bytes aus diesem Durchlauf werden abgespielt,
// statt sie wegzuwerfen und neu zu laufen. Nur ein Fehler aller Stufen
// oder eine fehlgeschlagene Wiedergabe sind Fehler.
// 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 beschreibt eine gelungene Ansage: welche Stufe gesprochen hat,
// wie lange Synthese (Ketten-Durchlauf bis zu den Bytes) und Wiedergabe
// gedauert haben, und ob es eine Direktansage war (keine Bytes, Provider
// spielt selbst — dann ist Synthesis die Gesamtzeit und Playback 0).
// Discarded meldet die tonlose Verwerfung einer Direktansage am null-Sink.
// 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
@@ -254,7 +251,7 @@ type Outcome struct {
Playback time.Duration
}
// SpeakOutcome ist Speak mit Bericht (Diagnose: `talk test`, Daemon-Log).
// 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)
@@ -262,16 +259,16 @@ func (c *Chain) SpeakOutcome(ctx context.Context, text, lang string) (Outcome, e
switch {
case r.data != nil:
if c.cfg.Player == nil {
return Outcome{}, fmt.Errorf("kette: kein Player konfiguriert — Bytes können nicht abgespielt werden")
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("kette: Wiedergabe über %s fehlgeschlagen: %w", c.cfg.Player.Name(), err)
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, "kette: Direktansage verworfen (audio.sink=null)\n")
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 {
@@ -283,22 +280,22 @@ func (c *Chain) SpeakOutcome(ctx context.Context, text, lang string) (Outcome, e
}
}
// noTierErr bündelt den Ende-ohne-Ergebnis-Fehler eines Durchlaufs: keine
// Stufe lieferte Bytes oder Direktansage — übersprungene (nicht verfügbar)
// und fehlgeschlagene Stufen werden genannt, nie still.
// 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("kette: keine Stufe konnte sprechen (nicht verfügbar: %s; fehlgeschlagen: %s)",
joinOr(r.skipped, "keine"), joinOr(r.failed, "keine"))
return fmt.Errorf("chain: no tier could speak (not available: %s; failed: %s)",
joinOr(r.skipped, "none"), joinOr(r.failed, "none"))
}
// chainLogf schreibt eine Protokoll-Zeile, wenn ein Log gesetzt ist.
// 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 verbindet Einträge mit sep; ist die Liste leer, kommt fallback.
// 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
+104 -105
View File
@@ -13,8 +13,8 @@ import (
"testing"
)
// fakePlayer zeichnet Play-Aufrufe auf (Name steuert die null-Entscheidung
// der Kette).
// fakePlayer records Play calls (the name drives the chain's null
// decision).
type fakePlayer struct {
name string
played [][]byte
@@ -26,8 +26,8 @@ func (f *fakePlayer) Play(_ context.Context, data []byte) error {
}
func (f *fakePlayer) Name() string { return f.name }
// countingTTSServer antwortet mit status + body und zählt die Requests
// (atomic, der Server kann mehrere Stufen-Requests sehen).
// countingTTSServer responds with status + body and counts the requests
// (atomic; the server may see requests from several tiers).
func countingTTSServer(t *testing.T, status int, body []byte) (url string, hits *atomic.Int32) {
t.Helper()
var n atomic.Int32
@@ -40,13 +40,13 @@ func countingTTSServer(t *testing.T, status int, body []byte) (url string, hits
return ts.URL, &n
}
// chainEnv baut die Getenv-Seam für Tests mit gefakten Cloud-Endpunkten.
// chainEnv builds the Getenv seam for tests with faked cloud endpoints.
func chainEnv(kv map[string]string) func(string) string {
return func(k string) string { return kv[k] }
}
// fakeProcBin schreibt ein ausführbares Shell-Skript (gleicher Mechanismus
// wie in osbuiltin_test.go — echter Prozess, PATH-Manipulation).
// fakeProcBin writes an executable shell script (same mechanism as in
// osbuiltin_test.go: real process, PATH manipulation).
func fakeProcBin(t *testing.T, dir, name, body string) {
t.Helper()
script := "#!/bin/sh\n" + body + "\n"
@@ -55,9 +55,9 @@ func fakeProcBin(t *testing.T, dir, name, body string) {
}
}
// cloudEnv liefert die Env-Map für eine Kette, deren elevenlabs-Stufe auf
// elevenURL und deren openai-Stufe auf openaiURL zeigt (edge tot, damit
// kein Test den echten Microsoft-Endpunkt anfasst).
// cloudEnv returns the env map for a chain whose elevenlabs tier points at
// elevenURL and whose openai tier points at openaiURL (edge is dead so that
// no test touches the real Microsoft endpoint).
func cloudEnv(elevenURL, openaiURL string, extra map[string]string) map[string]string {
kv := map[string]string{
EnvElevenLabsKey: "k",
@@ -72,11 +72,10 @@ func cloudEnv(elevenURL, openaiURL string, extra map[string]string) map[string]s
return kv
}
// TestChainFallbackErster500ZweiterTreffer (Abnahmekriterium aus dem
// Task-Brief): Stufe 1 (elevenlabs) antwortet 500 → die Kette fällt auf
// Stufe 2 (openai) durch, deren Bytes kommen zurück; der Fallback ist im
// Log sichtbar, nie still.
func TestChainFallbackErster500ZweiterTreffer(t *testing.T) {
// TestChainFallbackFirst500SecondHit (acceptance criterion): tier 1
// (elevenlabs) answers 500 → the chain falls through to tier 2 (openai),
// whose bytes come back; the fallback is visible in the log, never silent.
func TestChainFallbackFirst500SecondHit(t *testing.T) {
eleven, elHits := countingTTSServer(t, 500, []byte("boom"))
openai, oaHits := countingTTSServer(t, 200, fakeMP3)
@@ -89,30 +88,30 @@ func TestChainFallbackErster500ZweiterTreffer(t *testing.T) {
t.Fatalf("NewChain: %v", err)
}
data, err := ch.Synthesize(context.Background(), "Hallo Welt", "de")
data, err := ch.Synthesize(context.Background(), "Hello world", "de")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if string(data) != string(fakeMP3) {
t.Errorf("Synthesize = %q, want Bytes der openai-Stufe", data)
t.Errorf("Synthesize = %q, want bytes of the openai tier", data)
}
if elHits.Load() != 1 || oaHits.Load() != 1 {
t.Errorf("Hits: elevenlabs = %d, openai = %d — want je 1 (500 → Fallback)", elHits.Load(), oaHits.Load())
t.Errorf("hits: elevenlabs = %d, openai = %d; want 1 each (500 → fallback)", elHits.Load(), oaHits.Load())
}
for _, want := range []string{"elevenlabs", "500", "Fallback", "Ansage über openai"} {
for _, want := range []string{"elevenlabs", "500", "falling back", "spoken via openai"} {
if !strings.Contains(log.String(), want) {
t.Errorf("Log = %q, will %q", log.String(), want)
t.Errorf("log = %q, want %q", log.String(), want)
}
}
}
// TestChainAllesFehlt: beide Cloud-Stufen 500, edge tot, osbuiltin nicht
// verfügbar → Fehler nennt die fehlgeschlagenen Stufen; Log zeigt jeden.
func TestChainAllesFehlt(t *testing.T) {
// TestChainEverythingFails: both cloud tiers 500, edge dead, osbuiltin not
// available → the error names the failed tiers; the log shows each one.
func TestChainEverythingFails(t *testing.T) {
eleven, _ := countingTTSServer(t, 500, []byte("el-boom"))
openai, _ := countingTTSServer(t, 500, []byte("oa-boom"))
// PATH leer: osbuiltin nicht verfügbar.
// Empty PATH: osbuiltin not available.
t.Setenv("PATH", t.TempDir())
var log bytes.Buffer
@@ -124,21 +123,21 @@ func TestChainAllesFehlt(t *testing.T) {
t.Fatalf("NewChain: %v", err)
}
_, err = ch.Synthesize(context.Background(), "Hallo", "de")
_, err = ch.Synthesize(context.Background(), "Hello", "de")
if err == nil {
t.Fatal("Synthesize mit allen Stufen kaputt: kein Fehler")
t.Fatal("Synthesize with all tiers broken: no error")
}
for _, want := range []string{"elevenlabs", "openai", "osbuiltin", "500"} {
if !strings.Contains(err.Error(), want) && !strings.Contains(log.String(), want) {
t.Errorf("Weder Fehler (%v) noch Log nennen %q", err, want)
t.Errorf("neither error (%v) nor log mention %q", err, want)
}
}
}
// TestChainOhneKeysEdgeOsbuiltin (Abnahmekriterium): ohne jeden Key und
// mit totem edge wählt die Kette osbuiltin — mit gefaktem espeak kommen
// dessen Bytes zurück.
func TestChainOhneKeysEdgeOsbuiltin(t *testing.T) {
// TestChainWithoutKeysEdgeOsbuiltin (acceptance criterion): without any
// key and with a dead edge, the chain picks osbuiltin; with a faked espeak
// its bytes come back.
func TestChainWithoutKeysEdgeOsbuiltin(t *testing.T) {
dir := t.TempDir()
fakeProcBin(t, dir, "espeak", `printf 'RIFFxxxxWAVEfmt '`)
t.Setenv("PATH", dir)
@@ -151,20 +150,20 @@ func TestChainOhneKeysEdgeOsbuiltin(t *testing.T) {
if err != nil {
t.Fatalf("NewChain: %v", err)
}
data, err := ch.Synthesize(context.Background(), "Hallo", "de")
data, err := ch.Synthesize(context.Background(), "Hello", "de")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if !bytes.HasPrefix(data, []byte("RIFF")) {
t.Errorf("Synthesize = %q, want WAV-Bytes des fake espeak", data)
t.Errorf("Synthesize = %q, want WAV bytes of the fake espeak", data)
}
if !strings.Contains(log.String(), "übersprungen (nicht verfügbar)") {
t.Errorf("Log = %q, will Skip-Hinweise für die Key-Stufen", log.String())
if !strings.Contains(log.String(), "skipped (not available)") {
t.Errorf("log = %q, want skip notes for the key tiers", log.String())
}
}
// TestChainPin: --provider/config pinnen die Kette auf genau eine Stufe —
// andere Stufen werden nicht einmal angefragt.
// TestChainPin: --provider/config pin the chain to exactly one tier; other
// tiers are not even requested.
func TestChainPin(t *testing.T) {
eleven, elHits := countingTTSServer(t, 200, fakeMP3)
openai, oaHits := countingTTSServer(t, 500, []byte("boom"))
@@ -176,24 +175,24 @@ func TestChainPin(t *testing.T) {
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if _, err := ch.Synthesize(context.Background(), "Hallo", "de"); err == nil {
t.Fatal("Gepinnte kaputte Stufe soll fehlschlagen (kein Fallback bei Pin)")
if _, err := ch.Synthesize(context.Background(), "Hello", "de"); err == nil {
t.Fatal("pinned broken tier should fail (no fallback when pinned)")
}
if elHits.Load() != 0 || oaHits.Load() != 1 {
t.Errorf("Hits: elevenlabs = %d (want 0), openai = %d (want 1)", elHits.Load(), oaHits.Load())
t.Errorf("hits: elevenlabs = %d (want 0), openai = %d (want 1)", elHits.Load(), oaHits.Load())
}
}
// TestChainUnbekannterPin ist ein klarer Fehler mit Namensliste.
func TestChainUnbekannterPin(t *testing.T) {
if _, err := NewChain(ChainConfig{Pin: "gibtsnicht"}); err == nil || !strings.Contains(err.Error(), "elevenlabs") {
t.Errorf("NewChain-Fehler = %v, will verfügbare Namen nennen", err)
// TestChainUnknownPin is a clear error listing the names.
func TestChainUnknownPin(t *testing.T) {
if _, err := NewChain(ChainConfig{Pin: "doesnotexist"}); err == nil || !strings.Contains(err.Error(), "elevenlabs") {
t.Errorf("NewChain error = %v, want the available names", err)
}
}
// TestChainSpeakBytesUeberPlayer: erfolgreiche Synthese wird über den
// Player abgespielt.
func TestChainSpeakBytesUeberPlayer(t *testing.T) {
// TestChainSpeakBytesViaPlayer: a successful synthesis is played via the
// Player.
func TestChainSpeakBytesViaPlayer(t *testing.T) {
eleven, _ := countingTTSServer(t, 200, fakeMP3)
player := &fakePlayer{name: "oto"}
ch, err := NewChain(ChainConfig{
@@ -203,22 +202,22 @@ func TestChainSpeakBytesUeberPlayer(t *testing.T) {
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hallo", "de"); err != nil {
if err := ch.Speak(context.Background(), "Hello", "de"); err != nil {
t.Fatalf("Speak: %v", err)
}
if len(player.played) != 1 || string(player.played[0]) != string(fakeMP3) {
t.Errorf("Player.Play = %x, want die Synthese-Bytes", player.played)
t.Errorf("Player.Play = %x, want the synthesis bytes", player.played)
}
}
// TestChainDirektMitNullPlayer: fällt die Kette auf die Direkt-Stufe
// (osbuiltin ohne espeak), verwirft ein null-Player die Ansage tonlos und
// SUCCESS — spd-say wird nicht gerufen.
func TestChainDirektMitNullPlayer(t *testing.T) {
// TestChainDirectWithNullPlayer: when the chain falls back to the direct
// tier (osbuiltin without espeak), a null player discards the announcement
// silently and SUCCEEDS; spd-say is not called.
func TestChainDirectWithNullPlayer(t *testing.T) {
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
t.Setenv("PATH", dir) // kein espeak → keine Bytes, aber spd-say da
t.Setenv("PATH", dir) // no espeak → no bytes, but spd-say present
var log bytes.Buffer
ch, err := NewChain(ChainConfig{
@@ -230,20 +229,21 @@ func TestChainDirektMitNullPlayer(t *testing.T) {
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hallo Welt", "de"); err != nil {
t.Fatalf("Speak (null-Player): %v", err)
if err := ch.Speak(context.Background(), "Hello world", "de"); err != nil {
t.Fatalf("Speak (null player): %v", err)
}
if _, err := os.Stat(seen); err == nil {
t.Error("spd-say wurde gerufen — null-Player hätte verwerfen müssen")
t.Error("spd-say was called; the null player should have discarded")
}
if !strings.Contains(log.String(), "verworfen") {
t.Errorf("Log = %q, will Verwurf-Zeile", log.String())
if !strings.Contains(log.String(), "discarded") {
t.Errorf("log = %q, want discard line", log.String())
}
}
// TestChainDirektMitTonPlayer: mit echtem Player (Name ≠ null) läuft die
// Direktansage über SpeakDirect — blockierend (-w) mit Sprache und Text.
func TestChainDirektMitTonPlayer(t *testing.T) {
// TestChainDirectWithSoundPlayer: with a real player (name != null) the
// direct announcement runs via SpeakDirect, blocking (-w) with language and
// text.
func TestChainDirectWithSoundPlayer(t *testing.T) {
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
@@ -258,24 +258,24 @@ func TestChainDirektMitTonPlayer(t *testing.T) {
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hallo Welt", "de"); err != nil {
t.Fatalf("Speak (Direkt): %v", err)
if err := ch.Speak(context.Background(), "Hello world", "de"); err != nil {
t.Fatalf("Speak (direct): %v", err)
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("fake spd-say nicht gerufen: %v", err)
t.Fatalf("fake spd-say not called: %v", err)
}
if got := strings.TrimSpace(string(args)); got != "-w -l de Hallo Welt" {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt")
if got := strings.TrimSpace(string(args)); got != "-w -l de Hello world" {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hello world")
}
if len(player.played) != 0 {
t.Errorf("Player.Play wurde gerufen (%x) — Direktansage geht am Player vorbei", player.played)
t.Errorf("Player.Play was called (%x); a direct announcement bypasses the player", player.played)
}
}
// TestChainSynthesizeErrNoByteOutput: Die Kette als Provider meldet
// ErrNoByteOutput, wenn nur eine Direkt-Stufe übrig bleibt — der
// DirectSpeaker-Vertrag bleibt am Aufrufer erhalten.
// TestChainSynthesizeErrNoByteOutput: the chain as a Provider reports
// ErrNoByteOutput when only a direct tier remains; the DirectSpeaker
// contract is preserved towards the caller.
func TestChainSynthesizeErrNoByteOutput(t *testing.T) {
dir := t.TempDir()
fakeProcBin(t, dir, "spd-say", "true")
@@ -285,27 +285,27 @@ func TestChainSynthesizeErrNoByteOutput(t *testing.T) {
if err != nil {
t.Fatalf("NewChain: %v", err)
}
_, err = ch.Synthesize(context.Background(), "Hallo", "de")
_, err = ch.Synthesize(context.Background(), "Hello", "de")
if !errors.Is(err, ErrNoByteOutput) {
t.Fatalf("Synthesize-Fehler = %v, want ErrNoByteOutput", err)
t.Fatalf("Synthesize error = %v, want ErrNoByteOutput", err)
}
// Chain selbst ist DirectSpeaker.
// Chain itself is a DirectSpeaker.
if _, ok := any(ch).(DirectSpeaker); !ok {
t.Error("Chain implementiert DirectSpeaker nicht")
t.Error("Chain does not implement DirectSpeaker")
}
}
// TestChainAvailableUndNamen: Available true sobald eine Stufe verfügbar;
// Default-Reihenfolge ist die bindende aus der Spec.
func TestChainAvailableUndNamen(t *testing.T) {
// TestChainAvailableAndNames: Available is true as soon as one tier is
// available; the default order is the binding one from the spec.
func TestChainAvailableAndNames(t *testing.T) {
ch, err := NewChain(ChainConfig{Getenv: chainEnv(map[string]string{
EnvEdgeEndpoint: "wss://edge.example.com", // edge: immer verfügbar mit Endpunkt
EnvEdgeEndpoint: "wss://edge.example.com", // edge: always available with an endpoint
})})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if !ch.Available(context.Background()) {
t.Error("Available = false trotz verfügbarer edge-Stufe")
t.Error("Available = false despite an available edge tier")
}
want := []string{"elevenlabs", "openai", "edge", "osbuiltin"}
if len(ch.Tiers()) != len(want) {
@@ -318,9 +318,9 @@ func TestChainAvailableUndNamen(t *testing.T) {
}
}
// TestChainVoicePinDurchgereicht: ChainConfig.Voice wird via VoicePinner an
// die Stufen gepinnt — beobachtbar am Request-Pfad des ElevenLabs-Fakes.
func TestChainVoicePinDurchgereicht(t *testing.T) {
// TestChainVoicePinForwarded: ChainConfig.Voice is pinned on the tiers via
// VoicePinner; observable in the request path of the ElevenLabs fake.
func TestChainVoicePinForwarded(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
ch, err := NewChain(ChainConfig{
Pin: "elevenlabs",
@@ -333,24 +333,23 @@ func TestChainVoicePinDurchgereicht(t *testing.T) {
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if _, err := ch.Synthesize(context.Background(), "Hallo", "de"); err != nil {
if _, err := ch.Synthesize(context.Background(), "Hello", "de"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/ChainVoice"; rec.path != want {
t.Errorf("Pfad = %q, want %q", rec.path, want)
t.Errorf("path = %q, want %q", rec.path, want)
}
}
// TestChainDirektEndgameEinDurchlauf (Review-Fix): Im DirectSpeaker-
// Endgame läuft die Kette genau EINMAL — die fehlgeschlagene Cloud-Stufe
// wird nur einmal angefragt (keine doppelten POSTs auf bezahlten APIs,
// keine verdoppelte Latenz), jede Stufe protokolliert ihren Fehlschlag
// genau einmal, und die Direktansage kommt zustande.
func TestChainDirektEndgameEinDurchlauf(t *testing.T) {
// TestChainDirectEndgameSingleWalk (review fix): in the DirectSpeaker
// endgame the chain walks exactly ONCE; the failed cloud tier is requested
// only once (no duplicate POSTs to paid APIs, no doubled latency), every
// tier logs its failure exactly once, and the direct announcement happens.
func TestChainDirectEndgameSingleWalk(t *testing.T) {
eleven, elHits := countingTTSServer(t, 500, []byte("boom"))
// elevenlabs: Key + Fake (500). openai: Endpunkt, aber KEIN Key →
// übersprungen. edge: toter Loopback-Port. osbuiltin: PATH nur mit
// fake spd-say (kein espeak) → keine Bytes, Direktansage.
// elevenlabs: key + fake (500). openai: endpoint but NO key → skipped.
// edge: dead loopback port. osbuiltin: PATH with only a fake spd-say (no
// espeak) → no bytes, direct announcement.
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
@@ -361,7 +360,7 @@ func TestChainDirektEndgameEinDurchlauf(t *testing.T) {
Getenv: chainEnv(map[string]string{
EnvElevenLabsKey: "k",
EnvElevenLabsEndpoint: eleven,
EnvOpenAIEndpoint: eleven, // ohne Key ohnehin übersprungen
EnvOpenAIEndpoint: eleven, // skipped anyway without a key
EnvEdgeEndpoint: "ws://127.0.0.1:1/",
}),
Player: &fakePlayer{name: "oto"},
@@ -371,25 +370,25 @@ func TestChainDirektEndgameEinDurchlauf(t *testing.T) {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hallo Welt", "de"); err != nil {
t.Fatalf("Speak (Direkt-Endgame): %v", err)
if err := ch.Speak(context.Background(), "Hello world", "de"); err != nil {
t.Fatalf("Speak (direct endgame): %v", err)
}
if got := elHits.Load(); got != 1 {
t.Errorf("elevenlabs wurde %d× angefragt, want genau 1 (kein zweiter Durchlauf)", got)
t.Errorf("elevenlabs was requested %d times, want exactly 1 (no second walk)", got)
}
for _, tier := range []string{"elevenlabs", "edge"} {
if got := strings.Count(log.String(), "Stufe "+tier+" fehlgeschlagen"); got != 1 {
t.Errorf("„Stufe %s fehlgeschlagen“ steht %d× im Log, want genau 1:\n%s", tier, got, log.String())
if got := strings.Count(log.String(), "tier "+tier+" failed"); got != 1 {
t.Errorf("\"tier %s failed\" appears %d times in the log, want exactly 1:\n%s", tier, got, log.String())
}
}
if got := strings.Count(log.String(), "Stufe openai übersprungen"); got != 1 {
t.Errorf("openai-Skip steht %d× im Log, want genau 1", got)
if got := strings.Count(log.String(), "tier openai skipped"); got != 1 {
t.Errorf("openai skip appears %d times in the log, want exactly 1", got)
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("fake spd-say nicht gerufen: %v", err)
t.Fatalf("fake spd-say not called: %v", err)
}
if got := strings.TrimSpace(string(args)); got != "-w -l de Hallo Welt" {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt")
if got := strings.TrimSpace(string(args)); got != "-w -l de Hello world" {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hello world")
}
}
+10 -11
View File
@@ -1,12 +1,11 @@
// Package provider implementiert die TTS-Provider von talk und ihre
// Fallback-Kette. Der Vertrag (Name/Available/Synthesize) trägt die
// Vier-Stufen-Kette aus Task 03: ElevenLabs → OpenAI TTS → Edge-TTS →
// OS built-in (überschreibbar per --provider/config provider). Die
// Cloud-Provider liefern MP3 (Keys ausschließlich über TALK_ELEVENLABS_KEY
// bzw. TALK_OPENAI_KEY, Endpunkte konfigurierbar und damit fake-bar);
// osbuiltin liefert RIFF/WAVE-PCM. Die dokumentierte optionale Erweiterung
// DirectSpeaker deckt Plattformen ab, deren OS-TTS keine Audio-Bytes
// liefern kann (Linux speech-dispatcher) — Chain.Speak behandelt diesen
// Fall inklusive tonlosem Verwerfen am null-Sink, damit CLI (Task 03) und
// Daemon (Task 04) dieselbe Kette unverändert nutzen.
// Package provider implements talk's TTS providers and their fallback
// chain. The contract (Name/Available/Synthesize) carries the four-tier
// chain: ElevenLabs → OpenAI TTS → Edge TTS → OS built-in (overridable via
// --provider/config provider). The cloud providers return MP3 (keys come
// exclusively from TALK_ELEVENLABS_KEY and TALK_OPENAI_KEY, endpoints are
// configurable and therefore fakeable); osbuiltin returns RIFF/WAVE PCM.
// The documented optional extension DirectSpeaker covers platforms whose
// OS TTS cannot provide audio bytes (Linux speech-dispatcher); Chain.Speak
// handles that case, including silent discarding on the null sink, so that
// the CLI and the daemon use the same chain unchanged.
package provider
+77 -78
View File
@@ -16,20 +16,19 @@ import (
"github.com/gorilla/websocket"
)
// Edge-TTS-Konstanten. Das Protokoll ist das bekannte, von edge-tts
// reverse-engineerte „Read Aloud"-Protokoll von Microsoft Edge — Stand der
// Implementierung (2026): DRM-Token Sec-MS-GEC v2 (CS64 + verschleierte
// SHA-256-Variante, siehe edgedrm.go) + MUID-Cookie, im Übrigen das
// bekannte WebSocket-Nachrichtenformat (speech.config + SSML, Binär-Rahmen
// mit 2-Byte-Header-Länge, turn.end). Ändert Microsoft es, liefern die
// Synthese-Pfade klare Fehler mit Hinweis auf Protokoll-Änderung statt
// stiller Fehlfunktion.
// Edge TTS constants. The protocol is the well-known "Read Aloud" protocol
// of Microsoft Edge, reverse-engineered by edge-tts. State of the
// implementation (2026): DRM token Sec-MS-GEC v2 (CS64 + obfuscated SHA-256
// variant, see edgedrm.go) + MUID cookie; otherwise the known WebSocket
// message format (speech.config + SSML, binary frames with a 2-byte header
// length, turn.end). If Microsoft changes it, the synthesis paths return
// clear errors hinting at a protocol change instead of failing silently.
const (
// DefaultEndpointEdge ist der produktive WebSocket-Endpunkt (Pfad ohne
// Query — Token/ConnectionId/Sec-MS-GEC werden in requestURL ergänzt).
// DefaultEndpointEdge is the production WebSocket endpoint (path without
// query; token/ConnectionId/Sec-MS-GEC are added in requestURL).
DefaultEndpointEdge = "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1"
// EnvEdgeEndpoint überschreibt den Endpunkt (Tests/Firmenserver).
// EnvEdgeEndpoint overrides the endpoint (tests/corporate servers).
EnvEdgeEndpoint = "TALK_EDGE_ENDPOINT"
edgeTrustedClientToken = "6A5AA1D4EAFF4E9FB37E23D68491D6F4"
@@ -39,36 +38,36 @@ const (
edgeOutputFormat = "audio-24khz-48kbitrate-mono-mp3" // mp3!
)
// edgeIOTimeout begrenzt einzelne Lese-/Schreiboperationen sowie (wenn der
// Aufrufer keine Deadline mitgibt) die gesamte Synthese.
// edgeIOTimeout bounds individual read/write operations and (if the caller
// provides no deadline) the whole synthesis.
const edgeIOTimeout = 20 * time.Second
// edgeClockSkew korrigiert lokale Uhrenabweichung: Bei 403 liest die Kette
// das Date-Header-Feld der Antwort und justiert die Tick-Berechnung (ein
// Wiederholungsversuch, wie die edge-tts-Referenz).
// edgeClockSkew corrects local clock drift: on 403 the provider reads the
// Date header of the response and adjusts the tick calculation (one retry,
// like the edge-tts reference).
var edgeClockSkew atomic.Int64
// Edge ist die kostenlose dritte Stufe der Kette: Microsofts „Read Aloud"-
// Endpoint, kein Key nötig. Synthesize öffnet eine WebSocket-Verbindung,
// sendet speech.config + SSML-Anfrage und sammelt die Binär-Audio-Frames
// bis turn.end. Rückgabeformat: MP3 (24 kHz mono — der audio-Sink
// dekodiert das Format).
// Edge is the free third tier of the chain: Microsoft's "Read Aloud"
// endpoint, no key needed. Synthesize opens a WebSocket connection, sends
// speech.config + the SSML request and collects the binary audio frames
// until turn.end. Return format: MP3 (24 kHz mono; the audio sink decodes
// the format).
type Edge struct {
// Endpoint ist die WebSocket-URL (ws:// oder wss://), leer = Default.
// Endpoint is the WebSocket URL (ws:// or wss://), empty = default.
Endpoint string
// Voices ist das config-Mapping voice.edge.<lang> = id; leer = neutrale
// Repo-Defaults (de-DE-KatjaNeural / en-US-AriaNeural).
// Voices is the config mapping voice.edge.<lang> = id; empty = neutral
// repo defaults (de-DE-KatjaNeural / en-US-AriaNeural).
Voices map[string]string
// Dialer ist der WebSocket-Dialer; nil = websocket.DefaultDialer.
// Dialer is the WebSocket dialer; nil = websocket.DefaultDialer.
Dialer *websocket.Dialer
pinned string // --voice-Pin (VoicePinner)
pinned string // --voice pin (VoicePinner)
}
// NewEdge baut den Provider aus der Ketten-Konfiguration: Endpunkt aus
// TALK_EDGE_ENDPOINT > [providers.edge].endpoint > Default.
// NewEdge builds the provider from the chain configuration: endpoint from
// TALK_EDGE_ENDPOINT > [providers.edge].endpoint > default.
func NewEdge(cfg ChainConfig) *Edge {
return &Edge{
Endpoint: endpointFrom(cfg, "edge", EnvEdgeEndpoint, DefaultEndpointEdge),
@@ -76,25 +75,25 @@ func NewEdge(cfg ChainConfig) *Edge {
}
}
// Name implementiert Provider.
// Name implements Provider.
func (*Edge) Name() string { return "edge" }
// PinVoice implementiert VoicePinner (CLI-Flag --voice).
// PinVoice implements VoicePinner (CLI flag --voice).
func (p *Edge) PinVoice(id string) { p.pinned = id }
// Available implementiert Provider: Edge braucht nur einen Endpunkt —
// bewusst ohne Netz-Call (Erreichbarkeit stellt Synthesize fest, an deren
// Fehler fällt die Kette weiter).
// Available implements Provider: Edge only needs an endpoint, deliberately
// without a network call (reachability is determined by Synthesize, on
// whose error the chain falls back).
func (p *Edge) Available(ctx context.Context) bool { return p.Endpoint != "" }
// Synthesize implementiert Provider. Rückgabeformat: MP3.
// Synthesize implements Provider. Return format: MP3.
func (p *Edge) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("edge: leerer Text")
return nil, fmt.Errorf("edge: empty text")
}
voice := resolveVoice("edge", lang, p.pinned, p.Voices)
if voice == "" {
return nil, fmt.Errorf("edge: keine Stimme für Sprache %q konfiguriert", lang)
return nil, fmt.Errorf("edge: no voice configured for language %q", lang)
}
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
var cancel context.CancelFunc
@@ -135,7 +134,7 @@ func (p *Edge) Synthesize(ctx context.Context, text, lang string) ([]byte, error
case websocket.TextMessage:
if strings.Contains(string(msg), "Path:turn.end") {
if audio.Len() == 0 {
return nil, fmt.Errorf("edge: Server meldete turn.end ohne Audio-Frames — Protokoll geändert? (%s)", redactURL(wsURL))
return nil, fmt.Errorf("edge: server sent turn.end without audio frames; protocol changed? (%s)", redactURL(wsURL))
}
return audio.Bytes(), nil
}
@@ -148,8 +147,8 @@ func (p *Edge) Synthesize(ctx context.Context, text, lang string) ([]byte, error
}
}
// dialWithSkewRetry baut die Verbindung; bei 403 (Uhrabweichung) wird der
// Server-Zeitstempel übernommen und EINMAL wiederholt.
// dialWithSkewRetry establishes the connection; on 403 (clock drift) the
// server timestamp is adopted and the dial is retried ONCE.
func (p *Edge) dialWithSkewRetry(ctx context.Context) (*websocket.Conn, string, error) {
conn, wsURL, resp, err := p.dial(ctx)
if err == nil {
@@ -167,16 +166,16 @@ func (p *Edge) dialWithSkewRetry(ctx context.Context) (*websocket.Conn, string,
}
}
if resp != nil {
return nil, wsURL, fmt.Errorf("edge: WebSocket-Verbindung zu %s fehlgeschlagen (HTTP %d; Netz/Timeout oder Protokoll geändert?): %w",
return nil, wsURL, fmt.Errorf("edge: WebSocket connection to %s failed (HTTP %d; network/timeout, or the Edge TTS protocol changed?): %w",
redactURL(wsURL), resp.StatusCode, err)
}
return nil, wsURL, fmt.Errorf("edge: WebSocket-Verbindung zu %s fehlgeschlagen — Netz/Timeout oder das Edge-TTS-Protokoll hat sich geändert: %w",
return nil, wsURL, fmt.Errorf("edge: WebSocket connection to %s failed; network/timeout, or the Edge TTS protocol changed: %w",
redactURL(wsURL), err)
}
// dial öffnet die Verbindung gemäß Protokoll: Query mit Token,
// ConnectionId, Sec-MS-GEC (v2) und Version; Header mit Origin,
// Edge-User-Agent und MUID-Cookie.
// dial opens the connection according to the protocol: query with token,
// ConnectionId, Sec-MS-GEC (v2) and version; headers with Origin, Edge
// user agent and MUID cookie.
func (p *Edge) dial(ctx context.Context) (*websocket.Conn, string, *http.Response, error) {
wsURL, err := p.requestURL(time.Now())
if err != nil {
@@ -200,18 +199,18 @@ func (p *Edge) dial(ctx context.Context) (*websocket.Conn, string, *http.Respons
return conn, wsURL, nil, nil
}
// requestURL ergänzt die Protokoll-Query-Parameter und validiert das
// Schema (ws/wss).
// requestURL adds the protocol query parameters and validates the scheme
// (ws/wss).
func (p *Edge) requestURL(now time.Time) (string, error) {
if p.Endpoint == "" {
return "", fmt.Errorf("edge: kein Endpunkt konfiguriert")
return "", fmt.Errorf("edge: no endpoint configured")
}
u, err := url.Parse(p.Endpoint)
if err != nil {
return "", fmt.Errorf("edge: Endpunkt %q unlesbar: %w", p.Endpoint, err)
return "", fmt.Errorf("edge: endpoint %q unreadable: %w", p.Endpoint, err)
}
if u.Scheme != "ws" && u.Scheme != "wss" {
return "", fmt.Errorf("edge: Endpunkt %q muss ws:// oder wss:// sein (ist %q)", p.Endpoint, u.Scheme)
return "", fmt.Errorf("edge: endpoint %q must be ws:// or wss:// (is %q)", p.Endpoint, u.Scheme)
}
q := u.Query()
q.Set("TrustedClientToken", edgeTrustedClientToken)
@@ -222,12 +221,12 @@ func (p *Edge) requestURL(now time.Time) (string, error) {
return u.String(), nil
}
// edgeSecMSGEC erzeugt den DRM-Token Sec-MS-GEC v2: Windows-Filetime-Ticks
// (100 ns, 5-Minuten-Raster, Uhrenkorrektur) + Trusted-Client-Token →
// „<CS64>-<SHA256-Variante>" (beides großes Hex, siehe edgedrm.go).
// edgeSecMSGEC produces the DRM token Sec-MS-GEC v2: Windows filetime ticks
// (100 ns, 5-minute grid, clock correction) + trusted client token →
// "<CS64>-<SHA256 variant>" (both upper-case hex, see edgedrm.go).
func edgeSecMSGEC(now time.Time) string {
secs := now.Unix() + edgeClockSkew.Load() + drmWinEpoch
secs -= secs % 300 // 5-Minuten-Raster
secs -= secs % 300 // 5-minute grid
ticks := uint64(secs) * 10_000_000
strToHash := fmt.Appendf(nil, "%d%s", ticks, edgeTrustedClientToken)
digest := drmSHA256Digest(strToHash)
@@ -253,16 +252,16 @@ func edgeSecMSGEC(now time.Time) string {
return strings.ToUpper(hex.EncodeToString(cs64Buf[:])) + "-" + strings.ToUpper(hex.EncodeToString(shaBuf[:]))
}
// protocolErr überführt Lese-/Schreib-/Verbindungsabbrüche in klare
// „Protokoll geändert?"-Fehler — Edge ist ein undokumentierter Endpoint,
// Änderungen sollen sofort diagnostizierbar sein.
// protocolErr turns read/write/connection aborts into clear "protocol
// changed?" errors; Edge is an undocumented endpoint and changes should be
// diagnosable immediately.
func protocolErr(err error) error {
return fmt.Errorf("edge: Kommunikation fehlgeschlagen — Netz/Timeout oder das Edge-TTS-Protokoll hat sich geändert: %w", err)
return fmt.Errorf("edge: communication failed; network/timeout, or the Edge TTS protocol changed: %w", err)
}
// splitEdgeBinary zerlegt eine binäre Server-Nachricht: Die ersten 2 Bytes
// (big-endian) geben die Länge des Header-Blocks an (CRLF-Zeilen wie
// „Path:audio"), danach kommt die Audio-Payload.
// splitEdgeBinary splits a binary server message: the first 2 bytes
// (big-endian) give the length of the header block (CRLF lines such as
// "Path:audio"), followed by the audio payload.
func splitEdgeBinary(msg []byte) (header string, payload []byte, ok bool) {
if len(msg) < 2 {
return "", nil, false
@@ -274,9 +273,9 @@ func splitEdgeBinary(msg []byte) (header string, payload []byte, ok bool) {
return string(msg[2 : 2+headerLen]), msg[2+headerLen:], true
}
// edgeSpeechConfigMsg ist die erste Client-Nachricht: Header-Block +
// JSON-Body mit Ausgabeformat und Metadaten-Optionen (Satzgrenzen an,
// Wortgrenzen aus — Referenz-Default).
// edgeSpeechConfigMsg is the first client message: header block + JSON body
// with output format and metadata options (sentence boundaries on, word
// boundaries off; reference default).
func edgeSpeechConfigMsg(now time.Time) string {
return fmt.Sprintf(
"X-Timestamp:%s\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n"+
@@ -284,17 +283,17 @@ func edgeSpeechConfigMsg(now time.Time) string {
edgeTimestamp(now), edgeOutputFormat)
}
// edgeSynthesisHeaders ist der Header-Block der SSML-Anfrage. Der
// nachgestellte Z beim Timestamp ist Referenz-Verhalten („Microsoft Edge
// bug"), bewusst mitgeschleppt.
// edgeSynthesisHeaders is the header block of the SSML request. The
// trailing Z on the timestamp is reference behaviour ("Microsoft Edge
// bug"), deliberately carried along.
func edgeSynthesisHeaders(now time.Time, requestID string) string {
return fmt.Sprintf(
"X-RequestId:%s\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:%sZ\r\nPath:ssml\r\n\r\n",
requestID, edgeTimestamp(now))
}
// edgeSSML baut die Sprach-Anfrage (xml:lang wie die Referenz fest
// „en-US"; der Text wird von Steuerzeichen befreit und XML-escapt).
// edgeSSML builds the speech request (xml:lang fixed to "en-US" like the
// reference; the text is stripped of control characters and XML-escaped).
func edgeSSML(voice, text string) string {
return fmt.Sprintf(
"<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>"+
@@ -304,8 +303,8 @@ func edgeSSML(voice, text string) string {
voice, edgeEscape(text))
}
// edgeEscape entfernt nicht unterstützte Steuerzeichen (Referenz:
// remove_incompatible_characters) und escapt danach & < >.
// edgeEscape removes unsupported control characters (reference:
// remove_incompatible_characters) and then escapes & < >.
func edgeEscape(s string) string {
clean := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
@@ -331,27 +330,27 @@ func edgeEscape(s string) string {
return string(esc)
}
// edgeTimestamp formatiert den Zeitstempel im edge-tts-Format.
// edgeTimestamp formats the timestamp in the edge-tts format.
func edgeTimestamp(now time.Time) string {
return now.Format("Mon Jan 02 2006 15:04:05 GMT+0000 (Coordinated Universal Time)")
}
// edgeRequestID erzeugt eine 32-stellige Hex-ID (UUID v4 ohne Striche).
// edgeRequestID produces a 32-character hex ID (UUID v4 without dashes).
func edgeRequestID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// Fallback: zeitbasiert — die ID muss nur eindeutig pro Anfrage sein.
// Fallback: time based; the ID only has to be unique per request.
now := time.Now().UnixNano()
for i := 0; i < 16; i++ {
b[i] = byte(now >> (uint(i%8) * 8))
}
}
b[6] = (b[6] & 0x0f) | 0x40 // UUID-Version
b[8] = (b[8] & 0x3f) | 0x80 // Variante
b[6] = (b[6] & 0x0f) | 0x40 // UUID version
b[8] = (b[8] & 0x3f) | 0x80 // variant
return hex.EncodeToString(b[:])
}
// edgeMUID erzeugt die zufällige MUID für das Cookie (32 Hex, groß).
// edgeMUID produces the random MUID for the cookie (32 hex, upper case).
func edgeMUID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
@@ -360,8 +359,8 @@ func edgeMUID() string {
return strings.ToUpper(hex.EncodeToString(b[:]))
}
// redactURL maskiert die Query-Parameter (enthalten DRM-Token und IDs) in
// Fehlermeldungen — die URL selbst genügt zur Diagnose.
// redactURL masks the query parameters (they contain the DRM token and
// IDs) in error messages; the URL itself is enough for diagnosis.
func redactURL(raw string) string {
u, err := url.Parse(raw)
if err != nil {
+83 -84
View File
@@ -14,72 +14,72 @@ import (
"github.com/gorilla/websocket"
)
// TestEdgeAvailable: Edge braucht nur einen Endpunkt, keinen Key — der
// Konstruktor bäkt den Default-Endpunkt ein, daher ist ein über NewEdge
// gebauter Edge immer verfügbar; nur der Nullwert (direkt gebaut, ohne
// Endpunkt) ist es nicht.
// TestEdgeAvailable: Edge needs only an endpoint, no key. The constructor
// bakes in the default endpoint, so an Edge built via NewEdge is always
// available; only the zero value (built directly, without an endpoint) is
// not.
func TestEdgeAvailable(t *testing.T) {
if (&Edge{}).Available(context.Background()) {
t.Error("Available ohne Endpunkt (Nullwert) = true, want false")
t.Error("Available without endpoint (zero value) = true, want false")
}
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "wss://edge.example.com",
})})
if !p.Available(context.Background()) {
t.Error("Available mit Endpunkt = false, want true")
t.Error("Available with endpoint = false, want true")
}
if !NewEdge(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) {
t.Error("Available mit Default-Endpunkt = false, want true (edge ist der Key-freie Fallback)")
t.Error("Available with default endpoint = false, want true (edge is the key-free fallback)")
}
}
// TestEdgeVerbindungFehlschlaegt (Fehlerpfad-Mock): ein garantiert toter
// Loopback-Port liefert einen klaren Fehler — sofort, ohne Netz.
func TestEdgeVerbindungFehlschlaegt(t *testing.T) {
// TestEdgeConnectionFails (error-path mock): a guaranteed dead loopback
// port yields a clear error, immediately and without network.
func TestEdgeConnectionFails(t *testing.T) {
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "ws://127.0.0.1:1/",
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
_, err := p.Synthesize(context.Background(), "Hello", "de")
if err == nil {
t.Fatal("Synthesize gegen toten Port: kein Fehler")
t.Fatal("Synthesize against dead port: no error")
}
if !strings.Contains(err.Error(), "edge") || !strings.Contains(err.Error(), "WebSocket") {
t.Errorf("Fehler = %v, will klaren edge/WebSocket-Fehler", err)
t.Errorf("error = %v, want a clear edge/WebSocket error", err)
}
}
// TestEdgeUngueltigesSchema: ein Nicht-w(s)-Endpunkt ist ein klarer Fehler.
func TestEdgeUngueltigesSchema(t *testing.T) {
// TestEdgeInvalidScheme: a non-ws(s) endpoint is a clear error.
func TestEdgeInvalidScheme(t *testing.T) {
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "http://127.0.0.1:1234/",
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
_, err := p.Synthesize(context.Background(), "Hello", "de")
if err == nil || !strings.Contains(err.Error(), "ws://") {
t.Errorf("Fehler = %v, will Schema-Hinweis ws://", err)
t.Errorf("error = %v, want scheme hint ws://", err)
}
}
// TestEdgeSSML: die SSML-Anfrage enthält Stimme und den von Steuerzeichen
// befreiten, XML-escapten Text; xml:lang ist wie in der Referenz fest
// „en-US".
// TestEdgeSSML: the SSML request contains the voice and the text stripped
// of control characters and XML-escaped; xml:lang is fixed to "en-US" as in
// the reference.
func TestEdgeSSML(t *testing.T) {
got := edgeSSML("de-DE-KatjaNeural", "AT&T <Kunde> „fertig“")
got := edgeSSML("de-DE-KatjaNeural", "AT&T <customer> „done“")
for _, want := range []string{
"voice name='de-DE-KatjaNeural'",
"xml:lang='en-US'",
"AT&amp;T &lt;Kunde&gt;",
"AT&amp;T &lt;customer&gt;",
} {
if !strings.Contains(got, want) {
t.Errorf("SSML = %q, will %q", got, want)
t.Errorf("SSML = %q, want %q", got, want)
}
}
if strings.Contains(got, "AT&T") {
t.Errorf("SSML enthält unescaptes &: %q", got)
t.Errorf("SSML contains unescaped &: %q", got)
}
}
// TestEdgeEscape: Steuerzeichen (u. a. Vertical Tab aus OCR-Texten) werden
// durch Leerzeichen ersetzt, & < > escapt.
// TestEdgeEscape: control characters (e.g. vertical tab from OCR texts) are
// replaced by spaces, & < > are escaped.
func TestEdgeEscape(t *testing.T) {
in := "a\x0bb&c<d>\x08e"
if want := "a b&amp;c&lt;d&gt; e"; edgeEscape(in) != want {
@@ -87,19 +87,19 @@ func TestEdgeEscape(t *testing.T) {
}
}
// TestEdgeSpeechConfigMsg: Output-Format (mono-mp3) und Pfad stimmen.
// TestEdgeSpeechConfigMsg: output format (mono mp3) and path are correct.
func TestEdgeSpeechConfigMsg(t *testing.T) {
msg := edgeSpeechConfigMsg(time.Unix(0, 0).UTC())
if !strings.Contains(msg, "Path:speech.config") {
t.Errorf("speech.config-Nachricht ohne Path: %q", msg)
t.Errorf("speech.config message without Path: %q", msg)
}
if !strings.Contains(msg, edgeOutputFormat) {
t.Errorf("speech.config-Nachricht ohne Ausgabeformat %q: %q", edgeOutputFormat, msg)
t.Errorf("speech.config message without output format %q: %q", edgeOutputFormat, msg)
}
}
// TestEdgeRequestURL: Query-Parameter (Token, Sec-MS-GEC, Version) werden
// ergänzt; der Token landet nicht in der URL-Debug-Ausgabe.
// TestEdgeRequestURL: query parameters (token, Sec-MS-GEC, version) are
// added; the token does not end up in the redacted URL output.
func TestEdgeRequestURL(t *testing.T) {
p := &Edge{Endpoint: "wss://edge.example.com/path"}
raw, err := p.requestURL(time.Unix(1700000000, 0).UTC())
@@ -112,20 +112,20 @@ func TestEdgeRequestURL(t *testing.T) {
"Sec-MS-GEC-Version=" + edgeSecMSGECVersion,
} {
if !strings.Contains(raw, want) {
t.Errorf("requestURL = %q, will %q", raw, want)
t.Errorf("requestURL = %q, want %q", raw, want)
}
}
if got := redactURL(raw); strings.Contains(got, edgeTrustedClientToken) {
t.Errorf("redactURL leakt den Token: %q", got)
t.Errorf("redactURL leaks the token: %q", got)
}
}
// TestEdgeSecMSGEC: Format „<16 Hex>-<64 Hex>" (groß), 5-Minuten-Raster
// (innerhalb des Fensters stabil, über die Grenze anders) und exakte
// Referenzvektoren der edge-tts-DRM-Implementierung (v2).
// TestEdgeSecMSGEC: format "<16 hex>-<64 hex>" (upper case), 5-minute grid
// (stable within the window, different across the boundary) and exact
// reference vectors of the edge-tts DRM implementation (v2).
func TestEdgeSecMSGEC(t *testing.T) {
// Referenzvektoren: (Unix-Sekunden, erwarteter Token) — erzeugt mit der
// Python-Referenz (edge-tts src/edge_tts/drm.py, Stand 2026-10).
// Reference vectors: (unix seconds, expected token), generated with the
// Python reference (edge-tts src/edge_tts/drm.py, as of 2026-10).
vectors := []struct {
ts int64
want string
@@ -141,36 +141,36 @@ func TestEdgeSecMSGEC(t *testing.T) {
}
}
// Fenster-Stabilität (Basis am Fensterbeginn, mod 300 == 0).
// Window stability (base at window start, mod 300 == 0).
base := time.Unix(1700000400, 0).UTC()
token := edgeSecMSGEC(base)
if edgeSecMSGEC(base.Add(4*time.Minute)) != token {
t.Error("Sec-MS-GEC weicht innerhalb des 5-Minuten-Fensters ab")
t.Error("Sec-MS-GEC differs within the 5-minute window")
}
if edgeSecMSGEC(base.Add(6*time.Minute)) == token {
t.Error("Sec-MS-GEC ändert sich über die Fenster-Grenze nicht")
t.Error("Sec-MS-GEC does not change across the window boundary")
}
if len(token) != 16+1+64 || !strings.Contains(token, "-") {
t.Errorf("Token-Format = %q, want <16 Hex>-<64 Hex>", token)
t.Errorf("token format = %q, want <16 hex>-<64 hex>", token)
}
}
// TestEdgeRequestID: 32 Hex-Zeichen, eindeutig.
// TestEdgeRequestID: 32 hex characters, unique.
func TestEdgeRequestID(t *testing.T) {
a, b := edgeRequestID(), edgeRequestID()
if len(a) != 32 {
t.Errorf("RequestID-Länge = %d, want 32", len(a))
t.Errorf("request ID length = %d, want 32", len(a))
}
if _, err := hex.DecodeString(a); err != nil {
t.Errorf("RequestID ist kein Hex: %v", err)
t.Errorf("request ID is not hex: %v", err)
}
if a == b {
t.Error("RequestID nicht eindeutig")
t.Error("request ID not unique")
}
}
// TestEdgeSplitBinary: Binär-Rahmen: 2 Bytes big-endian Header-Länge, dann
// Header (Path:audio), dann Payload.
// TestEdgeSplitBinary: binary frame: 2 bytes big-endian header length, then
// header (Path:audio), then payload.
func TestEdgeSplitBinary(t *testing.T) {
msg := []byte{0, 12, 'P', 'a', 't', 'h', ':', 'a', 'u', 'd', 'i', 'o', '\r', '\n', 1, 2, 3}
header, payload, ok := splitEdgeBinary(msg)
@@ -178,15 +178,15 @@ func TestEdgeSplitBinary(t *testing.T) {
t.Errorf("splitEdgeBinary = %q, %x, %v", header, payload, ok)
}
if _, _, ok := splitEdgeBinary([]byte{0, 99, 'x'}); ok {
t.Error("übergroßer Header-Längenangabe soll abgelehnt werden")
t.Error("oversized header length should be rejected")
}
}
// fakeEdgeServer ist ein Loopback-Fake des Edge-WSS-Protokolls (kein
// echter Netz-Call — alles bleibt auf 127.0.0.1). Er prüft die beiden
// Client-Nachrichten und antwortet nach Protokoll: Audio-Binär-Rahmen mit
// Path:audio, danach turn.end. audio ist die zu liefernde Payload;
// closing schließt die Verbindung vor turn.end (Fehlerpfad).
// fakeEdgeServer is a loopback fake of the Edge WSS protocol (no real
// network call; everything stays on 127.0.0.1). It checks the two client
// messages and answers per protocol: a binary audio frame with Path:audio,
// then turn.end. audio is the payload to deliver; closing closes the
// connection before turn.end (error path).
func fakeEdgeServer(t *testing.T, audio []byte, closing bool) (*httptest.Server, *fakeEdgeSeen) {
t.Helper()
seen := &fakeEdgeSeen{}
@@ -208,21 +208,21 @@ func fakeEdgeServer(t *testing.T, audio []byte, closing bool) (*httptest.Server,
return
}
if _, cfg, err := conn.ReadMessage(); err != nil {
t.Errorf("speech.config lesen: %v", err)
t.Errorf("reading speech.config: %v", err)
} else {
seen.mu.Lock()
seen.config = string(cfg)
seen.mu.Unlock()
}
if _, req, err := conn.ReadMessage(); err != nil {
t.Errorf("ssml lesen: %v", err)
t.Errorf("reading ssml: %v", err)
} else {
seen.mu.Lock()
seen.ssml = string(req)
seen.mu.Unlock()
}
if closing {
return // Verbindung dichtmachen, ohne zu antworten
return // close the connection without answering
}
header := "X-RequestId:dummy\r\nPath:audio\r\n"
frame := make([]byte, 0, 2+len(header)+len(audio))
@@ -252,10 +252,9 @@ func (s *fakeEdgeSeen) snapshot() (configMsg, ssml string) {
return s.config, s.ssml
}
// TestEdgeLoopbackFakeServer: kompletter Protokoll-Durchlauf gegen den
// Loopback-Fake (fake-bar getestet: Handshake-Parameter, beide
// Client-Nachrichten, Binär-Rahmen-Parsing, turn.end-Ende) — siehe
// Task-03-Report für die Abgrenzung zum echten Endpoint.
// TestEdgeLoopbackFakeServer: complete protocol run against the loopback
// fake (tested fakeably: handshake parameters, both client messages,
// binary frame parsing, turn.end termination).
func TestEdgeLoopbackFakeServer(t *testing.T) {
wantAudio := []byte("\xff\xfb\x90\x00" + "edge-audio")
ts, seen := fakeEdgeServer(t, wantAudio, false)
@@ -263,9 +262,9 @@ func TestEdgeLoopbackFakeServer(t *testing.T) {
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "ws://" + ts.Listener.Addr().String(),
})})
data, err := p.Synthesize(context.Background(), "Hallo & fertig", "de")
data, err := p.Synthesize(context.Background(), "Hello & done", "de")
if err != nil {
t.Fatalf("Synthesize gegen Loopback-Fake: %v", err)
t.Fatalf("Synthesize against loopback fake: %v", err)
}
if string(data) != string(wantAudio) {
t.Errorf("Synthesize = %x, want %x", data, wantAudio)
@@ -275,47 +274,47 @@ func TestEdgeLoopbackFakeServer(t *testing.T) {
t.Errorf("speech.config = %q", configMsg)
}
if !strings.Contains(ssml, "Path:ssml") {
t.Errorf("ssml-Anfrage ohne Path:ssml: %q", ssml)
t.Errorf("ssml request without Path:ssml: %q", ssml)
}
if !strings.Contains(ssml, "voice name='de-DE-KatjaNeural'") {
t.Errorf("ssml ohne Default-Stimme de: %q", ssml)
t.Errorf("ssml without default voice de: %q", ssml)
}
if !strings.Contains(ssml, "Hallo &amp; fertig") {
t.Errorf("ssml ohne escapten Text: %q", ssml)
if !strings.Contains(ssml, "Hello &amp; done") {
t.Errorf("ssml without escaped text: %q", ssml)
}
}
// TestEdgeLoopbackVerbindungsAbbruch: Server schließt nach den Anfragen
// ohne Audio → klarer „Protokoll geändert?"-Fehler.
func TestEdgeLoopbackVerbindungsAbbruch(t *testing.T) {
// TestEdgeLoopbackConnectionAbort: the server closes after the requests
// without audio → clear "protocol changed?" error.
func TestEdgeLoopbackConnectionAbort(t *testing.T) {
ts, _ := fakeEdgeServer(t, nil, true)
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "ws://" + ts.Listener.Addr().String(),
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
if err == nil || !strings.Contains(err.Error(), "Protokoll") {
t.Errorf("Fehler = %v, will Protokoll-Änderungs-Hinweis", err)
_, err := p.Synthesize(context.Background(), "Hello", "de")
if err == nil || !strings.Contains(err.Error(), "protocol") {
t.Errorf("error = %v, want protocol-change hint", err)
}
}
// TestEdgeIntegrationEcht ist die als testing.Short überspringbare
// Integrations-Markierung gegen den ECHTEN Microsoft-Endpunkt (kostenlos,
// aber echtes Netz — Standard-Testläufe und -short überspringen; einmalige
// manuelle Abnahme mit TALK_TEST_REAL_EDGE=1).
func TestEdgeIntegrationEcht(t *testing.T) {
// TestEdgeIntegrationReal is the integration marker against the REAL
// Microsoft endpoint, skippable via testing.Short (free, but real network;
// standard test runs and -short skip it; one-off manual acceptance with
// TALK_TEST_REAL_EDGE=1).
func TestEdgeIntegrationReal(t *testing.T) {
if testing.Short() {
t.Skip("Übersprungen (-short)")
t.Skip("skipped (-short)")
}
if os.Getenv("TALK_TEST_REAL_EDGE") != "1" {
t.Skip("Übersprungen (echtes Netz) — mit TALK_TEST_REAL_EDGE=1 ausführen")
t.Skip("skipped (real network); run with TALK_TEST_REAL_EDGE=1")
}
p := NewEdge(ChainConfig{})
data, err := p.Synthesize(context.Background(), "Kurzer Sprachtest über Edge.", "de")
data, err := p.Synthesize(context.Background(), "Short speech test via Edge.", "de")
if err != nil {
t.Fatalf("Synthesize gegen echten Endpunkt: %v", err)
t.Fatalf("Synthesize against real endpoint: %v", err)
}
if len(data) == 0 {
t.Fatal("Synthesize gegen echten Endpunkt: 0 Bytes")
t.Fatal("Synthesize against real endpoint: 0 bytes")
}
t.Logf("Edge-Integration: %d Bytes Audio", len(data))
t.Logf("Edge integration: %d bytes of audio", len(data))
}
+46 -46
View File
@@ -4,35 +4,35 @@ import (
"encoding/binary"
)
// Edge-DRM: Sec-MS-GEC v2. Microsoft verlangt seit der Protokoll-Änderung
// (edge-tts-Referenz, Stand 2026) einen Token der Form
// „<16 Hex CS64>-<64 Hex SHA256-Variante>" über den 5-Minuten-gerundeten
// Windows-Filetime-Ticks + Trusted-Client-Token. Beide Digests sind
// absichtlich verschleierte Varianten bekannter Algorithmen — die
// Konstanten und der Kontrollfluss unten sind eine wörtliche Portierung
// der Referenz (edge-tts src/edge_tts/drm.py, MIT) und gegen deren
// Ausgaben tabellengeprüft (TestEdgeDRMReferenzvektoren). Ändert Microsoft
// das Schema erneut, liefert der Edge-Provider klare Fehler („Protokoll
// geändert?"), statt still zu versagen.
// Edge DRM: Sec-MS-GEC v2. Since the protocol change (edge-tts reference,
// as of 2026) Microsoft requires a token of the form
// "<16 hex CS64>-<64 hex SHA256 variant>" over the 5-minute-rounded
// Windows filetime ticks + trusted client token. Both digests are
// deliberately obfuscated variants of well-known algorithms; the constants
// and the control flow below are a literal port of the reference (edge-tts
// src/edge_tts/drm.py, MIT) and are table-checked against its outputs
// (TestEdgeSecMSGEC). If Microsoft changes the scheme again, the Edge
// provider returns clear errors ("protocol changed?") instead of failing
// silently.
const (
drmWinEpoch = 11644473600 // Sekunden zwischen 1601-01-01 und 1970-01-01
drmWinEpoch = 11644473600 // seconds between 1601-01-01 and 1970-01-01
)
// drmIV ist der Standard-SHA-256-Initialvektor.
// drmIV is the standard SHA-256 initial vector.
var drmIV = [8]uint32{
0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A,
0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19,
}
// drmMidState ist der Arbeitsregister-Stand der Referenz für den
// Mid-Block-Einstieg (Runde 14 des ersten Blocks).
// drmMidState is the reference's working-register state for the mid-block
// entry (round 14 of the first block).
var drmMidState = [8]uint32{
0xFD9FBEA2, 0x91243CE7, 0xBE499094, 0xCD2E1E0A,
0x6C006F49, 0x04BD35AB, 0xFEB0A7E4, 0x03EFFBD2,
}
// drmSchedule sind vorberechnete Schedule-Anteile des ersten Blocks.
// drmSchedule holds precomputed schedule parts of the first block.
var drmSchedule = [14]uint32{
0xEB24BF9E, 0x1558691F, 0xA0C09EE2, 0x0CFA32BA,
0xA622EE8C, 0xD1DD9491, 0xC0DD25A0, 0xF9E70AB6,
@@ -40,7 +40,7 @@ var drmSchedule = [14]uint32{
0xD258B060, 0x72766564,
}
// drmAdjustment ersetzt die Rundungskonstante K[14] im Mid-Block-Pfad.
// drmAdjustment replaces the round constant K[14] on the mid-block path.
const (
drmAdjustment uint32 = 0xC9DC06A7
drmSigma0Adj uint32 = 0x059C0B80
@@ -50,7 +50,7 @@ const (
drmCS64Adj1 uint32 = 0x0B1A0000
)
// drmK sind die Standard-SHA-256-Rundungskonstanten.
// drmK are the standard SHA-256 round constants.
var drmK = [64]uint32{
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
@@ -62,7 +62,7 @@ var drmK = [64]uint32{
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
}
// drmCS64Coefficients: (Term-, Offset-Koeffizient) je Zeile.
// drmCS64Coefficients: (term coefficient, offset coefficient) per row.
var drmCS64Coefficients = [14][2]uint32{
{0xE82E91B1, 0xB32700D0}, {0x44DE24C6, 0x88DFF51D},
{0x3BEB65BC, 0x29514038}, {0xC89C5933, 0xB328CBDF},
@@ -73,23 +73,23 @@ var drmCS64Coefficients = [14][2]uint32{
{0x35FA38C6, 0xBB04A2C1}, {0xCB49E3C6, 0xC5626167},
}
// drmSigma0 ist die (verschleiert notierte) SHA-256-σ0-Mischfunktion.
// drmSigma0 is the (obfuscated-notation) SHA-256 σ0 mixing function.
func drmSigma0(v uint32) uint32 {
return v>>7 ^ v<<25 ^ v>>18 ^ v<<14 ^ v>>3
}
// drmSigma1 ist die SHA-256-σ1-Mischfunktion.
// drmSigma1 is the SHA-256 σ1 mixing function.
func drmSigma1(v uint32) uint32 {
return v>>17 ^ v<<15 ^ v>>19 ^ v<<13 ^ v>>10
}
// drmRol16 rotiert 32 Bit um 16.
// drmRol16 rotates 32 bits by 16.
func drmRol16(v uint32) uint32 { return v<<16 | v>>16 }
// drmRotR rotiert 32 Bit rechts um n.
// drmRotR rotates 32 bits right by n.
func drmRotR(v uint32, n uint) uint32 { return v>>n | v<<(32-n) }
// drmExpand erweitert die Message-Schedule ab from auf 64 Wörter.
// drmExpand extends the message schedule from index from to 64 words.
func drmExpand(words []uint32, from int) []uint32 {
for i := from; i < 64; i++ {
x, y := words[i-2], words[i-15]
@@ -98,9 +98,9 @@ func drmExpand(words []uint32, from int) []uint32 {
return words
}
// drmRounds führt die SHA-256-Runden aus — wörtlich nach Referenz,
// inklusive des Mid-Block-Einstiegs (firstRound 14 mit ersetzter
// Rundungskonstante) und der 8er-Rollen-Rotation der Register.
// drmRounds executes the SHA-256 rounds, literally following the
// reference, including the mid-block entry (firstRound 14 with the replaced
// round constant) and the 8-way role rotation of the registers.
func drmRounds(state [8]uint32, words []uint32, firstRound int) [8]uint32 {
a, b, c, d, e, f, g, h := state[0], state[1], state[2], state[3], state[4], state[5], state[6], state[7]
midBlock := firstRound == 14
@@ -120,35 +120,35 @@ func drmRounds(state [8]uint32, words []uint32, firstRound int) [8]uint32 {
a, b, c, d, e, f, g, h = t1+t2, a, b, c, d+t1, e, f, g
}
for i := aligned; i < 64; i += 8 {
// Runde i
// round i
t1 := h + (drmRotR(e, 6) ^ drmRotR(e, 11) ^ drmRotR(e, 25)) + (g ^ (e & (f ^ g))) + k(i) + words[i]
d += t1
h = t1 + (drmRotR(a, 2) ^ drmRotR(a, 13) ^ drmRotR(a, 22)) + ((a & b) | (c & (a | b)))
// Runde i+1 (Rollen rotiert)
// round i+1 (roles rotated)
t1 = g + (drmRotR(d, 6) ^ drmRotR(d, 11) ^ drmRotR(d, 25)) + (f ^ (d & (e ^ f))) + k(i+1) + words[i+1]
c += t1
g = t1 + (drmRotR(h, 2) ^ drmRotR(h, 13) ^ drmRotR(h, 22)) + ((h & a) | (b & (h | a)))
// Runde i+2
// round i+2
t1 = f + (drmRotR(c, 6) ^ drmRotR(c, 11) ^ drmRotR(c, 25)) + (e ^ (c & (d ^ e))) + k(i+2) + words[i+2]
b += t1
f = t1 + (drmRotR(g, 2) ^ drmRotR(g, 13) ^ drmRotR(g, 22)) + ((g & h) | (a & (g | h)))
// Runde i+3
// round i+3
t1 = e + (drmRotR(b, 6) ^ drmRotR(b, 11) ^ drmRotR(b, 25)) + (d ^ (b & (c ^ d))) + k(i+3) + words[i+3]
a += t1
e = t1 + (drmRotR(f, 2) ^ drmRotR(f, 13) ^ drmRotR(f, 22)) + ((f & g) | (h & (f | g)))
// Runde i+4
// round i+4
t1 = d + (drmRotR(a, 6) ^ drmRotR(a, 11) ^ drmRotR(a, 25)) + (c ^ (a & (b ^ c))) + k(i+4) + words[i+4]
h += t1
d = t1 + (drmRotR(e, 2) ^ drmRotR(e, 13) ^ drmRotR(e, 22)) + ((e & f) | (g & (e | f)))
// Runde i+5
// round i+5
t1 = c + (drmRotR(h, 6) ^ drmRotR(h, 11) ^ drmRotR(h, 25)) + (b ^ (h & (a ^ b))) + k(i+5) + words[i+5]
g += t1
c = t1 + (drmRotR(d, 2) ^ drmRotR(d, 13) ^ drmRotR(d, 22)) + ((d & e) | (f & (d | e)))
// Runde i+6
// round i+6
t1 = b + (drmRotR(g, 6) ^ drmRotR(g, 11) ^ drmRotR(g, 25)) + (a ^ (g & (h ^ a))) + k(i+6) + words[i+6]
f += t1
b = t1 + (drmRotR(c, 2) ^ drmRotR(c, 13) ^ drmRotR(c, 22)) + ((c & d) | (e & (c | d)))
// Runde i+7
// round i+7
t1 = a + (drmRotR(f, 6) ^ drmRotR(f, 11) ^ drmRotR(f, 25)) + (h ^ (f & (g ^ h))) + k(i+7) + words[i+7]
e += t1
a = t1 + (drmRotR(b, 2) ^ drmRotR(b, 13) ^ drmRotR(b, 22)) + ((b & c) | (d & (b | c)))
@@ -156,16 +156,16 @@ func drmRounds(state [8]uint32, words []uint32, firstRound int) [8]uint32 {
return [8]uint32{a, b, c, d, e, f, g, h}
}
// drmSHA256Init berechnet den Zustand nach dem (verschleiert notierten)
// ersten Block: 14 Nullwörter + die ersten 7 Bytes von data als
// 24-Bit-/32-Bit-Big-Endian-Wörter, Schedule mit Korrekturen, Runden ab 14.
// drmSHA256Init computes the state after the (obfuscated-notation) first
// block: 14 zero words + the first 7 bytes of data as 24-bit/32-bit
// big-endian words, schedule with corrections, rounds from 14 onwards.
func drmSHA256Init(data []byte) [8]uint32 {
words := make([]uint32, 30, 64)
words[14] = uint32(data[0])<<16 | uint32(data[1])<<8 | uint32(data[2])
words[15] = binary.BigEndian.Uint32(data[3:7])
for i := 16; i < 30; i++ {
v := drmSchedule[i-16]
// σ1-Anteil von W[i-2] (Index 16 liest W[14], dazu die Korrektur).
// σ1 part of W[i-2] (index 16 reads W[14], plus the correction).
s1 := drmSigma1(words[i-2])
if i == 16 {
s1 ^= drmSigma1Adj
@@ -174,7 +174,7 @@ func drmSHA256Init(data []byte) [8]uint32 {
if i-7 >= 14 {
v += words[i-7]
}
if i-15 >= 14 { // nur i == 29, liest W[14], dazu die Korrektur
if i-15 >= 14 { // only i == 29, reads W[14], plus the correction
s0 := drmSigma0(words[i-15])
if i == 29 {
s0 ^= drmSigma0Adj
@@ -193,9 +193,9 @@ func drmSHA256Init(data []byte) [8]uint32 {
return out
}
// drmSHA256Digest ist die verschleierte SHA-256-Variante der Referenz:
// Block 1 steckt in drmSHA256Init (erste 7 Bytes), danach data[7:] mit
// Referenz-Längenfeld (57+len(data)) und Standard-Runden.
// drmSHA256Digest is the reference's obfuscated SHA-256 variant: block 1
// lives in drmSHA256Init (first 7 bytes), followed by data[7:] with the
// reference length field (57+len(data)) and standard rounds.
func drmSHA256Digest(data []byte) [8]uint32 {
length := 57 + len(data)
padded := append([]byte(nil), data[7:]...)
@@ -224,8 +224,8 @@ func drmSHA256Digest(data []byte) [8]uint32 {
return state
}
// drmWordsLE4Even zerlegt data in little-endian u32-Wörter (auf 4 Bytes
// null-aufgefüllt, Wortzahl gerade).
// drmWordsLE4Even splits data into little-endian u32 words (zero-padded to
// 4 bytes, even word count).
func drmWordsLE4Even(data []byte) []uint32 {
padded := make([]byte, len(data))
copy(padded, data)
@@ -242,8 +242,8 @@ func drmWordsLE4Even(data []byte) []uint32 {
return words
}
// drmCS64 berechnet die beiden CS64-Prüfsummen-Wörter (wörtliche
// Portierung; Schlüssel aus den ersten 8 Digest-Bytes little-endian).
// drmCS64 computes the two CS64 checksum words (literal port; keys from
// the first 8 digest bytes, little-endian).
func drmCS64(data []byte, digest [8]uint32) (a, b uint32) {
var digestBytes [32]byte
for i, w := range digest {
+33 -33
View File
@@ -8,51 +8,50 @@ import (
"strings"
)
// ElevenLabs-Konstanten (öffentlicher Vertrag: Env-Namen sind Teil der
// Fleet-Doku, Endpunkt-Default Teil der Konfigurierbarkeit).
// ElevenLabs constants (public contract: the env names are part of the
// fleet documentation, the endpoint default part of the configurability).
const (
// DefaultEndpointElevenLabs ist der produktive API-Endpunkt.
// DefaultEndpointElevenLabs is the production API endpoint.
DefaultEndpointElevenLabs = "https://api.elevenlabs.io"
// EnvElevenLabsKey liefert den API-Key (Spec: Keys ausschließlich Env,
// nie config.toml).
// EnvElevenLabsKey provides the API key (spec: keys come exclusively
// from the environment, never from config.toml).
EnvElevenLabsKey = "TALK_ELEVENLABS_KEY"
// EnvElevenLabsEndpoint überschreibt den Endpunkt (Tests/Firmenserver;
// schlägt die config.toml, siehe voices.go/chain.go-Doku).
// EnvElevenLabsEndpoint overrides the endpoint (tests/corporate
// servers; beats config.toml, see the voices.go/chain.go docs).
EnvElevenLabsEndpoint = "TALK_ELEVENLABS_ENDPOINT"
elevenLabsModel = "eleven_multilingual_v2" // multilingual v2, Task-Brief
elevenLabsModel = "eleven_multilingual_v2" // multilingual v2
)
// ElevenLabs ist die Premium-Stufe der Kette. Synthesize führt POST
// <endpoint>/v1/text-to-speech/<voiceID> aus — Header `xi-api-key`, Body
// ElevenLabs is the premium tier of the chain. Synthesize performs POST
// <endpoint>/v1/text-to-speech/<voiceID> with header `xi-api-key` and body
// {text, model_id: "eleven_multilingual_v2", voice_settings:
// {stability: 0.5, similarity_boost: 0.75}} — und liefert die Antwort-Bytes
// unverändert zurück. Rückgabeformat: MP3 (dokumentierter Vertrag des
// audio-Pakets).
// {stability: 0.5, similarity_boost: 0.75}} and returns the response bytes
// unchanged. Return format: MP3 (documented contract of the audio package).
type ElevenLabs struct {
// Endpoint ist die Basis-URL (ohne Pfad); leer wird beim Bau durch den
// Default ersetzt.
// Endpoint is the base URL (without path); empty is replaced by the
// default at construction.
Endpoint string
// APIKey ist der ElevenLabs-Key (aus TALK_ELEVENLABS_KEY).
// APIKey is the ElevenLabs key (from TALK_ELEVENLABS_KEY).
APIKey string
// Voices ist das config-Mapping voice.elevenlabs.<lang> = id; leer =
// neutrale Repo-Defaults (siehe voices.go).
// Voices is the config mapping voice.elevenlabs.<lang> = id; empty =
// neutral repo defaults (see voices.go).
Voices map[string]string
// Client ist der HTTP-Client; nil = geteilter Client mit Timeout
// Client is the HTTP client; nil = shared client with timeout
// (cloudTimeout).
Client *http.Client
pinned string // --voice-Pin (VoicePinner)
pinned string // --voice pin (VoicePinner)
}
// NewElevenLabs baut den Provider aus der Ketten-Konfiguration: Endpunkt
// aus TALK_ELEVENLABS_ENDPOINT > [providers.elevenlabs].endpoint > Default;
// Key ausschließlich aus TALK_ELEVENLABS_KEY.
// NewElevenLabs builds the provider from the chain configuration: endpoint
// from TALK_ELEVENLABS_ENDPOINT > [providers.elevenlabs].endpoint > default;
// key exclusively from TALK_ELEVENLABS_KEY.
func NewElevenLabs(cfg ChainConfig) *ElevenLabs {
return &ElevenLabs{
Endpoint: endpointFrom(cfg, "elevenlabs", EnvElevenLabsEndpoint, DefaultEndpointElevenLabs),
@@ -61,20 +60,21 @@ func NewElevenLabs(cfg ChainConfig) *ElevenLabs {
}
}
// Name implementiert Provider.
// Name implements Provider.
func (*ElevenLabs) Name() string { return "elevenlabs" }
// PinVoice implementiert VoicePinner (CLI-Flag --voice).
// PinVoice implements VoicePinner (CLI flag --voice).
func (p *ElevenLabs) PinVoice(id string) { p.pinned = id }
// Available implementiert Provider: Key und Endpunkt müssen gesetzt sein
// (bewusst ohne Netz-Call — Available muss billig bleiben; ob der Key
// gültig ist, stellt Synthesize fest und die Kette fällt weiter).
// Available implements Provider: key and endpoint must be set (deliberately
// without a network call; Available must stay cheap. Whether the key is
// valid is determined by Synthesize, and the chain falls back).
func (p *ElevenLabs) Available(ctx context.Context) bool {
return p.APIKey != "" && p.Endpoint != ""
}
// elevenLabsRequest ist der Antwort-Body (Task-Brief, wörtliche Werte).
// elevenLabsRequest is the request body (literal values from the API
// contract).
type elevenLabsRequest struct {
Text string `json:"text"`
ModelID string `json:"model_id"`
@@ -86,14 +86,14 @@ type elevenLabsVoiceSettings struct {
SimilarityBoost float64 `json:"similarity_boost"`
}
// Synthesize implementiert Provider. Rückgabeformat: MP3.
// Synthesize implements Provider. Return format: MP3.
func (p *ElevenLabs) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("elevenlabs: leerer Text")
return nil, fmt.Errorf("elevenlabs: empty text")
}
voice := resolveVoice("elevenlabs", lang, p.pinned, p.Voices)
if voice == "" {
return nil, fmt.Errorf("elevenlabs: keine Stimme für Sprache %q konfiguriert", lang)
return nil, fmt.Errorf("elevenlabs: no voice configured for language %q", lang)
}
body, err := json.Marshal(elevenLabsRequest{
Text: text,
@@ -101,7 +101,7 @@ func (p *ElevenLabs) Synthesize(ctx context.Context, text, lang string) ([]byte,
VoiceSettings: elevenLabsVoiceSettings{Stability: 0.5, SimilarityBoost: 0.75},
})
if err != nil {
return nil, fmt.Errorf("elevenlabs: Anfrage-Body bauen: %w", err)
return nil, fmt.Errorf("elevenlabs: building request body: %w", err)
}
url := strings.TrimRight(p.Endpoint, "/") + "/v1/text-to-speech/" + voice
return postForBytes(ctx, p.Client, "elevenlabs", url,
+47 -47
View File
@@ -11,7 +11,7 @@ import (
"testing"
)
// fakeTTServation zeichnet einen Request am Fake-Server auf.
// recordedRequest records a request seen by the fake server.
type recordedRequest struct {
method string
path string
@@ -19,8 +19,8 @@ type recordedRequest struct {
body []byte
}
// fakeTTSServer ist der httptest-Fake eines Cloud-Providers: antwortet mit
// status + body und zeichnet den (einzigen) gesehenen Request auf.
// fakeTTSServer is the httptest fake of a cloud provider: responds with
// status + body and records the (single) request it saw.
func fakeTTSServer(t *testing.T, status int, body []byte) (*httptest.Server, *recordedRequest) {
t.Helper()
rec := &recordedRequest{}
@@ -28,7 +28,7 @@ func fakeTTSServer(t *testing.T, status int, body []byte) (*httptest.Server, *re
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body2, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("Fake-Server: Body lesen: %v", err)
t.Errorf("fake server: reading body: %v", err)
}
mu.Lock()
rec.method, rec.path, rec.header, rec.body = r.Method, r.URL.Path, r.Header.Clone(), body2
@@ -44,14 +44,14 @@ func envMap(kv map[string]string) func(string) string {
return func(k string) string { return kv[k] }
}
// fakeMP3 ist eine beliebige Byte-Folge — die Provider geben Antwort-Bytes
// unverändert zurück, der audio-Sink dekodiert (eigene Tests).
var fakeMP3 = []byte("\xff\xfb\x90\x00" + "audio-bytes-von-elf")
// fakeMP3 is an arbitrary byte sequence; the providers return response
// bytes unchanged, the audio sink decodes (its own tests).
var fakeMP3 = []byte("\xff\xfb\x90\x00" + "audio-bytes-from-eleven")
// TestElevenLabsSynthese200: 200 mit Bytes → Bytes kommen unverändert
// zurück; Methode, Pfad (inkl. Voice-ID), Header (xi-api-key) und der
// JSON-Body (model_id, voice_settings) entsprechen dem Task-Brief.
func TestElevenLabsSynthese200(t *testing.T) {
// TestElevenLabsSynthesize200: 200 with bytes → bytes come back unchanged;
// method, path (incl. voice ID), header (xi-api-key) and the JSON body
// (model_id, voice_settings) match the API contract.
func TestElevenLabsSynthesize200(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewElevenLabs(ChainConfig{
Getenv: envMap(map[string]string{
@@ -60,18 +60,18 @@ func TestElevenLabsSynthese200(t *testing.T) {
}),
})
data, err := p.Synthesize(context.Background(), "Hallo Welt", "de")
data, err := p.Synthesize(context.Background(), "Hello world", "de")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if string(data) != string(fakeMP3) {
t.Errorf("Synthesize = %q, want Fake-Bytes %q", data, fakeMP3)
t.Errorf("Synthesize = %q, want fake bytes %q", data, fakeMP3)
}
if rec.method != http.MethodPost {
t.Errorf("Methode = %s, want POST", rec.method)
t.Errorf("method = %s, want POST", rec.method)
}
if want := "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM"; rec.path != want {
t.Errorf("Pfad = %q, want %q (neutrales Repo-Default „Rachel“)", rec.path, want)
t.Errorf("path = %q, want %q (neutral repo default \"Rachel\")", rec.path, want)
}
if got := rec.header.Get("xi-api-key"); got != "testkey" {
t.Errorf("xi-api-key = %q, want %q", got, "testkey")
@@ -86,9 +86,9 @@ func TestElevenLabsSynthese200(t *testing.T) {
} `json:"voice_settings"`
}
if err := json.Unmarshal(rec.body, &body); err != nil {
t.Fatalf("Antwort-Body ist kein JSON: %v (%s)", err, rec.body)
t.Fatalf("request body is not JSON: %v (%s)", err, rec.body)
}
if body.Text != "Hallo Welt" {
if body.Text != "Hello world" {
t.Errorf("text = %q", body.Text)
}
if body.ModelID != "eleven_multilingual_v2" {
@@ -99,9 +99,9 @@ func TestElevenLabsSynthese200(t *testing.T) {
}
}
// TestElevenLabsHTTPFehler: 500 und 401 sind klare Fehler, die Status und
// einen Body-Ausschnitt nennen (die Kette fällt daran weiter).
func TestElevenLabsHTTPFehler(t *testing.T) {
// TestElevenLabsHTTPError: 500 and 401 are clear errors naming the status
// and a body snippet (the chain falls back on them).
func TestElevenLabsHTTPError(t *testing.T) {
cases := []struct {
status int
body string
@@ -117,32 +117,32 @@ func TestElevenLabsHTTPFehler(t *testing.T) {
EnvElevenLabsKey: "testkey",
EnvElevenLabsEndpoint: ts.URL,
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
_, err := p.Synthesize(context.Background(), "Hello", "de")
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Synthesize-Fehler = %v, will HTTP %s", err, tc.want)
t.Fatalf("Synthesize error = %v, want HTTP %s", err, tc.want)
}
if !strings.Contains(err.Error(), "elevenlabs") {
t.Errorf("Fehler soll den Provider nennen: %v", err)
t.Errorf("error should name the provider: %v", err)
}
})
}
}
// TestElevenLabsAvailable: ohne Key nicht verfügbar, mit Key verfügbar —
// ohne jeden Netz-Call.
// TestElevenLabsAvailable: not available without a key, available with
// one; without any network call.
func TestElevenLabsAvailable(t *testing.T) {
if NewElevenLabs(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) {
t.Error("Available ohne Key = true, want false")
t.Error("Available without key = true, want false")
}
p := NewElevenLabs(ChainConfig{Getenv: envMap(map[string]string{EnvElevenLabsKey: "k"})})
if !p.Available(context.Background()) {
t.Error("Available mit Key = false, want true")
t.Error("Available with key = false, want true")
}
}
// TestElevenLabsVoiceRangfolge: --voice-Pin schlägt config-Mapping, Mapping
// schlägt Repo-Default (beobachtbar am Request-Pfad).
func TestElevenLabsVoiceRangfolge(t *testing.T) {
// TestElevenLabsVoicePrecedence: the --voice pin beats the config mapping,
// the mapping beats the repo default (observable in the request path).
func TestElevenLabsVoicePrecedence(t *testing.T) {
mk := func(t *testing.T, voices map[string]string, pin string) (*ElevenLabs, *recordedRequest) {
t.Helper()
ts, rec := fakeTTSServer(t, 200, fakeMP3)
@@ -159,46 +159,46 @@ func TestElevenLabsVoiceRangfolge(t *testing.T) {
return p, rec
}
t.Run("config-Mapping schlägt Default", func(t *testing.T) {
p, rec := mk(t, map[string]string{"de": "AusConfig"}, "")
if _, err := p.Synthesize(context.Background(), "Hallo", "de"); err != nil {
t.Run("config mapping beats default", func(t *testing.T) {
p, rec := mk(t, map[string]string{"de": "FromConfig"}, "")
if _, err := p.Synthesize(context.Background(), "Hello", "de"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/AusConfig"; rec.path != want {
t.Errorf("Pfad = %q, want %q", rec.path, want)
if want := "/v1/text-to-speech/FromConfig"; rec.path != want {
t.Errorf("path = %q, want %q", rec.path, want)
}
})
t.Run("Pin (--voice) schlägt config-Mapping", func(t *testing.T) {
p, rec := mk(t, map[string]string{"de": "AusConfig"}, "Gepinnt")
if _, err := p.Synthesize(context.Background(), "Hallo", "de"); err != nil {
t.Run("pin (--voice) beats config mapping", func(t *testing.T) {
p, rec := mk(t, map[string]string{"de": "FromConfig"}, "Pinned")
if _, err := p.Synthesize(context.Background(), "Hello", "de"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/Gepinnt"; rec.path != want {
t.Errorf("Pfad = %q, want %q", rec.path, want)
if want := "/v1/text-to-speech/Pinned"; rec.path != want {
t.Errorf("path = %q, want %q", rec.path, want)
}
})
t.Run("unbekannte Sprache fällt auf en-Default", func(t *testing.T) {
t.Run("unknown language falls back to en default", func(t *testing.T) {
p, rec := mk(t, nil, "")
if _, err := p.Synthesize(context.Background(), "Hallo", "fr"); err != nil {
if _, err := p.Synthesize(context.Background(), "Hello", "fr"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM"; rec.path != want {
t.Errorf("Pfad = %q, want en-Default %q", rec.path, want)
t.Errorf("path = %q, want en default %q", rec.path, want)
}
})
}
// TestElevenLabsLeererText ist ein klarer Fehler ohne Request.
func TestElevenLabsLeererText(t *testing.T) {
// TestElevenLabsEmptyText is a clear error without a request.
func TestElevenLabsEmptyText(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewElevenLabs(ChainConfig{Getenv: envMap(map[string]string{
EnvElevenLabsKey: "k",
EnvElevenLabsEndpoint: ts.URL,
})})
if _, err := p.Synthesize(context.Background(), "", "de"); err == nil {
t.Error("Synthesize(\"\") soll fehlschlagen")
t.Error("Synthesize(\"\") should fail")
}
if rec.method != "" {
t.Error("Bei leerem Text darf kein Request gehen")
t.Error("no request must be sent for empty text")
}
}
+19 -19
View File
@@ -9,30 +9,30 @@ import (
"time"
)
// cloudTimeout ist das Budget für einen einzigen Synthese-Call eines
// Cloud-Providers (Aufbau + Anfrage + vollständige Antwort). Ansagen sind
// kurz; nach Ablauf greift der Fallback auf die nächste Ketten-Stufe.
// cloudTimeout is the budget for a single synthesis call of a cloud
// provider (connect + request + complete response). Announcements are
// short; once it expires the chain falls back to the next tier.
const cloudTimeout = 45 * time.Second
// cloudClient ist der geteilte HTTP-Client der REST-Provider (mit Timeout,
// im Gegensatz zu http.DefaultClient). Bei Ausbleiben einer Antwort greift
// so der Ketten-Fallback statt einer Endlos-Blockade.
// cloudClient is the shared HTTP client of the REST providers (with a
// timeout, unlike http.DefaultClient). If a response never arrives, the
// chain falls back instead of blocking forever.
var cloudClient = &http.Client{Timeout: cloudTimeout}
// maxErrBody begrenzt, wie viel einer Fehler-Antwort in die Fehlermeldung
// übernommen wird (Diagnose ja, Spam nein).
// maxErrBody limits how much of an error response is copied into the error
// message (diagnosis yes, spam no).
const maxErrBody = 200
// postForBytes macht den gemeinsamen Kern der REST-Provider: POST von JSON
// an url mit Headern, erwartet 2xx und liefert die rohen Antwort-Bytes
// (Audio). Alles andere — non-2xx, Netz-Fehler, Timeouts, leere Antworten —
// ist ein klarer Fehler, an dem die Kette in die nächste Stufe fällt.
// postForBytes is the shared core of the REST providers: POST JSON to url
// with headers, expect 2xx and return the raw response bytes (audio).
// Everything else (non-2xx, network errors, timeouts, empty responses) is a
// clear error on which the chain falls back to the next tier.
func postForBytes(ctx context.Context, client *http.Client, providerName, url string, headers map[string]string, body []byte) ([]byte, error) {
if client == nil {
client = cloudClient
}
// Eigenes Zeit-Budget setzen, falls der Aufrufer keins mitgibt — die
// Kette soll auch ohne fremden Deadline-Kontext weiterfallen können.
// Apply our own time budget if the caller does not bring one; the chain
// must be able to fall through even without an external deadline.
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, cloudTimeout)
@@ -41,7 +41,7 @@ func postForBytes(ctx context.Context, client *http.Client, providerName, url st
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("%s: Anfrage an %s bauen: %w", providerName, url, err)
return nil, fmt.Errorf("%s: building request to %s: %w", providerName, url, err)
}
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
@@ -50,21 +50,21 @@ func postForBytes(ctx context.Context, client *http.Client, providerName, url st
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%s: Anfrage an %s fehlgeschlagen (Netz/Timeout?): %w", providerName, url, err)
return nil, fmt.Errorf("%s: request to %s failed (network/timeout?): %w", providerName, url, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrBody))
return nil, fmt.Errorf("%s: HTTP %d von %s: %s", providerName, resp.StatusCode, url, bytes.TrimSpace(snippet))
return nil, fmt.Errorf("%s: HTTP %d from %s: %s", providerName, resp.StatusCode, url, bytes.TrimSpace(snippet))
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%s: Antwort von %s lesen: %w", providerName, url, err)
return nil, fmt.Errorf("%s: reading response from %s: %w", providerName, url, err)
}
if len(data) == 0 {
return nil, fmt.Errorf("%s: leere Antwort von %s (0 Bytes Audio)", providerName, url)
return nil, fmt.Errorf("%s: empty response from %s (0 bytes of audio)", providerName, url)
}
return data, nil
}
+29 -28
View File
@@ -8,47 +8,48 @@ import (
"strings"
)
// OpenAI-TTS-Konstanten (öffentlicher Vertrag wie bei ElevenLabs).
// OpenAI TTS constants (public contract as with ElevenLabs).
const (
// DefaultEndpointOpenAI ist der produktive API-Endpunkt.
// DefaultEndpointOpenAI is the production API endpoint.
DefaultEndpointOpenAI = "https://api.openai.com"
// DefaultModelOpenAI: Entscheidungsnotiz aus dem Task-Brief — der
// Modell-String ist konfigurierbar ([providers.openai] model), Default
// "tts-1"; "gpt-4o-mini-tts" ist ein gültiger Alternativwert.
// DefaultModelOpenAI: decision note. The model string is configurable
// ([providers.openai] model), default "tts-1"; "gpt-4o-mini-tts" is a
// valid alternative.
DefaultModelOpenAI = "tts-1"
EnvOpenAIKey = "TALK_OPENAI_KEY"
EnvOpenAIEndpoint = "TALK_OPENAI_ENDPOINT"
)
// OpenAI ist die zweite Stufe der Kette (für Nutzer mit vorhandenem
// OpenAI-Key). Synthesize führt POST <endpoint>/v1/audio/speech aus —
// Bearer-Key, Body {model, voice, input, response_format: "mp3"} — und
// liefert die Antwort-Bytes unverändert zurück. Rückgabeformat: MP3.
// OpenAI is the second tier of the chain (for users with an existing OpenAI
// key). Synthesize performs POST <endpoint>/v1/audio/speech with a bearer
// key and body {model, voice, input, response_format: "mp3"} and returns
// the response bytes unchanged. Return format: MP3.
type OpenAI struct {
// Endpoint ist die Basis-URL (ohne Pfad); leer = Default beim Bau.
// Endpoint is the base URL (without path); empty = default at
// construction.
Endpoint string
// APIKey ist der OpenAI-Key (aus TALK_OPENAI_KEY).
// APIKey is the OpenAI key (from TALK_OPENAI_KEY).
APIKey string
// Model ist der TTS-Modell-String; leer = DefaultModelOpenAI.
// Model is the TTS model string; empty = DefaultModelOpenAI.
Model string
// Voices ist das config-Mapping voice.openai.<lang> = id; leer =
// neutrale Repo-Defaults (alloy, siehe voices.go).
// Voices is the config mapping voice.openai.<lang> = id; empty =
// neutral repo defaults (alloy, see voices.go).
Voices map[string]string
// Client ist der HTTP-Client; nil = geteilter Client mit Timeout.
// Client is the HTTP client; nil = shared client with timeout.
Client *http.Client
pinned string // --voice-Pin (VoicePinner)
pinned string // --voice pin (VoicePinner)
}
// NewOpenAI baut den Provider aus der Ketten-Konfiguration: Endpunkt aus
// TALK_OPENAI_ENDPOINT > [providers.openai].endpoint > Default; Modell aus
// [providers.openai].model; Key ausschließlich aus TALK_OPENAI_KEY.
// NewOpenAI builds the provider from the chain configuration: endpoint from
// TALK_OPENAI_ENDPOINT > [providers.openai].endpoint > default; model from
// [providers.openai].model; key exclusively from TALK_OPENAI_KEY.
func NewOpenAI(cfg ChainConfig) *OpenAI {
return &OpenAI{
Endpoint: endpointFrom(cfg, "openai", EnvOpenAIEndpoint, DefaultEndpointOpenAI),
@@ -58,19 +59,19 @@ func NewOpenAI(cfg ChainConfig) *OpenAI {
}
}
// Name implementiert Provider.
// Name implements Provider.
func (*OpenAI) Name() string { return "openai" }
// PinVoice implementiert VoicePinner (CLI-Flag --voice).
// PinVoice implements VoicePinner (CLI flag --voice).
func (p *OpenAI) PinVoice(id string) { p.pinned = id }
// Available implementiert Provider: Key und Endpunkt müssen gesetzt sein
// (ohne Netz-Call, siehe ElevenLabs.Available).
// Available implements Provider: key and endpoint must be set (without a
// network call, see ElevenLabs.Available).
func (p *OpenAI) Available(ctx context.Context) bool {
return p.APIKey != "" && p.Endpoint != ""
}
// openAIRequest ist der Anfrage-Body.
// openAIRequest is the request body.
type openAIRequest struct {
Model string `json:"model"`
Voice string `json:"voice"`
@@ -78,14 +79,14 @@ type openAIRequest struct {
ResponseFormat string `json:"response_format"`
}
// Synthesize implementiert Provider. Rückgabeformat: MP3.
// Synthesize implements Provider. Return format: MP3.
func (p *OpenAI) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("openai: leerer Text")
return nil, fmt.Errorf("openai: empty text")
}
voice := resolveVoice("openai", lang, p.pinned, p.Voices)
if voice == "" {
return nil, fmt.Errorf("openai: keine Stimme für Sprache %q konfiguriert", lang)
return nil, fmt.Errorf("openai: no voice configured for language %q", lang)
}
model := p.Model
if model == "" {
@@ -98,7 +99,7 @@ func (p *OpenAI) Synthesize(ctx context.Context, text, lang string) ([]byte, err
ResponseFormat: "mp3",
})
if err != nil {
return nil, fmt.Errorf("openai: Anfrage-Body bauen: %w", err)
return nil, fmt.Errorf("openai: building request body: %w", err)
}
url := strings.TrimRight(p.Endpoint, "/") + "/v1/audio/speech"
return postForBytes(ctx, p.Client, "openai", url,
+29 -29
View File
@@ -9,10 +9,10 @@ import (
"testing"
)
// TestOpenAISynthese200: 200 mit Bytes → Bytes unverändert; POST
// /v1/audio/speech, Bearer-Key, Body mit model/voice/input/response_format
// laut Task-Brief (Default-Modell tts-1, Default-Stimme alloy).
func TestOpenAISynthese200(t *testing.T) {
// TestOpenAISynthesize200: 200 with bytes → bytes unchanged; POST
// /v1/audio/speech, bearer key, body with model/voice/input/response_format
// per the API contract (default model tts-1, default voice alloy).
func TestOpenAISynthesize200(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewOpenAI(ChainConfig{
Getenv: envMap(map[string]string{
@@ -21,18 +21,18 @@ func TestOpenAISynthese200(t *testing.T) {
}),
})
data, err := p.Synthesize(context.Background(), "Build fertig", "en")
data, err := p.Synthesize(context.Background(), "Build finished", "en")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if string(data) != string(fakeMP3) {
t.Errorf("Synthesize = %q, want Fake-Bytes", data)
t.Errorf("Synthesize = %q, want fake bytes", data)
}
if rec.method != http.MethodPost {
t.Errorf("Methode = %s, want POST", rec.method)
t.Errorf("method = %s, want POST", rec.method)
}
if want := "/v1/audio/speech"; rec.path != want {
t.Errorf("Pfad = %q, want %q", rec.path, want)
t.Errorf("path = %q, want %q", rec.path, want)
}
if got := rec.header.Get("Authorization"); got != "Bearer sk-test" {
t.Errorf("Authorization = %q, want Bearer sk-test", got)
@@ -45,15 +45,15 @@ func TestOpenAISynthese200(t *testing.T) {
ResponseFormat string `json:"response_format"`
}
if err := json.Unmarshal(rec.body, &body); err != nil {
t.Fatalf("Body ist kein JSON: %v (%s)", err, rec.body)
t.Fatalf("body is not JSON: %v (%s)", err, rec.body)
}
if body.Model != "tts-1" {
t.Errorf("model = %q, want Default tts-1", body.Model)
t.Errorf("model = %q, want default tts-1", body.Model)
}
if body.Voice != "alloy" {
t.Errorf("voice = %q, want Default alloy", body.Voice)
t.Errorf("voice = %q, want default alloy", body.Voice)
}
if body.Input != "Build fertig" {
if body.Input != "Build finished" {
t.Errorf("input = %q", body.Input)
}
if body.ResponseFormat != "mp3" {
@@ -61,8 +61,8 @@ func TestOpenAISynthese200(t *testing.T) {
}
}
// TestOpenAIHTTPFehler: 500 und 401 sind klare Fehler mit Status.
func TestOpenAIHTTPFehler(t *testing.T) {
// TestOpenAIHTTPError: 500 and 401 are clear errors with the status.
func TestOpenAIHTTPError(t *testing.T) {
for _, status := range []int{500, 401} {
t.Run(http.StatusText(status), func(t *testing.T) {
ts, _ := fakeTTSServer(t, status, []byte("boom"))
@@ -70,21 +70,21 @@ func TestOpenAIHTTPFehler(t *testing.T) {
EnvOpenAIKey: "sk-test",
EnvOpenAIEndpoint: ts.URL,
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
_, err := p.Synthesize(context.Background(), "Hello", "de")
if err == nil || !strings.Contains(err.Error(), fmt.Sprintf("%d", status)) {
t.Fatalf("Synthesize-Fehler = %v, will HTTP %d", err, status)
t.Fatalf("Synthesize error = %v, want HTTP %d", err, status)
}
if !strings.Contains(err.Error(), "openai") {
t.Errorf("Fehler soll den Provider nennen: %v", err)
t.Errorf("error should name the provider: %v", err)
}
})
}
}
// TestOpenAIModelKonfigurierbar: [providers.openai].model ändert den
// Modell-String im Body (Entscheidungsnotiz: Default tts-1, Alternativen
// wie gpt-4o-mini-tts erlaubt).
func TestOpenAIModelKonfigurierbar(t *testing.T) {
// TestOpenAIModelConfigurable: [providers.openai].model changes the model
// string in the body (decision note: default tts-1, alternatives such as
// gpt-4o-mini-tts allowed).
func TestOpenAIModelConfigurable(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewOpenAI(ChainConfig{
Getenv: envMap(map[string]string{
@@ -93,7 +93,7 @@ func TestOpenAIModelKonfigurierbar(t *testing.T) {
}),
Model: "gpt-4o-mini-tts",
})
if _, err := p.Synthesize(context.Background(), "Hallo", "de"); err != nil {
if _, err := p.Synthesize(context.Background(), "Hello", "de"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
var body struct {
@@ -107,20 +107,20 @@ func TestOpenAIModelKonfigurierbar(t *testing.T) {
}
}
// TestOpenAIAvailable: Key entscheidet — ohne Netz-Call.
// TestOpenAIAvailable: the key decides; without a network call.
func TestOpenAIAvailable(t *testing.T) {
if NewOpenAI(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) {
t.Error("Available ohne Key = true, want false")
t.Error("Available without key = true, want false")
}
p := NewOpenAI(ChainConfig{Getenv: envMap(map[string]string{EnvOpenAIKey: "sk"})})
if !p.Available(context.Background()) {
t.Error("Available mit Key = false, want true")
t.Error("Available with key = false, want true")
}
}
// TestOpenAIVoiceRangfolge: Pin schlägt Mapping schlägt Default.
func TestOpenAIVoiceRangfolge(t *testing.T) {
t.Run("Mapping schlägt Default", func(t *testing.T) {
// TestOpenAIVoicePrecedence: pin beats mapping beats default.
func TestOpenAIVoicePrecedence(t *testing.T) {
t.Run("mapping beats default", func(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewOpenAI(ChainConfig{
Getenv: envMap(map[string]string{
@@ -140,7 +140,7 @@ func TestOpenAIVoiceRangfolge(t *testing.T) {
t.Errorf("voice = %q, want nova (config)", body.Voice)
}
})
t.Run("Pin schlägt Mapping", func(t *testing.T) {
t.Run("pin beats mapping", func(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewOpenAI(ChainConfig{
Getenv: envMap(map[string]string{
+69 -69
View File
@@ -13,35 +13,36 @@ import (
"unicode/utf16"
)
// OSBuiltin spricht über die im Betriebssystem eingebaute TTS-Runtime.
// Es ist der Notnagel-Provider der Kette: überall vorhanden, ohne Key und
// ohne Netz — aber die einfachste Stimme.
// OSBuiltin speaks through the TTS runtime built into the operating system.
// It is the chain's provider of last resort: present everywhere, without a
// key and without network, but with the plainest voice.
//
// Plattform-Mechanismen (Auswahl zur Laufzeit über runtime.GOOS, damit eine
// Binärversion alle Ziele kompiliert; jede Route ist reines exec ohne Shell):
// Platform mechanisms (selected at runtime via runtime.GOOS so that one
// binary compiles for all targets; every route is pure exec without a
// shell):
//
// Linux: Synthesize über espeak/espeak-ng (--stdout → WAV-Bytes).
// Fehlt beides, liefert Synthesize ErrNoByteOutput und
// SpeakDirect spielt über spd-say (speech-dispatcher) ab —
// spd-say kann Audio nach eigener --help-Ausgabe nur selbst über
// den Daemon ausgeben, nicht in eine Datei/in einen Prozess
// schreiben (verifiziert am Dev-Host 2026-10-18).
// macOS: Synthesize über say (-o temporäre WAV-Datei,
// --data-format=LEI16@22050); Stimmen de→Anna, en→Samantha
// (vorinstalliert; ersetzt durch das Voice-Mapping in Task 03).
// Windows: Synthesize über PowerShell System.Speech in temporäre WAV-
// Datei (SetOutputToWaveFile); Sprache via SelectVoiceByHints
// mit Fallback auf die Systemstimme.
// Linux: Synthesize via espeak/espeak-ng (--stdout → WAV bytes).
// If both are missing, Synthesize returns ErrNoByteOutput and
// SpeakDirect plays via spd-say (speech-dispatcher); according
// to its own --help output spd-say can only emit audio itself
// through the daemon, not write it to a file or a process
// (verified on the dev host 2026-10-18).
// macOS: Synthesize via say (-o temporary WAV file,
// --data-format=LEI16@22050); voices de→Anna, en→Samantha
// (preinstalled).
// Windows: Synthesize via PowerShell System.Speech into a temporary WAV
// file (SetOutputToWaveFile); language via SelectVoiceByHints
// with fallback to the system voice.
//
// Fehlende Binaries/Runtimes führen zu Available()==false bzw. klaren
// Fehlern — nie zu Panics.
// Missing binaries/runtimes lead to Available()==false or clear errors,
// never to panics.
type OSBuiltin struct{}
// Name implementiert Provider.
// Name implements Provider.
func (OSBuiltin) Name() string { return "osbuiltin" }
// Available implementiert Provider: true, wenn mindestens ein
// Plattform-Mechanismus vorhanden ist.
// Available implements Provider: true if at least one platform mechanism is
// present.
func (OSBuiltin) Available(ctx context.Context) bool {
switch runtime.GOOS {
case "darwin":
@@ -50,7 +51,7 @@ func (OSBuiltin) Available(ctx context.Context) bool {
case "windows":
_, err := exec.LookPath("powershell")
return err == nil
default: // linux und sonstige unixartige Systeme
default: // linux and other unix-like systems
for _, bin := range []string{"espeak", "espeak-ng", "spd-say"} {
if _, err := exec.LookPath(bin); err == nil {
return true
@@ -60,40 +61,40 @@ func (OSBuiltin) Available(ctx context.Context) bool {
}
}
// Describe nennt den gefundenen Plattform-Mechanismus für die Diagnose
// (`talk doctor`): z. B. „espeak-ng (Audio-Bytes)", „spd-say (nur
// Direktansage; espeak-ng für Audio-Bytes fehlt)", „say", „powershell
// (SAPI)" — oder, was fehlt.
// Describe names the platform mechanism found, for diagnosis
// (`talk doctor`): e.g. "espeak-ng (audio bytes)", "spd-say (direct speech
// only; install espeak-ng for audio bytes)", "say", "powershell (SAPI)",
// or what is missing.
func (OSBuiltin) Describe() string {
switch runtime.GOOS {
case "darwin":
if _, err := exec.LookPath("say"); err == nil {
return "say (macOS)"
}
return "say nicht gefunden"
return "say not found"
case "windows":
if _, err := exec.LookPath("powershell"); err == nil {
return "powershell (SAPI System.Speech)"
}
return "powershell nicht gefunden"
return "powershell not found"
default:
for _, bin := range []string{"espeak-ng", "espeak"} {
if _, err := exec.LookPath(bin); err == nil {
return bin + " (Audio-Bytes)"
return bin + " (audio bytes)"
}
}
if _, err := exec.LookPath("spd-say"); err == nil {
return "spd-say (nur Direktansage; espeak-ng für Audio-Bytes fehlt)"
return "spd-say (direct speech only; install espeak-ng for audio bytes)"
}
return "weder espeak-ng/espeak noch spd-say gefunden (espeak-ng installieren)"
return "neither espeak-ng/espeak nor spd-say found (install espeak-ng)"
}
}
// Synthesize implementiert Provider. Rückgabeformat: RIFF/WAVE (PCM) —
// abspielbar über jeden Sink des audio-Pakets.
// Synthesize implements Provider. Return format: RIFF/WAVE (PCM), playable
// through every sink of the audio package.
func (p OSBuiltin) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("osbuiltin: leerer Text")
return nil, fmt.Errorf("osbuiltin: empty text")
}
switch runtime.GOOS {
case "darwin":
@@ -105,31 +106,30 @@ func (p OSBuiltin) Synthesize(ctx context.Context, text, lang string) ([]byte, e
}
}
// SpeakDirect implementiert DirectSpeaker: direkte, blockierende Ausgabe
// über das OS-TTS mit eigener Wiedergabe. Notwendig auf Linux ohne espeak;
// auf macOS/Windows nicht erforderlich (Synthesize liefert WAV) und ist
// dort ein klarer Fehler.
// SpeakDirect implements DirectSpeaker: direct, blocking output through the
// OS TTS with its own playback. Required on Linux without espeak; not
// needed on macOS/Windows (Synthesize returns WAV) and a clear error there.
func (p OSBuiltin) SpeakDirect(ctx context.Context, text, lang string) error {
if text == "" {
return fmt.Errorf("osbuiltin: leerer Text")
return fmt.Errorf("osbuiltin: empty text")
}
switch runtime.GOOS {
case "darwin", "windows":
return fmt.Errorf("osbuiltin: SpeakDirect auf %s nicht nötig — Synthesize liefert WAV; über einen Sink abspielen", runtime.GOOS)
return fmt.Errorf("osbuiltin: SpeakDirect not needed on %s; Synthesize returns WAV, play it through a sink", runtime.GOOS)
default:
if _, err := exec.LookPath("spd-say"); err != nil {
return fmt.Errorf("osbuiltin: spd-say nicht gefunden (speech-dispatcher installieren oder espeak für Audio-Bytes): %w", err)
return fmt.Errorf("osbuiltin: spd-say not found (install speech-dispatcher, or espeak for audio bytes): %w", err)
}
cmd := exec.CommandContext(ctx, "spd-say", "-w", "-l", lang, text)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("osbuiltin: spd-say fehlgeschlagen: %w: %s", err, bytes.TrimSpace(out))
return fmt.Errorf("osbuiltin: spd-say failed: %w: %s", err, bytes.TrimSpace(out))
}
return nil
}
}
// synthEspeak versucht espeak, dann espeak-ng. Beide schreiben WAV
// (RIFF/PCM) auf stdout.
// synthEspeak tries espeak, then espeak-ng. Both write WAV (RIFF/PCM) to
// stdout.
func (OSBuiltin) synthEspeak(ctx context.Context, text, lang string) ([]byte, error) {
for _, bin := range []string{"espeak", "espeak-ng"} {
if _, err := exec.LookPath(bin); err != nil {
@@ -138,26 +138,26 @@ func (OSBuiltin) synthEspeak(ctx context.Context, text, lang string) ([]byte, er
cmd := exec.CommandContext(ctx, bin, "-v", lang, "--stdout", text)
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("osbuiltin: %s fehlgeschlagen: %w", bin, withStderr(err))
return nil, fmt.Errorf("osbuiltin: %s failed: %w", bin, withStderr(err))
}
if len(out) == 0 {
return nil, fmt.Errorf("osbuiltin: %s lieferte keine Audiodaten", bin)
return nil, fmt.Errorf("osbuiltin: %s returned no audio data", bin)
}
return out, nil
}
return nil, fmt.Errorf("osbuiltin: unter %s kein espeak/espeak-ng für Audio-Bytes gefunden: %w", runtime.GOOS, ErrNoByteOutput)
return nil, fmt.Errorf("osbuiltin: no espeak/espeak-ng for audio bytes found on %s: %w", runtime.GOOS, ErrNoByteOutput)
}
// synthSay erzeugt WAV über das macOS-say-Kommando in einer temporären
// Datei. --data-format=LEI16@22050 erzwingt 16-Bit-PCM; die Endung .wav
// den WAVE-Container.
// synthSay produces WAV via the macOS say command into a temporary file.
// --data-format=LEI16@22050 forces 16-bit PCM; the .wav extension forces
// the WAVE container.
func (OSBuiltin) synthSay(ctx context.Context, text, lang string) ([]byte, error) {
if _, err := exec.LookPath("say"); err != nil {
return nil, fmt.Errorf("osbuiltin: say nicht gefunden: %w", err)
return nil, fmt.Errorf("osbuiltin: say not found: %w", err)
}
tmp, err := os.CreateTemp("", "talk-say-*.wav")
if err != nil {
return nil, fmt.Errorf("osbuiltin: temporäre Datei: %w", err)
return nil, fmt.Errorf("osbuiltin: temporary file: %w", err)
}
name := tmp.Name()
tmp.Close()
@@ -166,16 +166,16 @@ func (OSBuiltin) synthSay(ctx context.Context, text, lang string) ([]byte, error
cmd := exec.CommandContext(ctx, "say", "-v", sayVoice(lang),
"--data-format=LEI16@22050", "-o", name, text)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("osbuiltin: say fehlgeschlagen: %w: %s", err, bytes.TrimSpace(out))
return nil, fmt.Errorf("osbuiltin: say failed: %w: %s", err, bytes.TrimSpace(out))
}
data, err := os.ReadFile(name)
if err != nil {
return nil, fmt.Errorf("osbuiltin: say-Ausgabe lesen: %w", err)
return nil, fmt.Errorf("osbuiltin: reading say output: %w", err)
}
return data, nil
}
// sayVoice mappt Sprachen auf vorinstallierte macOS-Stimmen.
// sayVoice maps languages to preinstalled macOS voices.
func sayVoice(lang string) string {
if lang == "en" {
return "Samantha" // en-US
@@ -183,18 +183,18 @@ func sayVoice(lang string) string {
return "Anna" // de-DE
}
// synthSAPI erzeugt WAV über die Windows-SAPI (System.Speech) per
// PowerShell. Das Skript wird als -EncodedCommand (Base64/UTF-16LE)
// übergeben und der Text darin base64-dekodiert übergeben — dadurch gibt
// es kein Quoting-Problem mit Sonderzeichen. Ist die Wunschsprache nicht
// installiert, fällt SAPI auf die Systemstimme zurück (try/catch).
// synthSAPI produces WAV via the Windows SAPI (System.Speech) through
// PowerShell. The script is passed as -EncodedCommand (Base64/UTF-16LE) and
// the text inside it is passed base64-decoded, so there is no quoting
// problem with special characters. If the requested language is not
// installed, SAPI falls back to the system voice (try/catch).
func (OSBuiltin) synthSAPI(ctx context.Context, text, lang string) ([]byte, error) {
if _, err := exec.LookPath("powershell"); err != nil {
return nil, fmt.Errorf("osbuiltin: powershell nicht gefunden: %w", err)
return nil, fmt.Errorf("osbuiltin: powershell not found: %w", err)
}
tmp, err := os.CreateTemp("", "talk-sapi-*.wav")
if err != nil {
return nil, fmt.Errorf("osbuiltin: temporäre Datei: %w", err)
return nil, fmt.Errorf("osbuiltin: temporary file: %w", err)
}
name := tmp.Name()
tmp.Close()
@@ -214,16 +214,16 @@ $s.Dispose()
cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-STA", "-EncodedCommand", psEncode(script))
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("osbuiltin: SAPI fehlgeschlagen: %w: %s", err, bytes.TrimSpace(out))
return nil, fmt.Errorf("osbuiltin: SAPI failed: %w: %s", err, bytes.TrimSpace(out))
}
data, err := os.ReadFile(name)
if err != nil {
return nil, fmt.Errorf("osbuiltin: SAPI-Ausgabe lesen: %w", err)
return nil, fmt.Errorf("osbuiltin: reading SAPI output: %w", err)
}
return data, nil
}
// sapiCulture mappt Sprachen auf SAPI-Kulturbezeichner.
// sapiCulture maps languages to SAPI culture identifiers.
func sapiCulture(lang string) string {
if lang == "en" {
return "en-US"
@@ -231,7 +231,7 @@ func sapiCulture(lang string) string {
return "de-DE"
}
// psEncode kodiert ein PowerShell-Skript für -EncodedCommand
// psEncode encodes a PowerShell script for -EncodedCommand
// (UTF-16LE + Base64).
func psEncode(script string) string {
runes := utf16.Encode([]rune(script))
@@ -242,8 +242,8 @@ func psEncode(script string) string {
return base64.StdEncoding.EncodeToString(buf)
}
// withStderr reichert einen exec-Fehler um die gesammelte stderr-Ausgabe
// des Prozesses an, wenn vorhanden.
// withStderr enriches an exec error with the process's collected stderr
// output, if any.
func withStderr(err error) error {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 {
+65 -65
View File
@@ -10,9 +10,9 @@ import (
"testing"
)
// fakeBin schreibt ein ausführbares Shell-Skript namens name in dir, das
// body ausführt. Damit werden die externen Prozesse des Providers an der
// echten Prozessgrenze gefaked (PATH-Manipulation, kein Mock-Framework).
// fakeBin writes an executable shell script called name into dir that runs
// body. This fakes the provider's external processes at the real process
// boundary (PATH manipulation, no mock framework).
func fakeBin(t *testing.T, dir, name, body string) {
t.Helper()
script := "#!/bin/sh\n" + body + "\n"
@@ -22,8 +22,8 @@ func fakeBin(t *testing.T, dir, name, body string) {
}
}
// emptyPATH setzt PATH auf ein Verzeichnis ohne einzige Binärdatei —
// kein espeak, kein spd-say.
// emptyPATH sets PATH to a directory without a single binary: no espeak,
// no spd-say.
func emptyPATH(t *testing.T) string {
t.Helper()
dir := t.TempDir()
@@ -31,15 +31,15 @@ func emptyPATH(t *testing.T) string {
return dir
}
// prependPATH stellt dir vor den bestehenden PATH: Binaries darin
// überschatten System-Binaries gleichen Namens (fake espeak vor einem
// echten), während Hilfsprogramme wie cat weiterhin erreichbar bleiben.
// prependPATH puts dir in front of the existing PATH: binaries in it shadow
// system binaries of the same name (fake espeak before a real one) while
// helpers such as cat stay reachable.
func prependPATH(t *testing.T, dir string) {
t.Helper()
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
}
// TestOSBuiltinName und TestNewRegistry pinnen die Registry-Sicht.
// TestOSBuiltinName and TestNewRegistry pin the registry view.
func TestOSBuiltinName(t *testing.T) {
if got := (OSBuiltin{}).Name(); got != "osbuiltin" {
t.Errorf("Name() = %q, want %q", got, "osbuiltin")
@@ -56,7 +56,7 @@ func TestNewRegistry(t *testing.T) {
t.Errorf("New(%q).Name() = %q", name, p.Name())
}
}
// Task 03: die Cloud-Provider stehen in der Registry.
// The cloud providers are in the registry.
for _, name := range []string{"elevenlabs", "openai", "edge"} {
p, err := New(name, ChainConfig{})
if err != nil {
@@ -66,38 +66,38 @@ func TestNewRegistry(t *testing.T) {
t.Errorf("New(%q).Name() = %q", name, p.Name())
}
}
if _, err := New("gibtsnicht", ChainConfig{}); err == nil {
t.Error("New(\"gibtsnicht\") soll fehlschlagen")
if _, err := New("doesnotexist", ChainConfig{}); err == nil {
t.Error("New(\"doesnotexist\") should fail")
} else if !strings.Contains(err.Error(), "osbuiltin") || !strings.Contains(err.Error(), "elevenlabs") {
t.Errorf("New-Fehler soll verfügbare Provider nennen: %v", err)
t.Errorf("New error should name the available providers: %v", err)
}
}
// TestAvailableOhneTTS: leere PATH-Umgebung → Available false.
func TestAvailableOhneTTS(t *testing.T) {
// TestAvailableWithoutTTS: empty PATH environment → Available false.
func TestAvailableWithoutTTS(t *testing.T) {
emptyPATH(t)
if (OSBuiltin{}).Available(context.Background()) {
t.Error("Available() = true ohne jedes TTS-Binary, want false")
t.Error("Available() = true without any TTS binary, want false")
}
}
// TestSynthesizeOhneTTS: fehlt byte-fähiges TTS, muss der Fehler
// ErrNoByteOutput sein (kein Panik, kein anderer Fehlertyp).
func TestSynthesizeOhneTTS(t *testing.T) {
// TestSynthesizeWithoutTTS: if byte-capable TTS is missing, the error must
// be ErrNoByteOutput (no panic, no other error type).
func TestSynthesizeWithoutTTS(t *testing.T) {
emptyPATH(t)
_, err := (OSBuiltin{}).Synthesize(context.Background(), "Hallo", "de")
_, err := (OSBuiltin{}).Synthesize(context.Background(), "Hello", "de")
if err == nil {
t.Fatal("Synthesize ohne TTS: kein Fehler")
t.Fatal("Synthesize without TTS: no error")
}
if !errors.Is(err, ErrNoByteOutput) {
t.Fatalf("Synthesize-Fehler = %v, want ErrNoByteOutput", err)
t.Fatalf("Synthesize error = %v, want ErrNoByteOutput", err)
}
}
// TestSynthesizeMitFakeEspeak: ein gefaktes espeak liefert WAV-Bytes auf
// stdout; Synthesize muss genau diese Bytes durchreichen und die Stimme
// (-v <lang>) sowie den Text übergeben bekommen haben.
func TestSynthesizeMitFakeEspeak(t *testing.T) {
// TestSynthesizeWithFakeEspeak: a faked espeak returns WAV bytes on stdout;
// Synthesize must pass exactly these bytes through and must have received
// the voice (-v <lang>) and the text.
func TestSynthesizeWithFakeEspeak(t *testing.T) {
dir := t.TempDir()
wav := filepath.Join(dir, "probe.wav")
if err := os.WriteFile(wav, minimalWAV, 0o644); err != nil {
@@ -108,95 +108,95 @@ func TestSynthesizeMitFakeEspeak(t *testing.T) {
cat "`+wav+`"`)
prependPATH(t, dir)
data, err := (OSBuiltin{}).Synthesize(context.Background(), "Hallo Welt", "de")
data, err := (OSBuiltin{}).Synthesize(context.Background(), "Hello world", "de")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if string(data) != string(minimalWAV) {
t.Errorf("Synthesize lieferte %d Bytes, want die WAV-Bytes des Fake-espeak (%d)", len(data), len(minimalWAV))
t.Errorf("Synthesize returned %d bytes, want the WAV bytes of the fake espeak (%d)", len(data), len(minimalWAV))
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("Fake-espeak wurde nicht aufgerufen: %v", err)
t.Fatalf("fake espeak was not called: %v", err)
}
if got := string(args); got != "-v de --stdout Hallo Welt\n" {
t.Errorf("Fake-espeak ARGS = %q, want %q", got, "-v de --stdout Hallo Welt\n")
if got := string(args); got != "-v de --stdout Hello world\n" {
t.Errorf("fake espeak ARGS = %q, want %q", got, "-v de --stdout Hello world\n")
}
if !(OSBuiltin{}).Available(context.Background()) {
t.Error("Available() = false trotz espeak im PATH")
t.Error("Available() = false despite espeak in PATH")
}
}
// TestSynthesizeLeererText ist ein klarer Fehler.
func TestSynthesizeLeererText(t *testing.T) {
// TestSynthesizeEmptyText is a clear error.
func TestSynthesizeEmptyText(t *testing.T) {
if _, err := (OSBuiltin{}).Synthesize(context.Background(), "", "de"); err == nil {
t.Error("Synthesize(\"\") soll fehlschlagen")
t.Error("Synthesize(\"\") should fail")
}
}
// TestSynthesizeFehlgeschlagenesEspeak: espeak crasht (exit 1) → klarer
// Fehler, kein ErrNoByteOutput.
func TestSynthesizeFehlgeschlagenesEspeak(t *testing.T) {
// TestSynthesizeFailingEspeak: espeak crashes (exit 1) → clear error, not
// ErrNoByteOutput.
func TestSynthesizeFailingEspeak(t *testing.T) {
dir := emptyPATH(t)
fakeBin(t, dir, "espeak", `echo "boom" >&2; exit 1`)
_, err := (OSBuiltin{}).Synthesize(context.Background(), "Hallo", "de")
_, err := (OSBuiltin{}).Synthesize(context.Background(), "Hello", "de")
if err == nil {
t.Fatal("Synthesize mit crashendem espeak: kein Fehler")
t.Fatal("Synthesize with crashing espeak: no error")
}
if errors.Is(err, ErrNoByteOutput) {
t.Errorf("Crashender espeak soll kein ErrNoByteOutput sein: %v", err)
t.Errorf("crashing espeak must not be ErrNoByteOutput: %v", err)
}
if !strings.Contains(err.Error(), "boom") {
t.Errorf("Fehler soll stderr des Prozesses enthalten: %v", err)
t.Errorf("error should contain the process stderr: %v", err)
}
}
// TestSpeakDirectMitFakeSpdSay: SpeakDirect ruft spd-say blockierend
// (-w) mit Sprache (-l <lang>) und Text auf.
func TestSpeakDirectMitFakeSpdSay(t *testing.T) {
// TestSpeakDirectWithFakeSpdSay: SpeakDirect calls spd-say blocking (-w)
// with language (-l <lang>) and text.
func TestSpeakDirectWithFakeSpdSay(t *testing.T) {
dir := emptyPATH(t)
seen := filepath.Join(dir, "seen.txt")
fakeBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
prependPATH(t, dir)
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hallo Welt", "de"); err != nil {
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hello world", "de"); err != nil {
t.Fatalf("SpeakDirect: %v", err)
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("Fake-spd-say wurde nicht aufgerufen: %v", err)
t.Fatalf("fake spd-say was not called: %v", err)
}
if got := string(args); got != "-w -l de Hallo Welt\n" {
t.Errorf("Fake-spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt\n")
if got := string(args); got != "-w -l de Hello world\n" {
t.Errorf("fake spd-say ARGS = %q, want %q", got, "-w -l de Hello world\n")
}
}
// TestSpeakDirectOhneSpdSay ist ein klarer Fehler.
func TestSpeakDirectOhneSpdSay(t *testing.T) {
// TestSpeakDirectWithoutSpdSay is a clear error.
func TestSpeakDirectWithoutSpdSay(t *testing.T) {
emptyPATH(t)
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hallo", "de"); err == nil {
t.Error("SpeakDirect ohne spd-say soll fehlschlagen")
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hello", "de"); err == nil {
t.Error("SpeakDirect without spd-say should fail")
}
}
// TestSpeakDirectEchtesSpdSay läuft nur, wenn TALK_TEST_REAL_SPD=1 gesetzt
// ist (erzeugt echte, hörbare Ausgabe über speech-dispatcher — einmalige
// manuelle Abnahme; Standard-Testläufe bleiben stumm).
func TestSpeakDirectEchtesSpdSay(t *testing.T) {
// TestSpeakDirectRealSpdSay runs only when TALK_TEST_REAL_SPD=1 is set (it
// produces real, audible output through speech-dispatcher; one-off manual
// acceptance, standard test runs stay silent).
func TestSpeakDirectRealSpdSay(t *testing.T) {
if os.Getenv("TALK_TEST_REAL_SPD") != "1" {
t.Skip("Übersprungen (hörbar) — mit TALK_TEST_REAL_SPD=1 ausführen")
t.Skip("skipped (audible); run with TALK_TEST_REAL_SPD=1")
}
if _, err := exec.LookPath("spd-say"); err != nil {
t.Skip("spd-say nicht installiert")
t.Skip("spd-say not installed")
}
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Sprachtest über speech dispatcher", "de"); err != nil {
t.Fatalf("SpeakDirect gegen echtes spd-say: %v", err)
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Speech test via speech dispatcher", "de"); err != nil {
t.Fatalf("SpeakDirect against real spd-say: %v", err)
}
}
// minimalWAV ist eine minimal gültige RIFF/WAVE-Datei (PCM 16 Bit, mono,
// 8000 Hz) mit vier Samples — handgebaut nach der RIFF-Spezifikation,
// unabhängig vom Parser im audio-Paket.
// minimalWAV is a minimal valid RIFF/WAVE file (PCM 16 bit, mono, 8000 Hz)
// with four samples, hand-built per the RIFF specification, independent of
// the parser in the audio package.
var minimalWAV = []byte{
'R', 'I', 'F', 'F', 44, 0, 0, 0, // ChunkSize = 4 + (8+16) + (8+8)
'W', 'A', 'V', 'E',
@@ -208,5 +208,5 @@ var minimalWAV = []byte{
2, 0, // BlockAlign
16, 0, // Bits
'd', 'a', 't', 'a', 8, 0, 0, 0,
0, 0, 0x10, 0x7F, 0xF0, 0x80, 0x00, 0x40, // 4 Samples
0, 0, 0x10, 0x7F, 0xF0, 0x80, 0x00, 0x40, // 4 samples
}
+45 -46
View File
@@ -8,69 +8,68 @@ import (
"strings"
)
// DefaultName ist der Provider, den New("") liefert (Einzelkonstruktion,
// z. B. für doctor). Die Default-Ansage-Kette ab Task 03 steht in
// DefaultOrder (chain.go): elevenlabs → openai → edge → osbuiltin.
// 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 meldet, dass die Plattform keine über Synthesize
// beziehbaren Audio-Bytes liefern kann — konkret Linux mit speech-dispatcher
// (spd-say) ohne espeak: spd-say kann Audio nur selbst über den Daemon
// abspielen, nicht in einen Prozess schreiben. Aufrufer sollen auf
// DirectSpeaker ausweichen (siehe dort).
// 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(
"diese Plattform kann keine Audio-Bytes liefern (Linux ohne espeak; spd-say spielt nur direkt)")
"this platform cannot provide audio bytes (Linux without espeak; spd-say only speaks directly)")
// Provider ist der Vertrag aller TTS-Backends von talk.
// Provider is the contract of all talk TTS backends.
//
// Synthesize liefert dekodierbare Audio-Bytes; das Format ist von der
// Implementierung dokumentiert: osbuiltin liefert RIFF/WAVE (PCM), die
// Cloud-Provider elevenlabs, openai und edge liefern MP3 — beides wird vom
// audio-Paket (oto-Sink) verstanden.
// 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 liefert den eindeutigen Providernamen (CLI --provider, Config).
// Name returns the unique provider name (CLI --provider, config).
Name() string
// Available meldet, ob der Provider auf diesem System überhaupt nutzbar
// ist (z. B. Binary oder Key vorhanden). Muss billig und ohne Neben-
// wirkungen sein; false heißt: Kette soll überspringen werden.
// 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 erzeugt Audio-Bytes für text in der Sprache lang.
// Synthesize produces audio bytes for text in language lang.
Synthesize(ctx context.Context, text, lang string) ([]byte, error)
}
// DirectSpeaker ist eine dokumentierte optionale Erweiterung über das
// Provider-Interface hinaus (kein Bestandteil des Basisvertrags): Provider,
// die Text ohne Umweg über Synthesize-Bytes selbst hörbar ausgeben können.
// Notwendig auf Linux-Systemen, deren OS-TTS (speech-dispatcher) keine
// Audio-Bytes an Prozesse liefern kann — dort spielt osbuiltin.SpeakDirect
// über spd-say selbst ab (blockierend bis zum Ende der Ansage).
// 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 der Kette (chain.go) ist SpeakDirect die letzte Stufe, wenn kein
// Provider Bytes liefern kann; Chain implementiert DirectSpeaker selbst und
// reicht an den Stufen-Provider durch.
// 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 gibt text in der Sprache lang direkt über das OS-TTS aus
// und blockiert bis zum Ende der Wiedergabe (oder bis ctx abgebrochen
// wird).
// 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 ist eine dokumentierte optionale Erweiterung über das
// Provider-Interface hinaus: Provider, die eine explizite Voice-ID pinnen
// können (das CLI-Flag --voice). Die gepinnte Stimme schlägt das
// voice.<provider>.<lang>-Mapping der config.toml und die eingebauten
// neutralen Defaults. Die Kette ruft PinVoice beim Bau auf alle Stufen auf,
// die die Erweiterung implementieren (Stufen ohne Voice-Begriff, z. B.
// osbuiltin, loggen einen Hinweis statt still zu ignorieren).
// 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 bildet Providernamen auf Konstruktoren; NewChain und New bauen
// darüber. Die Konstruktoren erhalten die ChainConfig (Endpunkte, Voices,
// Keys via Getenv), damit Kette und Daemon (Task 04) dieselbe Quelle haben.
// 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) },
@@ -78,7 +77,7 @@ var registry = map[string]func(ChainConfig) Provider{
"osbuiltin": func(ChainConfig) Provider { return OSBuiltin{} },
}
// Names liefert die registrierten Providernamen (sortiert).
// Names returns the registered provider names (sorted).
func Names() []string {
names := make([]string, 0, len(registry))
for name := range registry {
@@ -88,16 +87,16 @@ func Names() []string {
return names
}
// New liefert den Provider mit dem angegebenen Namen (Einzelkonstruktion);
// "" wählt DefaultName. Für die Ansage-Kette (Fallback) NewChain verwenden.
// Unbekannte Namen sind ein klarer Fehler.
// 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("unbekannter oder noch nicht implementierter Provider %q (verfügbar: %s)",
return nil, fmt.Errorf("unknown provider %q (available: %s)",
name, strings.Join(Names(), ", "))
}
if cfg.Getenv == nil {
+21 -22
View File
@@ -2,28 +2,27 @@ package provider
import "sort"
// voiceDefaults sind die neutralen Repo-Defaults je Provider und Sprache
// (Teil des öffentlichen Vertrags: `talk voices` aus Task 08 listet sie,
// überschrieben werden sie durch voice.<provider>.<lang> in der config.toml
// oder das CLI-Flag --voice).
// voiceDefaults are the neutral repo defaults per provider and language
// (part of the public contract: `talk voices` lists them; they are
// overridden by voice.<provider>.<lang> in config.toml or the CLI flag
// --voice).
//
// Entscheidungsnotiz ElevenLabs-Stimme: Spec und Plan führen die persönliche
// Stimme KXxZd16DiBqt82nbarJx ausdrücklich als PRIVATEN Default des Nutzers
// („nicht Repo-Default", Spec Further Notes); der Repo-Default ist deshalb
// die neutrale, mehrsprachige Standardstimme „Rachel" (21m00Tcm4TlvDq8ikWAM
// aus dem öffentlichen ElevenLabs-Katalog). Die persönliche Stimme gehört in
// die eigene config.toml:
// Decision note on the ElevenLabs voice: spec and plan explicitly treat the
// personal voice KXxZd16DiBqt82nbarJx as the user's PRIVATE default ("not a
// repo default", spec Further Notes); the repo default is therefore the
// neutral, multilingual standard voice "Rachel" (21m00Tcm4TlvDq8ikWAM from
// the public ElevenLabs catalog). The personal voice belongs in one's own
// config.toml:
//
// [voice.elevenlabs]
// de = "KXxZd16DiBqt82nbarJx"
// en = "KXxZd16DiBqt82nbarJx"
//
// OpenAI „alloy" und die Edge-Neural-Stimmen (de-DE-KatjaNeural,
// en-US-AriaNeural) sind die jeweils neutralen Standardstimmen der APIs
// (Edge-Stimmen sind im Task-Brief vorgegeben).
// OpenAI "alloy" and the Edge neural voices (de-DE-KatjaNeural,
// en-US-AriaNeural) are the respective neutral standard voices of the APIs.
var voiceDefaults = map[string]map[string]string{
"elevenlabs": {
"de": "21m00Tcm4TlvDq8ikWAM", // „Rachel", multilingual
"de": "21m00Tcm4TlvDq8ikWAM", // "Rachel", multilingual
"en": "21m00Tcm4TlvDq8ikWAM",
},
"openai": {
@@ -35,19 +34,19 @@ var voiceDefaults = map[string]map[string]string{
"en": "en-US-AriaNeural",
},
"osbuiltin": {
// osbuiltin mappt intern selbst (macOS Anna/Samantha, SAPI-Kultur,
// espeak -v <lang>) und kennt keine Voice-IDs — hier nur der
// Vollständigkeit wegen dokumentiert.
// osbuiltin maps internally on its own (macOS Anna/Samantha, SAPI
// culture, espeak -v <lang>) and has no voice IDs; documented here
// only for completeness.
"de": "",
"en": "",
},
}
// resolveVoice liefert die Voice-ID für einen Provider und eine Sprache.
// Rangfolge: override (CLI-Flag --voice bzw. PinVoice) > Mapping aus der
// config.toml > neutrales Repo-Default > en-Default > alphabetisch erstes
// Default. Ein leerer Rückgabewert heißt: der Provider kennt für diese
// Sprache keine Stimme (osbuiltin) und entscheidet selbst.
// resolveVoice returns the voice ID for a provider and a language.
// Precedence: override (CLI flag --voice or PinVoice) > mapping from
// config.toml > neutral repo default > en default > alphabetically first
// default. An empty result means: the provider has no voice for this
// language (osbuiltin) and decides on its own.
func resolveVoice(name, lang, override string, cfgVoices map[string]string) string {
if override != "" {
return override
+11 -11
View File
@@ -2,9 +2,9 @@ package provider
import "testing"
// TestResolveVoiceRangfolge pinnt die dokumentierte Rangfolge
// --voice-Pin > config-Mapping > Repo-Default > en-Default.
func TestResolveVoiceRangfolge(t *testing.T) {
// TestResolveVoicePrecedence pins the documented precedence
// --voice pin > config mapping > repo default > en default.
func TestResolveVoicePrecedence(t *testing.T) {
cases := []struct {
name string
provider string
@@ -13,14 +13,14 @@ func TestResolveVoiceRangfolge(t *testing.T) {
cfg map[string]string
want string
}{
{"Default edge de", "edge", "de", "", nil, "de-DE-KatjaNeural"},
{"Default edge en", "edge", "en", "", nil, "en-US-AriaNeural"},
{"Default elevenlabs de (neutral, nicht privat)", "elevenlabs", "de", "", nil, "21m00Tcm4TlvDq8ikWAM"},
{"Default openai en", "openai", "en", "", nil, "alloy"},
{"Mapping schlägt Default", "edge", "de", "", map[string]string{"de": "de-DE-ConradNeural"}, "de-DE-ConradNeural"},
{"Pin schlägt Mapping", "edge", "de", "Gepinnt", map[string]string{"de": "de-DE-ConradNeural"}, "Gepinnt"},
{"unbekannte Sprache → en-Default", "edge", "fr", "", nil, "en-US-AriaNeural"},
{"osbuiltin kennt keine Voice-IDs", "osbuiltin", "de", "", nil, ""},
{"default edge de", "edge", "de", "", nil, "de-DE-KatjaNeural"},
{"default edge en", "edge", "en", "", nil, "en-US-AriaNeural"},
{"default elevenlabs de (neutral, not private)", "elevenlabs", "de", "", nil, "21m00Tcm4TlvDq8ikWAM"},
{"default openai en", "openai", "en", "", nil, "alloy"},
{"mapping beats default", "edge", "de", "", map[string]string{"de": "de-DE-ConradNeural"}, "de-DE-ConradNeural"},
{"pin beats mapping", "edge", "de", "Pinned", map[string]string{"de": "de-DE-ConradNeural"}, "Pinned"},
{"unknown language falls back to en default", "edge", "fr", "", nil, "en-US-AriaNeural"},
{"osbuiltin has no voice IDs", "osbuiltin", "de", "", nil, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {