84 lines
13 KiB
Markdown
84 lines
13 KiB
Markdown
# 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 `<endpoint>/v1/text-to-speech/<voiceID>`, 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 `<endpoint>/v1/audio/speech`, `Authorization: Bearer <TALK_OPENAI_KEY>`, 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.<provider>.<lang>` 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.<name>] endpoint/model`, `[voice.<provider>] <lang> = 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.<name>]`: `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
|
||
```
|