fix(provider): Chain.Speak läuft im Direkt-Endgame nur einmal durch die Kette

Speak rief bei ErrNoByteOutput SpeakDirect auf, das einen eigenen
vollständigen walk machte: fehlgeschlagene Cloud-Stufen wurden zweimal
angefragt (doppelte POSTs auf bezahlten APIs), Fehlschlag-Zeilen
verdoppelten sich im Log, die Worst-Case-Latenz verdoppelte sich, und
Bytes aus einer transienten Erholung im zweiten Durchlauf hätten einen
Fehler statt Wiedergabe produziert.

Speak entscheidet jetzt aus EINEM walk-Ergebnis (Bytes → Player,
nur-Direkt-Stufe → SpeakDirect direkt daran, sonst gebündelter
Stufen-Fehler aus dem geteilten Helper walkResult.noTierErr).
Synthesize/SpeakDirect behalten ihre öffentlichen Verträge; null-Player
verwirft tonlos mit Exit 0 wie zuvor.

Test: TestChainDirektEndgameEinDurchlauf pinnt Cloud-Stufe genau 1×
angefragt, jede Fehlschlag-Zeile genau 1×, Direktansage kommt zustande.
This commit is contained in:
m3ta-chiron committed 2026-10-18 16:41:23 +02:00
1 parent 8351d40717
commit 3b44605234
2 files changed
+78 -14

No files matched your search

+25 -14
View File
@@ -205,8 +205,7 @@ func (c *Chain) Synthesize(ctx context.Context, text, lang string) ([]byte, erro
}
return nil, fmt.Errorf("%w (Stufe %s spricht nur direkt)", ErrNoByteOutput, r.directName)
}
return nil, fmt.Errorf("kette: keine Stufe konnte sprechen (nicht verfügbar: %s; fehlgeschlagen: %s)",
joinOr(r.skipped, "keine"), joinOr(r.failed, "keine"))
return nil, r.noTierErr()
}
// SpeakDirect implementiert DirectSpeaker: Direktansage über die Stufe,
@@ -225,34 +224,46 @@ func (c *Chain) SpeakDirect(ctx context.Context, text, lang string) error {
joinOr(r.skipped, "keine"), joinOr(r.failed, "keine"))
}
// Speak ist die komplette Ansage: 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).
// Nur ein Fehler aller Stufen oder eine fehlgeschlagene Wiedergabe sind
// Fehler.
// 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.
func (c *Chain) Speak(ctx context.Context, text, lang string) error {
data, err := c.Synthesize(ctx, text, lang)
r := c.walk(ctx, text, lang)
switch {
case err == nil:
case r.data != nil:
if c.cfg.Player == nil {
return fmt.Errorf("kette: kein Player konfiguriert — Bytes können nicht abgespielt werden")
}
if err := c.cfg.Player.Play(ctx, data); err != nil {
if err := c.cfg.Player.Play(ctx, r.data); err != nil {
return fmt.Errorf("kette: Wiedergabe über %s fehlgeschlagen: %w", c.cfg.Player.Name(), err)
}
return nil
case errors.Is(err, ErrNoByteOutput):
case r.direct != nil:
if c.cfg.Player != nil && c.cfg.Player.Name() == NullPlayerName {
chainLogf(c.cfg, "kette: Direktansage verworfen (audio.sink=null)\n")
return nil
}
return c.SpeakDirect(ctx, text, lang)
return r.direct.SpeakDirect(ctx, text, lang)
default:
return err
return r.noTierErr()
}
}
// 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.
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"))
}
// chainLogf schreibt eine Protokoll-Zeile, wenn ein Log gesetzt ist.
func chainLogf(cfg ChainConfig, format string, args ...any) {
if cfg.Log != nil {
+53
View File
@@ -340,3 +340,56 @@ func TestChainVoicePinDurchgereicht(t *testing.T) {
t.Errorf("Pfad = %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) {
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.
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
t.Setenv("PATH", dir)
var log bytes.Buffer
ch, err := NewChain(ChainConfig{
Getenv: chainEnv(map[string]string{
EnvElevenLabsKey: "k",
EnvElevenLabsEndpoint: eleven,
EnvOpenAIEndpoint: eleven, // ohne Key ohnehin übersprungen
EnvEdgeEndpoint: "ws://127.0.0.1:1/",
}),
Player: &fakePlayer{name: "oto"},
Log: &log,
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hallo Welt", "de"); err != nil {
t.Fatalf("Speak (Direkt-Endgame): %v", err)
}
if got := elHits.Load(); got != 1 {
t.Errorf("elevenlabs wurde %d× angefragt, want genau 1 (kein zweiter Durchlauf)", 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(), "Stufe openai übersprungen"); got != 1 {
t.Errorf("openai-Skip steht %d× im Log, want genau 1", got)
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("fake spd-say nicht gerufen: %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")
}
}