From 3b44605234b8ed70bdc78f7a98029d730bcc5b2b Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Sun, 18 Oct 2026 16:41:23 +0200 Subject: [PATCH] =?UTF-8?q?fix(provider):=20Chain.Speak=20l=C3=A4uft=20im?= =?UTF-8?q?=20Direkt-Endgame=20nur=20einmal=20durch=20die=20Kette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/provider/chain.go | 39 +++++++++++++++--------- internal/provider/chain_test.go | 53 +++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 14 deletions(-) diff --git a/internal/provider/chain.go b/internal/provider/chain.go index a4df552..067a00d 100644 --- a/internal/provider/chain.go +++ b/internal/provider/chain.go @@ -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 { diff --git a/internal/provider/chain_test.go b/internal/provider/chain_test.go index 5c034dd..fade5cf 100644 --- a/internal/provider/chain_test.go +++ b/internal/provider/chain_test.go @@ -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") + } +}