# Review package: 8351d40..HEAD ## Commits 3b44605 fix(provider): Chain.Speak läuft im Direkt-Endgame nur einmal durch die Kette ## Files changed internal/provider/chain.go | 39 +++++++++++++++++++----------- internal/provider/chain_test.go | 53 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 14 deletions(-) ## Diff internal/provider/chain.go --- 1/2 --- Go 205 } 205 } 206 return nil, fmt.Errorf("%w 206 return nil, fmt.Errorf("%w ... (Stufe %s spricht nur direkt)", Err ... (Stufe %s spricht nur direkt)", Err ... NoByteOutput, r.directName) ... NoByteOutput, r.directName) 207 } 207 } 208 return nil, fmt.Errorf("kette: 208 return nil, r.noTierErr() ... keine Stufe konnte sprechen (nicht ... ... verfügbar: %s; fehlgeschlagen: %s)" ... ... , ... 209 joinOr(r.skipped, "keine"), ... ... joinOr(r.failed, "keine")) ... 210 } 209 } 211 210 212 // SpeakDirect implementiert Direct 211 // SpeakDirect implementiert Direct ... Speaker: Direktansage über die Stuf ... Speaker: Direktansage über die Stuf ... e, ... e, internal/provider/chain.go --- 2/2 --- Go 225 joinOr(r.skipped, "keine"), 224 joinOr(r.skipped, "keine"), ... joinOr(r.failed, "keine")) ... joinOr(r.failed, "keine")) 226 } 225 } 227 226 228 // Speak ist die komplette Ansage: 227 // Speak ist die komplette Ansage i ... Synthese mit Fallback → Wiedergabe ... n EINEM Ketten-Durchlauf: Synthese ... über ... mit 229 // den Player; fällt die Kette auf 228 // Fallback → Wiedergabe über den P ... die Direkt-Stufe zurück, entscheide ... layer; fällt die Kette auf die Dire ... t der ... kt- 230 // Player-Name über Ton (alles auße 229 // Stufe zurück, entscheidet der Pl ... r „null") oder tonloses Verwerfen ( ... ayer-Name über Ton (alles außer „nu ... null: ... ll") 231 // CI/Server bleiben fehler- und to 230 // oder tonloses Verwerfen (null: C ... nfrei, der Verwurf wird protokollie ... I/Server bleiben fehler- und tonfre ... rt). ... i, ... 231 // der Verwurf wird protokolliert). ... ... Der Durchlauf findet bewusst genau ... 232 // einmal statt: fehlgeschlagene Cl ... ... oud-Stufen werden nicht ein zweites ... ... Mal ... 233 // angefragt (keine doppelten POSTs ... ... auf bezahlten APIs, keine doppelte ... ... n ... 234 // Protokoll-Zeilen), und Bytes aus ... ... diesem Durchlauf werden abgespielt ... ... , ... 235 // statt sie wegzuwerfen und neu zu ... ... laufen. Nur ein Fehler aller Stufe ... ... n 232 // Nur ein Fehler aller Stufen oder 236 // oder eine fehlgeschlagene Wieder ... eine fehlgeschlagene Wiedergabe si ... gabe sind Fehler. ... nd ... 233 // Fehler. ... 234 func (c *Chain) Speak(ctx context.C 237 func (c *Chain) Speak(ctx context.C ... ontext, text, lang string) error { ... ontext, text, lang string) error { 235 data, err := c.Synthesize(ctx, 238 r := c.walk(ctx, text, lang) ... text, lang) ... 236 switch { 239 switch { 237 case err == nil: 240 case r.data != nil: 238 if c.cfg.Player == nil { 241 if c.cfg.Player == nil { 239 return fmt.Errorf("kett 242 return fmt.Errorf("kett ... e: kein Player konfiguriert — Bytes ... e: kein Player konfiguriert — Bytes ... können nicht abgespielt werden") ... können nicht abgespielt werden") 240 } 243 } 241 if err := c.cfg.Player.Play 244 if err := c.cfg.Player.Play ... (ctx, data); err != nil { ... (ctx, r.data); err != nil { 242 return fmt.Errorf("kett 245 return fmt.Errorf("kett ... e: Wiedergabe über %s fehlgeschlage ... e: Wiedergabe über %s fehlgeschlage ... n: %w", c.cfg.Player.Name(), err) ... n: %w", c.cfg.Player.Name(), err) 243 } 246 } 244 return nil 247 return nil 245 case errors.Is(err, ErrNoByteOu 248 case r.direct != nil: ... tput): ... 246 if c.cfg.Player != nil && c 249 if c.cfg.Player != nil && c ... .cfg.Player.Name() == NullPlayerNam ... .cfg.Player.Name() == NullPlayerNam ... e { ... e { 247 chainLogf(c.cfg, "kette 250 chainLogf(c.cfg, "kette ... : Direktansage verworfen (audio.sin ... : Direktansage verworfen (audio.sin ... k=null)\n") ... k=null)\n") 248 return nil 251 return nil 249 } 252 } 250 return c.SpeakDirect(ctx, t 253 return r.direct.SpeakDirect ... ext, lang) ... (ctx, text, lang) 251 default: 254 default: 252 return err 255 return r.noTierErr() 253 } 256 } 254 } 257 } ... 258 ... 259 // noTierErr bündelt den Ende-ohne- ... ... Ergebnis-Fehler eines Durchlaufs: k ... ... eine ... 260 // Stufe lieferte Bytes oder Direkt ... ... ansage — übersprungene (nicht verfü ... ... gbar) ... 261 // und fehlgeschlagene Stufen werde ... ... n genannt, nie still. ... 262 func (r walkResult) noTierErr() err ... ... or { ... 263 return fmt.Errorf("kette: keine ... ... Stufe konnte sprechen (nicht verfü ... ... gbar: %s; fehlgeschlagen: %s)", ... 264 joinOr(r.skipped, "keine"), ... ... joinOr(r.failed, "keine")) ... 265 } 255 266 256 // chainLogf schreibt eine Protokol 267 // chainLogf schreibt eine Protokol ... l-Zeile, wenn ein Log gesetzt ist. ... l-Zeile, wenn ein Log gesetzt ist. 257 func chainLogf(cfg ChainConfig, for 268 func chainLogf(cfg ChainConfig, for ... mat string, args ...any) { ... mat string, args ...any) { internal/provider/chain_test.go --- Go 341 341 } 342 342 } 343 344 // TestChainDirektEndgameEinDurchlauf (Review-Fix): Im DirectSpeaker- 345 // Endgame läuft die Kette genau EINMAL — die fehlgeschlagene Cloud-Stufe 346 // wird nur einmal angefragt (keine doppelten POSTs auf bezahlten APIs, 347 // keine verdoppelte Latenz), jede Stufe protokolliert ihren Fehlschlag 348 // genau einmal, und die Direktansage kommt zustande. 349 func TestChainDirektEndgameEinDurchlauf(t *testing.T) { 350 eleven, elHits := countingTTSServer(t, 500, []byte("boom")) 351 // elevenlabs: Key + Fake (500). openai: Endpunkt, aber KEIN Key → 352 // übersprungen. edge: toter Loopback-Port. osbuiltin: PATH nur mit 353 // fake spd-say (kein espeak) → keine Bytes, Direktansage. 354 dir := t.TempDir() 355 seen := filepath.Join(dir, "seen.txt") 356 fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`) 357 t.Setenv("PATH", dir) 358 359 var log bytes.Buffer 360 ch, err := NewChain(ChainConfig{ 361 Getenv: chainEnv(map[string]string{ 362 EnvElevenLabsKey: "k", 363 EnvElevenLabsEndpoint: eleven, 364 EnvOpenAIEndpoint: eleven, // ohne Key ohnehin übersprungen 365 EnvEdgeEndpoint: "ws://127.0.0.1:1/", 366 }), 367 Player: &fakePlayer{name: "oto"}, 368 Log: &log, 369 }) 370 if err != nil { 371 t.Fatalf("NewChain: %v", err) 372 } 373 374 if err := ch.Speak(context.Background(), "Hallo Welt", "de"); err != nil { 375 t.Fatalf("Speak (Direkt-Endgame): %v", err) 376 } 377 if got := elHits.Load(); got != 1 { 378 t.Errorf("elevenlabs wurde %d× angefragt, want genau 1 (kein zweiter Durchlauf)", got) 379 } 380 for _, tier := range []string{"elevenlabs", "edge"} { 381 if got := strings.Count(log.String(), "Stufe "+tier+" fehlgeschlagen"); got != 1 { 382 t.Errorf("„Stufe %s fehlgeschlagen“ steht %d× im Log, want genau 1:\n%s", tier, got, log.String()) 383 } 384 } 385 if got := strings.Count(log.String(), "Stufe openai übersprungen"); got != 1 { 386 t.Errorf("openai-Skip steht %d× im Log, want genau 1", got) 387 } 388 args, err := os.ReadFile(seen) 389 if err != nil { 390 t.Fatalf("fake spd-say nicht gerufen: %v", err) 391 } 392 if got := strings.TrimSpace(string(args)); got != "-w -l de Hallo Welt" { 393 t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt") 394 } 395 }