# Task 2 Report: Erste Ansage end-to-end (OS-TTS) **Status: DONE** · Branch `feat/v1` · Basis `fa4f193` → HEAD `8306c6b` (6 Commits) ## Was geliefert wurde `talk "Hallo Welt"` spricht über OS-TTS, blockierend, end-to-end: Text (Arg oder stdin) → `langdetect.Detect` → Provider `osbuiltin` (`--provider`-Flag existiert, Default `osbuiltin`; Kette folgt Task 03) → `Synthesize` → `Sink.Play`. `TALK_AUDIO_SINK=null` läuft tonlos mit Exit 0 durch. ### spd-say-Mechanismus (verifiziert am Host, nicht geraten) `spd-say --help` auf dem Dev-Host (18.10.2026) zeigt: **keine Option, Audio in eine Datei oder Pipe zu schreiben**. Es gibt nur `-w/--wait` (auf Sprechende warten), `-e/--pipe-mode` (Text durchreichen, Sprache geht an den Daemon) und Stop/Cancel. SSIP/die Daemon-Schnittstelle liefert Audio ausschließlich an den Server-Audio-Output, nie an den aufrufenden Prozess. `espeak` ist auf dem Dev-Host **nicht** installiert, `spd-say`/`speech-dispatcher` systemweit verfügbar. **Getroffene Entscheidung (der im Brief erlaubte pragmatische Split):** - `osbuiltin.Synthesize` liefert WAV-Bytes, wo die Plattform es kann: Linux via `espeak`/`espeak-ng --stdout` (WAV auf stdout), macOS via `say --data-format=LEI16@22050 -o .wav` (Stimmen de→Anna, en→Samantha, vorinstalliert), Windows via SAPI/PowerShell (`System.Speech`, `SetOutputToWaveFile`; Skript als `-EncodedCommand` UTF-16LE/Base64, Text darin base64-kodiert — kein Quoting-Problem; `SelectVoiceByHints` mit try/catch-Fallback auf die Systemstimme). - Fehlt byte-fähiges TTS (Linux ohne espeak), liefert `Synthesize` den Sentinel `provider.ErrNoByteOutput` — und die **dokumentierte optionale Interface-Erweiterung** `provider.DirectSpeaker` (`SpeakDirect(ctx, text, lang)`, blockierend via `spd-say -w -l `) spielt selbst ab. Auf macOS/Windows ist `SpeakDirect` ein klarer Fehler (dort unnötig — kein toter Code-Pfad im CLI). - Der Basis-Contract aus dem Brief bleibt exakt intakt: `Provider{Name() string; Available(ctx) bool; Synthesize(ctx, text, lang string) ([]byte, error)}` — nur additive Erweiterung `DirectSpeaker` + Sentinel `ErrNoByteOutput`. - Semantik im say-Pfad: `ErrNoByteOutput` + Sink `oto` → `SpeakDirect` (hörbar); `ErrNoByteOutput` + Sink `null` → Ansage wird **verworfen** (Exit 0, Hinweis auf stderr) — null-Sink darf nie Ton erzeugen (CI/Server-Garantie). `Available()` ist auf Linux true, wenn irgendein Mechanismus existiert (espeak, espeak-ng oder spd-say); fehlt alles: klarer Fehler, kein Panik. ### Audio: oto-Version gepinnt `github.com/ebitengine/oto/v3` **v3.5.0** — aktuelle stabile Version des aktuellen Major (v3.6.0 existiert nur als alpha; v2/v1 sind alt). Der Brief nannte „oto v2 … oto/v3 is the current major — verify and pin": verifiziert via `go list -m -versions`, v3.5.0 gepinnt. Transitiv: `purego` (dlopen der Audiolibres ohne CGO), `jfreymuth/pulse`, `x/sys`. **Kein CGO nötig** → cross-compilierbar (siehe unten). oto erlaubt nur einen Context pro Prozess und bindet ihn an ein PCM-Format → der OtoSink cachet einen Context pro Format (`map[pcmFormat]*oto.Context` + Mutex). WAV wird von einem eigenen kleinen RIFF-Parser zerlegt (PCM 8/16 Bit, Float32; Mono/Stereo; klare Fehler für alles andere) — kein zweites Audio-Dependency. Zweites (und letztes) Dependency dieses Tasks: `github.com/BurntSushi/toml` v1.6.0 (MIT, pure Go, de-facto-Standard) für config.toml — ohne eigenen Subset-Parser zu bauen. ## Wie getestet - `internal/langdetect`: tischgesteuert (14 Fälle: de/en/gemischt/leer/ Interpunktion/Umlaute/Gleichstand → Default „de"). Mehrdeutige Wörter („was", „in", „build") bewusst aus den Listen. - `internal/config`: tischgesteuert — Pfadlogik `dirFor(goos, …)` für alle drei Plattformen vom Linux-Host aus (XDG-Vorrang, relativer XDG ignoriert, fehlende HOME/APPDATA → klarer Fehler); Merge Defaults → TOML → Env (inkl. Env-schlägt-Datei, ungültige Werte → klarer Fehler, kaputte TOML → klarer Fehler, ohne HOME → Defaults + Env). - `internal/provider`: Fake-Prozesse über PATH-Manipulation (echte exec-Prozessgrenze, kein Mock-Framework): fake `espeak` (WAV + ARGS-Pro Tokoll), fake `spd-say` (ARGS-Protokoll); leerer PATH → `Available()==false`, `Synthesize→ErrNoByteOutput`, crashendes espeak → klarer Fehler mit stderr. Echte hörbare spd-say-Ausgabe nur hinter `TALK_TEST_REAL_SPD=1`. - `internal/audio`: WAV-Parser gegen handgebaute RIFF-Fixtures (inkl. Extra-Chunk mit word-alignment, Float32, Fehlerfälle); NewSink/NullSink; oto-Fehlerpfad (ungültige Daten scheitern VOR Audio-Gerät — CI-safe). Echte Wiedergabe nur hinter `TALK_TEST_REAL_AUDIO=1`. - `cmd/talk`: CLI-End-to-End über die `run(argv, stdin, stdout, stderr)`-Seam mit injizierbarem `getenv`: `talk "Hallo Welt"` + sink null → Exit 0 und fake-espeak nachweislich gerufen; stdin-Pfad; Sprachwahl (`-v de`/`-v en`, `--lang` pinnt); Direktfall ohne espeak + fake spd-say (oto-Sink → SpeakDirect mit korrekten Argumenten; null-Sink → Verwurf, spd-say NICHT gerufen); ungültige `--lang`/`--provider`/`TALK_AUDIO_SINK` → klare Fehler; Task-1-Tests angepasst (stdin-Parameter; say kein Stub mehr). **Manuelle Abnahme am Dev-Host (Linux, NixOS, kein espeak, spd-say vorhanden):** - `talk "Hallo Welt, dies ist die erste Ansage von talk zwei Punkt null"` → Exit 0, **hörbar** via `spd-say -w` (Direktansage, blockierend). - Englische Ansage (`langdetect` → en) → Exit 0, gesprochen. - `TALK_TEST_REAL_SPD=1 go test ./internal/provider -run TestSpeakDirectEchtesSpdSay` → PASS in 2,4 s (= reale Sprechdauer, `-w` blockiert bis Ende). - `TALK_AUDIO_SINK=null talk "Hallo Welt"` → „Ansage verworfen", Exit 0, tonlos. - Espeak als Dev-Abhängigkeit im README dokumentiert (`nix shell nixpkgs#espeak-ng`). ## Verifikation - `go vet ./...` ✓ · `gofmt -l .` leer ✓ · `go test ./... -count=1` ✓ (5 Pakete) · `go build ./...` ✓ - Cross-Compile-Smoke (je `go build ./...`): **darwin/amd64 ✓, darwin/arm64 ✓, windows/amd64 ✓, windows/arm64 ✓, linux/amd64 ✓, linux/arm64 ✓** — mit `CGO_ENABLED=0`. Hinweis: ohne `CGO_ENABLED=0` scheitern darwin/linux-arm64 am lokalen gcc (`-arch`/ARM-Assembler) — Go versucht dort cgo; windows/amd64 ging auch mit Default-Settings. Release/Task 11 muss `CGO_ENABLED=0` pinnen (statische Binaries sind ohnehin Global Constraint; oto/purego braucht kein CGO). ## Dateien | Datei | Zweck | | --- | --- | | `internal/langdetect/detect.go` + Test | `Detect(text) → "de"|"en"` | | `internal/config/config.go`, `path.go` + Test | Defaults→TOML→Env, Plattformpfade injizierbar | | `internal/provider/provider.go` | Contract, `DirectSpeaker`, `ErrNoByteOutput`, Registry | | `internal/provider/osbuiltin.go` + Test | Linux(espeak/spd-say)/macOS(say)/Windows(SAPI) | | `internal/audio/audio.go` | Sink-Interface, NewSink, NullSink | | `internal/audio/wav.go` | RIFF/WAVE-Parser | | `internal/audio/oto.go` + Test | OtoSink v3.5.0, Context-Cache pro Format | | `cmd/talk/say.go` + Test | say-Pfad end-to-end | | `cmd/talk/cli.go`, `main.go`, `cli_test.go` | run(+stdin)-Seam, handler-Dispatch, say-Hilfe | | `go.mod`/`go.sum`, `README.md` | Deps gepinnt, espeak-Doku | ## Selbstreview (`git diff fa4f193..HEAD`) - Interfaces exakt wie vom Controller vorgegeben; nur dokumentiert-additive Erweiterungen (`DirectSpeaker`, `ErrNoByteOutput`) — Task 3 kann darauf bauen. - Eine(Task-1-)Seam-Änderung: `run(argv, stdout, stderr)` → `run(argv, stdin, stdout, stderr)` (nötig für den stdin-Pfad; Task-1-Tests mechanisch mitangepasst). Env bleibt über Paket-Var `getenv` injizierbar. - **Go-Falle gefunden und gefixt:** Flag-Bindung über Wertkopie der Options- Struct hätte nach `Parse` weiterhin Defaults geliefert (flag-Zeiger zeigten auf die tote lokale Variable) — `addSayFlags` gibt deshalb einen Pointer zurück; Debug-Test hatte das aufgedeckt (`--provider=elevenlabs` lief still auf osbuiltin durch). - Kein toter Code: `wait/now/priority` sind registrierte Flags ohne Verhalten (Daemon-Semantik Task 04), `--voice` gibt einen Hinweis (Voice-Registry Task 03). Beides bewusst so und kommentiert. - Coverage: langdetect 100 %, config 91,9 %, cmd/talk 80,9 %, audio 56,6 %, provider 44,2 % — die Lücken sind die hardware-/OS-gebundenen Pfade (echtes oto-Rendering, macOS-/Windows-Synth, echte spd-say-Ausgabe), die laut Spec bewusst ungetestet bleiben (doctor übernimmt das manuell). ## Concerns / Hinweise für Folge-Tasks 1. **macOS/Windows-Synth-Pfade sind zur Laufzeit ungetestet** (Dev-Host ist Linux). Mechanismen sind dokumentiert und cross-compilieren; ein echter Laufbarkeitstest bleibt Task 11 (Release-Matrix) oder manuellen Checks auf Zielplattformen vorbehalten. 2. **Task 03 muss `DirectSpeaker` in der Kette beachten** (letzte Stufe, wenn kein Provider Bytes liefert) und die Registry um elevenlabs/openai/edge erweitern; `--provider`-Validierung passiert bereits zentral in `provider.New`. 3. **`CGO_ENABLED=0` muss in CI/Release festgezogen werden** (Task 11), sonst brechen darwin/arm64-Builds am lokalen gcc. 4. `espeak -v ` nutzt den Sprachcode direkt als Voicenamen („de"/„en" existieren); feinere Stimmenwahl kommt mit der Voice-Registry (Task 03). 5. otos Poll-Loop (5 ms) für das Blockieren ist simpel und ausreichend; falls Task 04 Abbruch-Latenz kritisch findet, dort verfeinern. --- ## Fix-Report (Review-Nachbereitung, 18.10.2026) ### Important: NullSink-Logging war im CLI-Pfad toter Code **Befund:** `NewSink` lieferte `NullSink{}` mit nil `W`; `runSay` übergab stderr nie an den Sink. Bei erfolgreicher Synthese (Provider mit Byte-Output, z. B. espeak oder ab Task 03 Cloud-Provider) verwarf `TALK_AUDIO_SINK=null talk "Text"` **stumm** — die sichtbare „Ansage verworfen (audio.sink=null)"-Zeile kam nur aus dem ErrNoByteOutput-Zweig. Task 04 hätte fälschlich auf null-Sink-Sichtbarkeit vertraut. **Fix:** - `audio.NewSink(name string, log io.Writer)` — neuer zweiter Parameter; der null-Sink bekommt ihn als `W` (nil = still verwerfen), der oto-Sink ignoriert ihn. **Aufrufstellen Task 03+ müssen den Log-Writer übergeben.** Das `Sink`-Interface selbst (Play/Name) ist unverändert. - `runSay` übergibt jetzt stderr: `audio.NewSink(cfg.Audio.Sink, stderr)`. - Kommentar an `NullSink` korrigiert: Der Writer kommt jetzt wirklich über `NewSink` von der CLI (nicht mehr das claims-hafige „der CLI-Pfad hängt stderr an"). **Tests:** - `TestSayArgumentMitNullSink` (cmd/talk) erweitert: fake espeak (Synthese erfolgreich) + `TALK_AUDIO_SINK=null` → Exit 0 UND stderr enthält „verworfen (null sink)" — extern beobachtbares Verhalten auf dem CLI-Pfad, genau der geforderte Nachweis. - `TestNewSink/null-Sink_loggt_über_den_Factory-Writer` (internal/audio) neu: Factory verdrahtet den Writer (`NewSink(NameNull, &buf).Play` loggt). **Live-Beweis am Dev-Host** (espeak-ng via `nix shell` in PATH, config.toml mit `sink = "null"`): ```console $ nix shell nixpkgs#espeak-ng -c sh -c 'talk "Hallo Welt"' audio: 42850 Bytes verworfen (null sink) # stderr — vor dem Fix: stumm EXIT=0 ``` ### Minor: „was" aus englishWords entfernt `detect.go` führte „was" in der englischen Liste — widersprach dem germanWords-Kommentar („Mehrdeutigkeiten wie „was" und „in" wurden weggelassen") und dem Task-2-Report. Entfernt; neuer tischgesteuerter Fall `TestDetect/deutsch_frage_mit_was` („Was macht er?" → „de") — vorher hätte die Heuristik fälschlich „en" geliefert. ### Verifikation ```console $ go vet ./internal/audio/ ./internal/langdetect/ ./cmd/talk/ && gofmt -l internal/audio internal/langdetect cmd/talk (leer = ok) $ go test ./internal/audio/ ./internal/langdetect/ ./cmd/talk/ -count=1 -run 'Sink|null|Null|Detect|Say' ok code.m3ta.dev/m3tam3re/talk/internal/audio 0.002s ok code.m3ta.dev/m3tam3re/talk/internal/langdetect 0.001s ok code.m3ta.dev/m3tam3re/talk/cmd/talk 0.017s $ go test ./... -count=1 # kompletter Durchlauf als Absicherung ok (alle 5 Pakete) ```