# Task 3 Report: Provider-Kette mit Cloud-Anbietern Status: **DONE** · Branch `feat/v1` · Commits `7a843ae..8351d40` (5 Commits auf `ae9f3b6`) ## Geliefert - **`internal/provider/elevenlabs.go`** — POST `/v1/text-to-speech/`, Header `xi-api-key` (Key ausschließlich `TALK_ELEVENLABS_KEY`), Body `{text, model_id: "eleven_multilingual_v2", voice_settings: {stability: 0.5, similarity_boost: 0.75}}` (Brief-Werte wörtlich), Antwort-Bytes (MP3) unverändert. Default-Endpunkt `https://api.elevenlabs.io`. - **`internal/provider/openai.go`** — POST `/v1/audio/speech`, `Authorization: Bearer `, Body `{model, voice, input, response_format: "mp3"}`. Modell konfigurierbar (`[providers.openai] model`, Default `tts-1` — Entscheidungsnotz aus dem Brief umgesetzt). Default-Endpunkt `https://api.openai.com`. - **`internal/provider/edge.go` + `edgedrm.go`** — Microsofts „Read Aloud"-WSS-Protokoll (edge-tts-kompatibel), kostenlos, kein Key. Siehe „Edge-Protokoll-Notizen" unten. - **`internal/provider/chain.go`** — geordnete Vier-Stufen-Kette `elevenlabs → openai → edge → osbuiltin` (`DefaultOrder`); Pin per `--provider` (schlägt `provider = "…"` aus der config.toml); `Available()` rein lokal (Key/Endpunkt/Binary, keine Netz-Calls); Fallback bei HTTP ≠ 2xx, Timeout, Netz — jede übersprungene/fehlgeschlagene Stufe wird ins `Log` geschrieben (nie still), am Ende zusätzlich Fehler mit allen Stufen. **Chain implementiert Provider UND DirectSpeaker**: `Speak(ctx, text, lang)` orchestriert Bytes → `Player.Play` bzw. letzte Stufe Direktansage; bei Player-Name „null" wird die Direktansage tonlos verworfen (loggt, exit clean). Der `Player`-Vertrag ist strukturell identisch zu `audio.Sink` (`Play`+`Name`) — CLI und Task-04-Daemon übergeben ihr `audio.NewSink(name, log)`-Ergebnis unverändert; die Kette importiert das audio-Paket nicht. - **`internal/provider/voices.go`** — Voice-Auflösung mit dokumentierter Rangfolge: `--voice` (VoicePinner) > `voice..` aus config.toml > neutrale Repo-Defaults > en-Default. Neutrale Defaults: elevenlabs „Rachel" `21m00Tcm4TlvDq8ikWAM` (de+en), openai `alloy` (de+en), edge `de-DE-KatjaNeural` / `en-US-AriaNeural`. - **Config (`internal/config`)** — `provider` (Pin, Env `TALK_PROVIDER`), `[providers.] endpoint/model`, `[voice.] = id`. TOML-Key bewusst Mehrzahl `providers`, damit Pin und Tabelle koexistieren. Endpunkt-Env-Overrides (`TALK_ELEVENLABS_ENDPOINT` etc.) liest die Provider-Schicht (Env > Datei > Default) — config-Paket bleibt frei von Provider-Namen, Tests kommen ohne Config-Datei aus. - **CLI (`cmd/talk/say.go` + `cli.go`)** — say baut die Kette aus Config+Flags und ruft `chain.Speak`; der Task-02-Direktansage-/null-Sink-Sonderweg steckt jetzt in der Kette. **Zusätzlich gefundener und gefixter CLI-Bug**: Go-stdlib-flag stoppt beim ersten Nicht-Flag-Argument — `talk "Test" --provider=elevenlabs` (Abnahme-Aufrufform!) hätte das Flag als Text geschluckt. `parseInterspersed` parst Flags überall (Regressionstest vorhanden). - **Audio (`internal/audio/mp3.go`)** — MP3-Dekodierung, siehe Entscheidung unten. ## Entscheidungsnotiz: MP3-Dekodierung (Brief-Frage) **Entschieden: audio-Paket dekodiert MP3 mit reinem Go-Dekoder `github.com/hajimehoshi/go-mp3 v0.3.4`.** Begründung: (1) Nur OpenAI kann alternatives `response_format` (wav/pcm) anfordern — ElevenLabs und Edge-TTS liefern MP3 als einziges Format; eine einheitliche MP3-Pipeline hält die Kette symmetrisch statt zwei Wege zu pflegen. (2) Reinheits-Nachweis: kein `import "C"` im Modul (grep-geprüft), CGO-frei. (3) Cross-Compile-Nachweis: `CGO_ENABLED=0 go build` für **alle 6 Targets** (linux/darwin/windows × amd64/arm64, talk + talkd) grün — die Verpflichtung aus den Global Constraints bleibt erfüllt. (4) Ausgabe-Vertrag des Dekoders: immer 16-Bit-LE-PCM mit 2 Kanälen (Mono wie Edge 24 kHz wird gespiegelt), Sample-Rate aus dem ersten Frame — passt direkt an den oto-Sink, der dafür eine Format-Weiche (`decodeAudio`: RIFF→WAV-Parser, ID3/Frame-Sync→MP3) bekommen hat. Fixtures in `mp3_test.go` sind echte MP3s (einmalig per ffmpeg erzeugt, base64 eingebettet): mono 24 kHz 48 kbit/s (= exakt Edges Ausgabeformat) und stereo 44,1 kHz. ## Edge-Protokoll-Notizen (Dokumentationspflicht aus dem Brief) Stand der Referenz (rany2/edge-tts, master, 2026-10 abgerufen): Das Protokoll hat sich seit dem klassischen Stand geändert — mein erster Real-Endpoint-Versuch mit dem alten Schema (plain SHA-256-Sec-MS-GEC v1) bekam **HTTP 403**. Aktuell nötig: 1. **DRM-Token Sec-MS-GEC v2**: `<16 Hex CS64>-<64 Hex SHA256-Variante>` über „5-Minuten-gerundete Windows-Filetime-Ticks + TrustedClientToken". Beide Digests sind verschleierte Varianten (CS64-Checksumme + modifizierte SHA-256 mit Mid-Block-Einstieg) — `edgedrm.go` ist eine wörtliche Portierung und gegen 4 Referenzvektoren der Python-Implementierung getestet (inkl. Zeitfenster-Raster). Sec-MS-GEC-Version `2-143.0.3650.75`. 2. **MUID-Cookie** (`Cookie: muid=<32 Zufalls-Hex groß>`) im Handshake, dazu Origin chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold und Edge-Chrome-143-User-Agent. 3. Query: `TrustedClientToken`, `ConnectionId`, `Sec-MS-GEC`, `Sec-MS-GEC-Version` (Default-Endpunkt konfigurierbar; Schema-Validierung ws/wss mit klarem Fehler). 4. Nachrichten wie bekannt: `speech.config` (Output `audio-24khz-48kbitrate-mono-mp3`), SSML-Anfrage (X-Timestamp mit Referenz-Bug-Nachstell-Z), Binär-Rahmen (2-Byte-BE-Header-Länge, `Path:audio`), Ende bei `Path:turn.end`. Bei 403: Uhrenkorrektur aus dem Date-Header + genau ein Retry (wie die Referenz). 5. **Bei künftiger Protokoll-Änderung**: alle Fehlerpfade nennen explizit „Netz/Timeout oder das Edge-TTS-Protokoll hat sich geändert" — nie still. **Real-Verifikation (einmalig, manuell)**: `TALK_TEST_REAL_EDGE=1 go test -run TestEdgeIntegrationEcht` → **PASS, 17136 Bytes echtes Audio** vom echten Microsoft-Endpunkt; zusätzlich `talk "Sprachtest über Edge TTS" --provider=edge` mit `TALK_AUDIO_SINK=null` → „Ansage über edge", 26784 Bytes verworfen, Exit 0. Ohne jeden Key wählt die Kette damit real edge. ## Was wie fake-bar getestet ist (Brief-Pflicht) - **elevenlabs/openai**: `httptest`-Fakes — 200 (Bytes unverändert; Methode/Pfad inkl. Voice-ID, Header, JSON-Body wörtlich geprüft), 500, 401; Available mit/ohne Key; Voice-Rangfolge (--voice > Mapping > Default > en-Fallback); leerer Text. Für die Kette zusätzlich zählende Fakes (erster 500 → zweiter getroffen → Bytes). - **edge** (vom Brief explizit von httptest ausgenommen): Interface-/Fehlerpfad-Tests (toter Loopback-Port, falsches Schema, Nullwert-Available), reine Protokollfunktionen (SSML, Escaping inkl. Steuerzeichen, speech.config, Request-URL/Token-Redaction, CS64/SHA-Referenzvektoren, Binär-Rahmen-Split), ein **Loopback-WSS-Fake** (gorilla-Upgrader auf 127.0.0.1: voller Protokoll-Durchlauf ohne echtes Netz; prüft Query-Token, Origin, beide Client-Nachrichten, Audio-Payload; Abbruch-Variante → Protokoll-Fehler) und die **als `testing.Short` überspringbare Integrations-Markierung** `TestEdgeIntegrationEcht` (Doppel-Gate: `-short` UND `TALK_TEST_REAL_EDGE=1`; einmalig manuell ausgeführt, s. o.). - **Kette**: Fallback 500→Stufe 2, alles kaputt (Fehler nennt Stufen), ohne Keys → edge(osbuiltin)-Pfad mit fake espeak, Pin (nur gepinnte Stufe wird angefragt), Bytes→Player, Direktansage mit ton-Player (spd-say-ARGS) und null-Player (Verwurf, spd-say NICHT gerufen), ErrNoByteOutput-Durchreichen, Default-Reihenfolge, Voice-Pin-Durchreichung. - **CLI** (`cmd/talk`): Cloud-End-to-End über die Env-Seam (`--provider=elevenlabs` gegen Fake, null-Sink-Verwurfzeile), `--voice` im Request-Pfad, 500-Fallback bis osbuiltin, Flag-nach-Text, gepinnter Provider ohne Key → klarer Fehler. Tests fassen nie echte Endpoints: Die Env-Seam injiziert jedem Test einen toten Loopback-Port für edge. - **Kein Standard-Test hängt am Netz** (auch nicht an Microsoft); `go test -short ./...` grün. ## Verifikation - `gofmt -l .` leer · `go vet ./...` grün · `go test ./...` grün (80 Testfälle) · `go build ./...` grün. - Cross-Compile: `CGO_ENABLED=0` linux/darwin/windows × amd64/arm64, je `talk` + `talkd` — 6/6 OK. - `go.mod`: +`github.com/hajimehoshi/go-mp3 v0.3.4`, +`github.com/gorilla/websocket v1.5.3` (beide reines Go, MIT/BSD). - Daemon `cmd/talkd` unangetastet. ## Echter-Key-Check (Abnahmekriterium) `talk "Test" --provider=elevenlabs` mit echtem ElevenLabs-/OpenAI-Key: **nicht durchgeführt** — `TALK_ELEVENLABS_KEY` und `TALK_OPENAI_KEY` sind in der Dev-Host-Umgebung nicht gesetzt (geprüft, nicht gefragt). Ersatz-Evidenz: Request-Vertrag beider Provider ist wörtlich gegen Fakes getestet (Pfad, Header, Body inkl. model_id/voice_settings/response_format); der Edge-Provider wurde zusätzlich real verifiziert (s. o.). Nachreichende einmalige Abnahme mit Key empfohlen (gleiche Form wie der CLI-Test, nur mit echtem Endpunkt). ## Abweichungen & Begründungen 1. **ElevenLabs-Default-Stimme ist „Rachel" (21m00Tcm4TlvDq8ikWAM), nicht KXxZd16DiBqt82nbarJx.** Brief/Plan nennen KXxZ… als „Default-Voice-ID"; die Spec (bindende Autorität, „bei Konflikt gewinnt die Spec") führt KXxZ… ausdrücklich als **privaten** Default („nicht Repo-Default", Further Notes) und verlangt „neutrale Defaults im Repo". Umsetzung: neutraler Repo-Default Rachel; die persönliche Stimme gehört in die eigene config.toml (`[voice.elevenlabs] de/en = "KXxZ…"` — so auch im Code-Kommentar und README dokumentiert). Ein-Wort-Flip, falls der Controller es anders will. 2. **TOML-Key `providers` (Mehrzahl)** statt `[provider.]`: `provider = "…"` (Pin, Singular) und eine Einstellungs-Tabelle können in TOML nicht denselben Schlüssel belegen — Mehrzahl löst den Konflikt ohne Spec-Verstoß (Spec schreibt keinen Tabellennamen vor). 3. **Interspersed-Flags-Fix in der CLI** (kleiner Umfang, `cli.go`): ohne ihn wäre das Abnahmekriterium `talk "Test" --provider=elevenlabs` (Flags nach dem Text) funktional unmöglich gewesen. Ändert Task-01-Dispatch minimal, alle Task-01/02-Tests bleiben grün, neuer Regressionstest deckt die Form ab. 4. **`TALK_PROVIDER` als Env-Override des Pins** (kleine Ergänzung über Brief): konsistent mit „Env schlägt Datei", erleichtert Task-04-Prozessgrenzen-Tests. Analog Endpunkt-Env-Overrides `TALK_*_ENDPOINT` (Test-Seam laut Spec-Testing-Entscheidung). ## Bedenken / Risiken für Folgetasks - Edge-DRM ist ein undokumentierter Endpoint: weitere Microsoft-Änderungen brechen die Stufe (klar benannte Fehler + Kette fällt auf osbuiltin). Referenzvektor-Tests machen Regressionen sofort sichtbar. - `chain.Speak` blockiert (wie Task 02) — non-blocking kommt mit dem Daemon (Task 04), der die Kette unverändert übernimmt (Player/Log/Getenv injizierbar). - Default-Kette versucht edge real, wenn elevenlabs/openai ohne Key sind: gewollt (User Story 10), aber Offline-Hosts warten die edgeIOTimeout (20 s) nur dann ab, wenn auch edge nicht erreichbar ist und osbuiltin die Ansage macht. Verschwiegener Träger: Fehler wird protokolliert. --- ## Review-Fix: Chain.Speak läuft im DirectSpeaker-Endgame nur noch einmal **Fund (Important):** `Speak` lief die Kette zweimal — `Speak` → `Synthesize` (Durchlauf 1) und bei ErrNoByteOutput mit Nicht-null-Player → `SpeakDirect` (eigener kompletter Durchlauf 2). Folge: fehlgeschlagene Cloud-Stufen wurden erneut angefragt (doppelte POSTs auf bezahlten APIs), doppelte „Stufe X fehlgeschlagen"-Zeilen im Log, verdoppelte Worst-Case-Latenz, und eine transiente Erholung in Durchlauf 2 hätte den Fehler „liefert Bytes — über Synthesize …" produziert statt die geholten Bytes abzuspielen. **Änderung (`internal/provider/chain.go`):** `Speak` macht jetzt EINEN `walk` und entscheidet aus dessen Ergebnis: Bytes → Player.Play; nur-Direkt-Stufe → `r.direct.SpeakDirect` (null-Player → Verwurf-Log + clean exit, unverändert); nichts → gebündelter Stufen-Fehler. Die Fehleraggregation steckt jetzt im geteilten Helper `walkResult.noTierErr()`, den `Synthesize` und `Speak` gemeinsam nutzen (identische Meldung wie zuvor). `Synthesize` und `SpeakDirect` behalten ihre öffentlichen Verträge unverändert (SpeakDirect macht als eigenständiger Einstieg weiter seinen eigenen Durchlauf — von `Speak` wird es nicht mehr gerufen). Externe Verträge unverändert: je Stufe genau eine Fehlschlag-Zeile, gleiche Fehler-Aggregation, DirectSpeaker nur bei ErrNoByteOutput mit Nicht-null-Player, null-Sink → Verwurf + Exit 0. **Deckende Tests (`internal/provider/chain_test.go`):** - NEU `TestChainDirektEndgameEinDurchlauf`: elevenlabs-Fake mit 500 wird im Direkt-Endgame **genau 1×** angefragt (Zähler), „Stufe elevenlabs fehlgeschlagen" und „Stufe edge fehlgeschlagen" stehen je **genau 1×** im Log, openai-Skip genau 1×, spd-say erhält `-w -l de Hallo Welt`. - Bestehende Kettentests (Fallback 500→Stufe 2, Pin, Bytes→Player, Direkt mit/ohne null-Player, ErrNoByteOutput-Durchreichen, Voice-Pin) unverändert grün — sie pinnen das unveränderte Außenverhalten. **Befehl / Output:** ``` go test -count=1 ./internal/provider/ -run 'TestChain' -v # 12/12 PASS (inkl. neuem Test) go test -count=1 ./... # ok cmd/talk, audio, config, langdetect, provider gofmt -l . ; go vet ./... ; go build ./... # leer / grün / grün ```