chore(sdd): handoff snapshot — ledger, briefs, reports, review packages + HANDOFF.md for cross-machine continuation
This commit is contained in:
1 parent
acfba19fc2
commit
b934571b97
25 files changed
+17590
No files matched your search
@@ -0,0 +1,225 @@
|
||||
# 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)
|
||||
```
|
||||
|
||||
Reference in new issue
Block a user