13 KiB
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>, Headerxi-api-key(Key ausschließlichTALK_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-Endpunkthttps://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, Defaulttts-1— Entscheidungsnotz aus dem Brief umgesetzt). Default-Endpunkthttps://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-Ketteelevenlabs → openai → edge → osbuiltin(DefaultOrder); Pin per--provider(schlägtprovider = "…"aus der config.toml);Available()rein lokal (Key/Endpunkt/Binary, keine Netz-Calls); Fallback bei HTTP ≠ 2xx, Timeout, Netz — jede übersprungene/fehlgeschlagene Stufe wird insLoggeschrieben (nie still), am Ende zusätzlich Fehler mit allen Stufen. Chain implementiert Provider UND DirectSpeaker:Speak(ctx, text, lang)orchestriert Bytes →Player.Playbzw. letzte Stufe Direktansage; bei Player-Name „null" wird die Direktansage tonlos verworfen (loggt, exit clean). DerPlayer-Vertrag ist strukturell identisch zuaudio.Sink(Play+Name) — CLI und Task-04-Daemon übergeben ihraudio.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), openaialloy(de+en), edgede-DE-KatjaNeural/en-US-AriaNeural.- Config (
internal/config) —provider(Pin, EnvTALK_PROVIDER),[providers.<name>] endpoint/model,[voice.<provider>] <lang> = id. TOML-Key bewusst Mehrzahlproviders, damit Pin und Tabelle koexistieren. Endpunkt-Env-Overrides (TALK_ELEVENLABS_ENDPOINTetc.) 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 ruftchain.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.parseInterspersedparst 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:
- 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.goist eine wörtliche Portierung und gegen 4 Referenzvektoren der Python-Implementierung getestet (inkl. Zeitfenster-Raster). Sec-MS-GEC-Version2-143.0.3650.75. - MUID-Cookie (
Cookie: muid=<32 Zufalls-Hex groß>) im Handshake, dazu Origin chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold und Edge-Chrome-143-User-Agent. - Query:
TrustedClientToken,ConnectionId,Sec-MS-GEC,Sec-MS-GEC-Version(Default-Endpunkt konfigurierbar; Schema-Validierung ws/wss mit klarem Fehler). - Nachrichten wie bekannt:
speech.config(Outputaudio-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 beiPath:turn.end. Bei 403: Uhrenkorrektur aus dem Date-Header + genau ein Retry (wie die Referenz). - 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-MarkierungTestEdgeIntegrationEcht(Doppel-Gate:-shortUNDTALK_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=elevenlabsgegen Fake, null-Sink-Verwurfzeile),--voiceim 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=0linux/darwin/windows × amd64/arm64, jetalk+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/talkdunangetastet.
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
- 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. - 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). - Interspersed-Flags-Fix in der CLI (kleiner Umfang,
cli.go): ohne ihn wäre das Abnahmekriteriumtalk "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. TALK_PROVIDERals Env-Override des Pins (kleine Ergänzung über Brief): konsistent mit „Env schlägt Datei", erleichtert Task-04-Prozessgrenzen-Tests. Analog Endpunkt-Env-OverridesTALK_*_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.Speakblockiert (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