Files
talk/.superpowers/sdd/plan/task-2-report.md
T

225 lines
12 KiB
Markdown
Raw Normal View History

# 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 <tmp>.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 <lang> <text>`) 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 <lang>` 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)
```