225 lines
12 KiB
Markdown
225 lines
12 KiB
Markdown
# 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)
|
||
|
|
```
|
||
|
|
|