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.Synthesizeliefert WAV-Bytes, wo die Plattform es kann: Linux viaespeak/espeak-ng --stdout(WAV auf stdout), macOS viasay --data-format=LEI16@22050 -o <tmp>.wav(Stimmen de→Anna, en→Samantha, vorinstalliert), Windows via SAPI/PowerShell (System.Speech,SetOutputToWaveFile; Skript als-EncodedCommandUTF-16LE/Base64, Text darin base64-kodiert — kein Quoting-Problem;SelectVoiceByHintsmit try/catch-Fallback auf die Systemstimme).- Fehlt byte-fähiges TTS (Linux ohne espeak), liefert
Synthesizeden Sentinelprovider.ErrNoByteOutput— und die dokumentierte optionale Interface-Erweiterungprovider.DirectSpeaker(SpeakDirect(ctx, text, lang), blockierend viaspd-say -w -l <lang> <text>) spielt selbst ab. Auf macOS/Windows istSpeakDirectein 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 ErweiterungDirectSpeaker+ SentinelErrNoByteOutput. - Semantik im say-Pfad:
ErrNoByteOutput+ Sinkoto→SpeakDirect(hörbar);ErrNoByteOutput+ Sinknull→ 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 — PfadlogikdirFor(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): fakeespeak(WAV + ARGS-Pro Tokoll), fakespd-say(ARGS-Protokoll); leerer PATH →Available()==false,Synthesize→ErrNoByteOutput, crashendes espeak → klarer Fehler mit stderr. Echte hörbare spd-say-Ausgabe nur hinterTALK_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 hinterTALK_TEST_REAL_AUDIO=1.cmd/talk: CLI-End-to-End über dierun(argv, stdin, stdout, stderr)-Seam mit injizierbaremgetenv:talk "Hallo Welt"+ sink null → Exit 0 und fake-espeak nachweislich gerufen; stdin-Pfad; Sprachwahl (-v de/-v en,--langpinnt); 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 viaspd-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,-wblockiert 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 ✓ — mitCGO_ENABLED=0. Hinweis: ohneCGO_ENABLED=0scheitern darwin/linux-arm64 am lokalen gcc (-arch/ARM-Assembler) — Go versucht dort cgo; windows/amd64 ging auch mit Default-Settings. Release/Task 11 mussCGO_ENABLED=0pinnen (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-Vargetenvinjizierbar. - Go-Falle gefunden und gefixt: Flag-Bindung über Wertkopie der Options-
Struct hätte nach
Parseweiterhin Defaults geliefert (flag-Zeiger zeigten auf die tote lokale Variable) —addSayFlagsgibt deshalb einen Pointer zurück; Debug-Test hatte das aufgedeckt (--provider=elevenlabslief still auf osbuiltin durch). - Kein toter Code:
wait/now/prioritysind registrierte Flags ohne Verhalten (Daemon-Semantik Task 04),--voicegibt 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
- 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.
- Task 03 muss
DirectSpeakerin der Kette beachten (letzte Stufe, wenn kein Provider Bytes liefert) und die Registry um elevenlabs/openai/edge erweitern;--provider-Validierung passiert bereits zentral inprovider.New. CGO_ENABLED=0muss in CI/Release festgezogen werden (Task 11), sonst brechen darwin/arm64-Builds am lokalen gcc.espeak -v <lang>nutzt den Sprachcode direkt als Voicenamen („de"/„en" existieren); feinere Stimmenwahl kommt mit der Voice-Registry (Task 03).- 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 alsW(nil = still verwerfen), der oto-Sink ignoriert ihn. Aufrufstellen Task 03+ müssen den Log-Writer übergeben. DasSink-Interface selbst (Play/Name) ist unverändert.runSayübergibt jetzt stderr:audio.NewSink(cfg.Audio.Sink, stderr).- Kommentar an
NullSinkkorrigiert: Der Writer kommt jetzt wirklich überNewSinkvon 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).Playloggt).
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)