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

12 KiB

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"
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"):

$ 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

$ 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)