chore: SDD-Workspace aus dem Produkt-Branch nehmen (liegt jetzt auf Branch sdd-state)
28 Dateien Prozess-Zustand (Ledger, Briefs, Reports, Review-Diffs, Plan, Handoff) sind für eine Veröffentlichung Rauschen. Sie leben weiter auf dem Branch sdd-state; .scratch/ und .superpowers/ sind gitignored. Restore und Update stehen in .gitignore und in der Commit-Nachricht von sdd-state. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
a492485e3d
commit
92333ff1c3
27 files changed
+5
-17764
No files matched your search
@@ -6,3 +6,8 @@
|
|||||||
|
|
||||||
# Release-Artefakte (GoReleaser, Task 11)
|
# Release-Artefakte (GoReleaser, Task 11)
|
||||||
/dist/
|
/dist/
|
||||||
|
|
||||||
|
# SDD-Prozesszustand (Ledger, Briefs, Review-Diffs, Plan): liegt auf Branch sdd-state,
|
||||||
|
# nicht im Produkt-Code. Wiederherstellen: git checkout sdd-state -- .scratch .superpowers && git reset -q
|
||||||
|
/.scratch/
|
||||||
|
/.superpowers/
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# HANDOFF — talk 2.0, SDD-Lauf 2026-10-18 (Abendstand)
|
|
||||||
|
|
||||||
Für nahtlose Weiterarbeit am Arbeitslaptop (oder hier): dieser Stand ist komplett auf Gitea gepusht (main + feat/v1 + SDD-Arbeitszustand via force-add).
|
|
||||||
|
|
||||||
## Wo der Loop steht
|
|
||||||
|
|
||||||
- **Tasks 1–6: complete & review clean** (Details + aufgeschobene Minors: `.superpowers/sdd/plan/progress.md`)
|
|
||||||
- **Task 7 (talk wrap): implementiert, 5 Commits `a3424c3..acfba19`** — Dispatch wurde extern unterbrochen, Report-Datei fehlt, Review wurde nachträglich vom Controller angestoßen. **Erste Aktion beim Weitermachen: Ledger-Eintrag „Task 7: review …" lesen.** Falls Review Okay/Approved → complete setzen; falls Befunde → Fix-Loop frisch dispatchen (Implementer-Sessions sind maschinenlokal, NICHT fortsetzbar — neues Dispatch mit Brief + Befunden).
|
|
||||||
- **Tasks 8–13: noch nicht dispatched.** Reihenfolge/Blocker: siehe `.scratch/talk-2-0/plan.md`. Task 13 ist Nutzer-gated (ElevenLabs-Key-Rotation VOR Löschung von ~/talk.sh).
|
|
||||||
|
|
||||||
## SOFORT ZUERST morgen
|
|
||||||
|
|
||||||
1. **Task-5-Zeitbomben-Fix**: `TestLawState` (internal/queue/queue_test.go:491) hardcodiert "18:00-08:00" — Test rot zwischen 18:00-08:00 Uhr. Frisches Fix-Dispatch (dynamisch inaktives Fenster now+2h→now+3h, Assertions nicht schwächen), danach `go test -race -count=3 -run TestLawState ./internal/queue/` + volle Suite.
|
|
||||||
2. Task-7-Cross-Compile-Smoke vervollständigen (darwin/windows amd64, CGO_ENABLED=0) → dann Task 7 complete setzen.
|
|
||||||
|
|
||||||
## Wie weitermachen (exact)
|
|
||||||
|
|
||||||
1. Repo klonen/pullen: `git@code.m3ta.dev:m3tam3re/talk.git`, Branch `feat/v1`
|
|
||||||
2. SDD-Skill laden (`subagent-driven-development`), Ledger lesen: `.superpowers/sdd/plan/progress.md` (erste Zeile = plan-Identität; Tasks mit „complete" sind fertig — nicht re-dispatchen)
|
|
||||||
3. Nächster Task: Brief extrahieren mit `bash ~/.agents/skills/subagent-driven-development/scripts/task-brief .scratch/talk-2-0/plan.md <N>` — N = erster Task ohne complete-Zeile
|
|
||||||
4. Subagents auf `zai-coding-plan/glm-5.3#max` (Nutzer-Vorgabe)
|
|
||||||
|
|
||||||
## Umgebungsfakten (dev-relevant)
|
|
||||||
|
|
||||||
- Dev-Host Linux/NixOS, Go 1.26.8; **espeak fehlt, spd-say/speech-dispatcher vorhanden** (osbuiltin-Linux nutzt spd-say)
|
|
||||||
- Cross-Compile nur mit `CGO_ENABLED=0` (oto v3 purego) — Release-Task 11 muss das pinnen
|
|
||||||
- **Windows-Test-VM: 192.168.122.115 via WinRM** (Zugangsdaten beim Nutzer) — Task 11 bekommt echte Windows-Laufzeit-Verifikation
|
|
||||||
- Real-Key-Check (ElevenLabs/OpenAI) noch ausständig — Keys waren nicht in Env; natürlicher Nachtest via Task 8 `talk test`/`doctor` nach Key-Rotation
|
|
||||||
|
|
||||||
## Offene Nutzer-Aktionen
|
|
||||||
|
|
||||||
- [ ] **Push-Auth**: auf neuer Maschine `ssh-add ~/.ssh/gitea` (SSH-Config hat Host code.m3ta.dev → IdentityFile gitea)
|
|
||||||
- [ ] ElevenLabs-Key rotieren → dann Task 13 (Löschung ~/talk.sh) freigeben
|
|
||||||
- [ ] nixpkgs-PRs #24/#7 als superseded schließen + m3ta-nixpkgs talk/crunch deprecaten (Vorlage kommt in Task 12)
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
# Plan: talk 2.0 — V1 Implementationsplan
|
|
||||||
|
|
||||||
Plan: .scratch/talk-2-0/plan.md
|
|
||||||
Spec: docs/spec.md (bindende Autorität — Konflikte gehen zu ihren Lasten; Nachtrag 2026-10-24 beachten: ein Binary, kein talkd)
|
|
||||||
Branch: feat/v1 (ab main; Repo wird in Task 01 initialisiert)
|
|
||||||
|
|
||||||
## Global Constraints
|
|
||||||
|
|
||||||
(aus der Spec, bindend für alle Tasks — bei Konflikt gewinnt die Spec)
|
|
||||||
|
|
||||||
- **Sprache/Laufzeit:** Go; statische Binaries für Windows/macOS/Linux × amd64/arm64; keine Runtime-Abhängigkeiten (kein ffmpeg, kein mpv, kein Python).
|
|
||||||
- **Binaries:** `talk` (CLI) + `talkd` (Daemon). Modulname: `code.m3ta.dev/m3tam3re/talk`.
|
|
||||||
- **CLI-Surface (exakt):** Default-Befehl `talk "Text"` = non-blocking; Flags `--wait`, `--now`, `--priority`, `--voice`, `--lang`, `--provider`. Subcommands: `say`, `wrap`, `mute`, `unmute`, `status`, `cancel`, `voices`, `test`, `doctor`, `setup`. `--help` auf jedem Subcommand.
|
|
||||||
- **Provider-Kette (Fallback-Reihenfolge):** ElevenLabs → OpenAI TTS → Edge-TTS (frei, kein Key) → OS built-in (macOS `say`, Windows SAPI, Linux espeak/speech-dispatcher). Überschreibbar per Config/Flag. Keys Env-first: `TALK_ELEVENLABS_KEY`, `TALK_OPENAI_KEY`. Provider-Endpunkte konfigurierbar (Fake-bar für Tests).
|
|
||||||
- **Config:** optionale `config.toml` im platform-nativen Verzeichnis: Linux `~/.config/talk`, macOS `~/Library/Application Support/talk`, Windows `%APPDATA%\talk`. Env schlägt Datei. Voice-Mapping `voice.<provider>.<lang> = id`.
|
|
||||||
- **Audio:** Playback embedded via oto; Audio-Sink wählbar inkl. `null` (CI/Server). Notifications best effort via beeep.
|
|
||||||
- **Daemon:** talkd besitzt Queue (Serialisierung, Prioritäten, Rate-Limit Default 1 Ansage/30 s, prioritätsbewusstes Verwerfen), persistenten Mute-State (Datei im Config-Dir), Ruhezeiten `quiet_hours = "18:00-08:00"` (lokale Zeit; still verwerfen oder toast-only), HTTP-API `POST /speak` auf 127.0.0.1 mit konfigurierbarem CORS, MCP-Endpoint (Streamable HTTP) auf localhost.
|
|
||||||
- **Degraded Mode:** ohne talkd: Lock-File + sequenzielles Abspielen; nichts bricht still.
|
|
||||||
- **TUI (Bubble Tea):** interaktiv nur `setup` (Wizard), `status` (Live-Dashboard), `voices` (Picker mit Preview); alle anderen Befehle plain. Kein TTY → alle Befehle plain. Agenten-Pfade (Args/stdin) starten nie eine TUI.
|
|
||||||
- **Etikette (nur Skill-Doku, kein Daemon-Session-State):** Opt-in; Ansageinhalt = Status/nächster Schritt/Freigabe-Bedarf, nie Langantwort.
|
|
||||||
- **Testing:** Primäre Seam = Prozessgrenze talkd+CLI als Black Box mit injizierbaren Fakes über Config (Provider-Endpunkt → Fake-HTTP-Server mit Audio-Bytes; `audio.sink = "null"`). Reine Unit-Seams: langdetect, Config-Auflösung. Echt-Audio und OS-Toasts ungetestet (doctor-Rendering manuell). Tests testen nur extern beobachtbares Verhalten.
|
|
||||||
- **CI:** Gitea Actions — `.gitea/workflows/test.yml` läuft `go vet` + `go test ./...` bei jedem Push. `go build ./...` muss auf dem Dev-Host (Linux) grün sein; Cross-Compile via `GOOS`/`GOARCH` mindestens einmal in Task 11 verifiziert.
|
|
||||||
- **Lizenz:** MIT (LICENSE-Datei ab Task 01).
|
|
||||||
- **Doku-Sprache:** Deutsch (README, Skill).
|
|
||||||
- **Commits:** Conventional Commits; keine Pushes; keine Änderungen außerhalb des Repos (Ausnahme Task 12, siehe dort).
|
|
||||||
|
|
||||||
## Task 1: Repo-Skeleton & CI-Backbone
|
|
||||||
|
|
||||||
Keine Blocker. Liefert: initialisierbares, grünes Go-Repo.
|
|
||||||
|
|
||||||
- `git` bereits initialisiert (main); dieser Task entsteht auf Branch `feat/v1` (besteht bereits).
|
|
||||||
- Go-Modul `code.m3ta.dev/m3tam3re/talk` (go.mod, aktuelle stabile Go-Version des Dev-Hosts).
|
|
||||||
- Verzeichnisgerüst: `cmd/talk/`, `cmd/talkd/`, `internal/{provider,queue,audio,notify,config,api,langdetect}/` (jeweils mit placehalterfreiem Miniinhalt oder leer + doc.go — kein toter Code).
|
|
||||||
- `cmd/talk/main.go`: Subcommand-Gerüst (cobra oder stdlib flag — Entscheidung: **stdlib `flag` + eigene Subcommand-Dispatch**, keine externen CLI-Abhängigkeiten in T01) mit `--version` (zeigt `dev` + Commit via ldflags-Variablen, default `dev`) und allen Subcommands als `not implemented`-Stubs mit korrektem `--help`.
|
|
||||||
- `cmd/talkd/main.go`: startet, loggt eine Zeile, exit 0 (Platzhalter).
|
|
||||||
- `.gitea/workflows/test.yml`: `go vet ./...`, `go test ./...`, `go build ./...` bei push/PR.
|
|
||||||
- `.gitignore` (Go-Binaries, `dist/`), `LICENSE` (MIT, Copyright 2026 m3tam3re), `README.md`-Gerüst (Projektname, Ein-Satz-Zweck, Status WIP, Hinweis auf Spec).
|
|
||||||
- Mind. ein sinnvoller Test, der die CI-Struktur verifiziert (z. B. Version-String-Test).
|
|
||||||
- Abnahme: `go vet && go test && go build` grün; Workflow-Datei valider YAML; README/LICENSE vorhanden; Commit(s) conventional.
|
|
||||||
|
|
||||||
## Task 2: Erste Ansage end-to-end (OS-TTS)
|
|
||||||
|
|
||||||
Blockiert durch: 01. Liefert: `talk "Hallo Welt"` spricht über OS-TTS.
|
|
||||||
|
|
||||||
- `internal/langdetect`: reine Funktion `Detect(text string) string` → `"de"` | `"en"` (Default `"de"`); Heuristik: Common-Word-Listen + Umlaute/ß; tischgesteuerte Tests (de/en/gemischt/leer).
|
|
||||||
- `internal/provider`: Interface `Provider` mit `Name() string`, `Available(ctx) bool`, `Synthesize(ctx, text string, lang string) ([]byte, error)` (Rückgabe: dekodierbare Audio-Bytes, Format von der Implementierung dokumentiert; osbuiltin liefert WAV/PCM).
|
|
||||||
- Implementierung `osbuiltin`: Linux über `espeak`/`speech-dispatcher`-Fallback (externer Prozess — OK, es ist der Notnagel-Provider; wenn Binary fehlt: `Available() == false`), macOS über `say` (Ausgabe in temporäre WAV-Datei), Windows über SAPI (PowerShell `System.Speech`). Fehlende Binary/Runtime → klarer Fehler, kein Panik.
|
|
||||||
- `internal/audio`: `Sink`-Interface (`Play(ctx, data []byte) error`, `Name() string`); Implementierungen `oto` (embedded Playback, PCM/WAV) und `null` (verwirft, loggt). Auswahl per Config `audio.sink = "oto"|"null"` (Default `oto`).
|
|
||||||
- `internal/config`: minimale Auflösung: Defaults + TOML-Datei (falls vorhanden) + Env-Overrides (`TALK_AUDIO_SINK`); Platform-Pfad-Auflösung (`~/.config/talk` | `~/Library/Application Support/talk` | `%APPDATA%\talk`, respektive `XDG_CONFIG_HOME`/Env); tischgesteuerte Unit-Tests für Merge + Pfadlogik (Pfade injizierbar).
|
|
||||||
- `cmd/talk`: `say`-Pfad implementiert: Text (Arg oder stdin) → langdetect → Providerauswahl (hier: osbuiltin, Kette kommt in Task 03; Struktur so, dass `--provider`-Flag schon existiert und `osbuiltin` default ist) → Synthesize → Sink.Play. Blocking (Daemon kommt in Task 04).
|
|
||||||
- Abnahme: `talk "Hallo Welt"` erzeugt hörbare Ausgabe auf dem Dev-Host (Linux; espeak vorhanden oder als Dev-Abhängigkeit dokumentiert); `TALK_AUDIO_SINK=null` läuft ohne Ton durch; `go test ./...` grün (langdetect + config + provider-mit-fake-proc wo sinnvoll).
|
|
||||||
|
|
||||||
## Task 3: Provider-Kette mit Cloud-Anbietern
|
|
||||||
|
|
||||||
Blockiert durch: 02. Liefert: Vier-Stufen-Fallback mit Keys + Fake-barkeit.
|
|
||||||
|
|
||||||
- Provider-Implementierungen (je `internal/provider/<name>`):
|
|
||||||
- `elevenlabs`: POST `<endpoint>/v1/text-to-speech/<voiceID>`, Header `xi-api-key`, Body `{text, model_id: "eleven_multilingual_v2", voice_settings:{stability:0.5,similarity_boost:0.75}}`, Antwort = Audio-Bytes (mp3). Key: `TALK_ELEVENLABS_KEY`; Default-Voice-ID `KXxZd16DiBqt82nbarJx`. Endpunkt konfigurierbar (Default `https://api.elevenlabs.io`).
|
|
||||||
- `openai`: POST `<endpoint>/v1/audio/speech`, Bearer-Key `TALK_OPENAI_KEY`, Body `{model:"gpt-4o-mini-tts"|"tts-1", voice, input, response_format:"mp3"}` — Entscheidungsnotiz: Modell-String konfigurierbar, Default `tts-1`. Endpunkt Default `https://api.openai.com`.
|
|
||||||
- `edge`: Microsoft Edge-TTS-Endpunkt (kostenlos, kein Key) — WebSocket/REST nach aktuellem bekannten Protokoll (edge-tts-kompatibel, Stimmen z. B. `de-DE-KatjaNeural`/`en-US-AriaNeural`); Endpunkt konfigurierbar; bei Protokoll-Änderung: klarer Fehler.
|
|
||||||
- `osbuiltin`: existiert (Task 02).
|
|
||||||
- Kette `internal/provider/chain.go`: Resolver liefert geordnete Kette laut Config (Default-Reihenfolge oben); `--provider`-Flag und `provider = "…"`-Config pinnen; `Available()` prüft Key/Endpunkt; Synthesize fällt bei Fehler (HTTP != 2xx, Timeout, Netz) auf den nächsten durch; letzter Fehler wird geloggt/gemeldet, nie still.
|
|
||||||
- Voice-Mapping: `voice.<provider>.<lang>` aus config.toml; Defaults je Provider+Sprache hinterlegt (neutral, dokumentiert).
|
|
||||||
- Tests: jeder Cloud-Provider gegen `httptest`-Fake (200 mit Bytes; 500 → Fehler; 401 → Fehler) **außer edge** (WebSocket-Protokoll — edge wird über Interface + Fehlerpfad-Mock und eine als `testing.Short` überspringbare Integration-Testmarkierung behandelt; Dokumentationspflicht im Report, welche Teile fake-bar getestet sind); Kettentests: erster 500 → zweiter getroffen → Bytes.
|
|
||||||
- Abnahme: `talk "Test" --provider=elevenlabs` mit echtem Key funktioniert am Dev-Host ( einmalig manuell, im Report dokumentiert); ohne jeden Key wählt die Kette edge bzw. osbuiltin; `go test ./...` grün.
|
|
||||||
|
|
||||||
## Task 4: talkd-Daemon + Queue + Mute
|
|
||||||
|
|
||||||
Blockiert durch: 02, 03. Liefert: Daemon-betriebene, serialisierte Ansagen mit persistentem Mute.
|
|
||||||
|
|
||||||
- `cmd/talkd`: Daemon-Prozess; HTTP-Server auf `127.0.0.1:<port>` (Port Default `7800`, konfigurierbar `TALK_PORT`/config `server.port`); Endpunkte:
|
|
||||||
- `POST /speak` — JSON `{text, priority?, provider?, voice?, lang?, now?}` → enqueued, Antwort `202 {id}`; bei Mute: `202 {muted:true}` (verworfen, dokumentiertes Verhalten).
|
|
||||||
- `GET /status` — JSON: Queue-Länge, aktuelles Item, Mute-State, aktiver Provider, Version.
|
|
||||||
- `POST /cancel` — bricht aktive Ansage ab + leert Queue, Antwort `200 {cancelled:n}`.
|
|
||||||
- `POST /mute`, `POST /unmute` — toggelt persistenten Mute-State (Datei `mute` im Config-Dir).
|
|
||||||
- `internal/queue`: serialisierte Abarbeitung (eine Ansage gleichzeitig), Priorität (high > normal > low; gleiches Level: FIFO), `now`-Items springen vor; IDs (z. B. fortlaufend/zufällig kurz); konfigurierbares Rate-Limit existiert als Struktur, Limit-Wiring folgt Task 05 (Default unendlich in T04-Tests, dokumentiert).
|
|
||||||
- CLI-Anbindung: `talk say` (und Default) erkennt Daemon via `GET /status`; läuft er: enqueue + sofort zurück (non-blocking Default); `--wait` pollt bis abgearbeitet; `--now` setzt `now:true`; `mute`/`unmute`/`status`/`cancel` werden Daemon-Aufrufe mit plain Output.
|
|
||||||
- Degraded Mode: Daemon antwortet nicht → Lock-File `talk.lock` im Config-Dir, sequenzielles lokale Abspielen (best-effort; Kollision = warten), mute wird respektiert.
|
|
||||||
- Tests an der **Prozessgrenze** (primäre Seam): talkd mit Fake-Provider-Endpunkt + `audio.sink=null` starten; per CLI/HTTP enqueue; assert: Serialisierung (2 schnell nacheinander → nacheinander abgespielt, an Fake+Sink beobachtbar), Priorität, Mute-Persistenz über Daemon-Restart, cancel, non-blocking-Verhalten (say kehrt vor Abspielende zurück), Degraded Mode (Daemon tot → Lock-File-Pfad).
|
|
||||||
- Abnahme: alle Prozessgrenzen-Tests grün; `talkd &` + 3 parallele `talk`-Aufrufe → drei Ansagen nacheinander; `talk mute` → Daemon-Restart → weiter stumm.
|
|
||||||
|
|
||||||
## Task 5: Rate-Limit + Ruhezeiten
|
|
||||||
|
|
||||||
Blockiert durch: 04. Liefert: Daemon-Gesetz gegen Spam.
|
|
||||||
|
|
||||||
- Rate-Limit in Queue: Default **1 Ansage / 30 s** (konfigurierbar `queue.rate_limit = "30s"`); Tokens/Zeitfenster-Logik; `now:true` umgeht Warteschlangen-Wartezeit nicht das Rate-Limit; bei Überschreitung: Items mit `priority=low` werden **verworfen**, `normal` wartet, `high` darf konfigurierbar verstoßen (`queue.allow_high_burst = true|false`, Default true).
|
|
||||||
- `quiet_hours`: Config `quiet_hours = "18:00-08:00"` (lokale Zeit, 24h, über Mitternacht); Verhalten configbar: `quiet_mode = "discard"|"toast"` (Default `discard`); Mute (manuell) hat immer Vorrang; `now:true` bricht auch quiet hours (bewusster Ausbruch, dokumentiert).
|
|
||||||
- Tests an der Prozessgrenze: kurzes Rate-Limit (z. B. 1/s) konfigurieren; 5 enqueues → 1 abgespielt, low verworfen, normal geparkt; quiet_hours-Fenster künstlich um die aktuelle Zeit legen → discard/toast-Verhalten.
|
|
||||||
- Abnahme: Tests grün; `talk status` zeigt Rate-Limit- und Quiet-Hours-State.
|
|
||||||
|
|
||||||
## Task 6: CORS-Tür + MCP-Endpoint
|
|
||||||
|
|
||||||
Blockiert durch: 04. Liefert: Browser- und MCP-Anbindung auf localhost.
|
|
||||||
|
|
||||||
- CORS: `POST /speak` (und nur dieser) beantwortet `OPTIONS`-Preflight und setzt `Access-Control-Allow-Origin` aus Config-Liste `server.allowed_origins = ["https://chat.az-gruppe.com"]` (Wildcard `*` erlaubt aber nicht Default; leere Liste = kein CORS-Header). Tests: Preflight 204 + Header; nicht gelistete Origin → kein ACAO-Header.
|
|
||||||
- MCP: Streamable-HTTP-Endpoint `POST /mcp` (und ggf. `GET /mcp` für Stream) auf localhost; Implementierung des MCP-Protokolls (initialize, tools/list, tools/call) ohne schwergewichtige SDK — Decision: **eigene schlanke Implementierung des JSON-RPC-Subsets** (initialize, notifications/initialized, tools/list, tools/call), kein externer MCP-SDK-Dependency-Zwang; falls eine begründete leichte SDK existiert, im Report begründen.
|
|
||||||
- Ein Tool `speak`: Input-Schema `{text: string, priority?, provider?, lang?, now?}` → verhält sich wie POST /speak; Response = MCP-Tool-Result (Text: "queued: <id>" o. Ä.).
|
|
||||||
- `GET /status` um Felder `mcp: true, cors: [origins]` ergänzt (Compat).
|
|
||||||
- Tests: MCP-Handshake + tools/call an der Prozessgrenze (HTTP-Payloads literal getestet); CORS-Preflights.
|
|
||||||
- Abnahme: curl-Preflight von gelisteter Origin ok; ein MCP-fähiger Client (z. B. manuelle JSON-RPC-Curl-Sequenz im Test/Report) sieht `speak` und enqueued erfolgreich.
|
|
||||||
|
|
||||||
## Task 7: `talk wrap`
|
|
||||||
|
|
||||||
Blockiert durch: 04. Liefert: Befehlsummwicklung mit Ansage.
|
|
||||||
|
|
||||||
- `talk wrap -- <befehl> [args…]`: führt Befehl aus (stdin durchreichen, stdout/stderr durchreichen), misst Dauer, Ansage nach Ende: Erfolg: „<label> erfolgreich. Dauer: X." / Fehler: „<label> fehlgeschlagen, Code N." — Label = Config `wrap.label` oder der Basisname des Befehls; Ansage läuft über den Daemon-Pfad (enqueue, non-blocking; `--wait`-Flag respektiert).
|
|
||||||
- Exit-Code des gewrappten Befehls wird durchgereicht (talk-Fehler ≠ Befehls-Fehler: wrap exited mit Befehls-Code; wenn die Ansage selbst scheitert: Warning auf stderr, Befehls-Code bleibt).
|
|
||||||
- Dauer-Formatierung: menschlich („3 Minuten 42 Sekunden", lokalisiert de/en nach langdetect des Labels).
|
|
||||||
- Tests: Unit für Formatierung + Prozessgrenze: `talk wrap -- sleep 1` mit Fake/Sink null → enqueue-Call mit Erfolgs-Ansage verifiziert; fehlschlagender Befehl → Code 2 durchgereicht + Fehler-Ansage.
|
|
||||||
- Abnahme: `talk wrap -- sleep 2` annonciert Erfolg + Dauer; Exit-Codes korrekt.
|
|
||||||
|
|
||||||
## Task 8: `doctor` / `test` / `voices` (plain)
|
|
||||||
|
|
||||||
Blockiert durch: 03, 04. Liefert: Selbstdiagnose und plain Oberflächen.
|
|
||||||
|
|
||||||
- `talk doctor`: prüft und berichtet zeilenweise mit ✔/✘/⚠: Daemon erreichbar (Port/Status), Audio-Sink (oto initialisierbar; null → Hinweis), Provider-Verfügbarkeit je Stufe (Key vorhanden? Endpunkt erreichbar — leichtgewichtiger Probe-Call konfigurierbar `doctor.probe = true|false`, Default false → nur Key/Config-Checks, keine Netz-Calls), Notification-Daemon (beeep available), Config-Pfad + effektiv geladene Werte (Keys maskiert), Mute/Rate-Limit/quiet_hours-State via Daemon. Exit-Code 0 wenn kritikalfrei, 1 wenn ✘ (Daemon tot zählt nicht als ✘, sondern ⚠ mit Hinweis Degraded Mode). Machine-lesbar via `--json`.
|
|
||||||
- `talk test`: spricht feste Testphrase (de/en je `--lang`), zeigt verwendeten Provider + gemessene Latenz; ohne Daemon → direkter Pfad.
|
|
||||||
- `talk voices`: plain Tabelle `PROVIDER LANG VOICE-ID NAME DEFAULT` aus der vereinheitlichten Voice-Registry (statische Defaults je Provider + config-overrides); `--provider`/`--lang`-Filter; `--json`.
|
|
||||||
- Tests: doctor gegen laufenden/keinen Daemon an der Prozessgrenze (mit sink null + fake provider); voices-Output-Format Unit.
|
|
||||||
- Abnahme: bei kaputtem Setup (falscher Port, Key weg) nennt doctor die Punkte explizit; `talk test` funktioniert einmal end-to-end am Dev-Host.
|
|
||||||
|
|
||||||
## Task 9: Bubble-Tea-TUI: `setup` / `status` / `voices`
|
|
||||||
|
|
||||||
Blockiert durch: 03, 08. Liefert: geführtes Onboarding, Live-Dashboard, Voice-Picker.
|
|
||||||
|
|
||||||
- Dependency: `github.com/charmbracelet/bubbletea` (+ minimal nötige charm-libs wie lipgloss/bubbles — begründete Auswahl im Report).
|
|
||||||
- TTY-Erkennung: kein TTY (Pipe/CI/Agent) → plain-Fallback-Output derselben Info (nie Stacktrace, nie interaktive Schleife).
|
|
||||||
- `talk setup` (Wizard): Schritt 1 Daemon/Version prüfen (Hinweis wenn tot, fortfahrbar) → Schritt 2 Provider-Keys erkennen (env) + optional Key-Eingabe (maskiert) + Probe → Schritt 3 Stimme je Provider/Sprache probehören (Auswahl → `test`-Absage mit dieser Stimme) → Schritt 4 config.toml schreiben (existierende Datei: zusammenführen, Backup `.bak` anlegen) → Abschlussbericht. Abbruch jederzeit mit q/Ctrl-C, nichts halbfertig schreiben.
|
|
||||||
- `talk status` (interaktiv): Live-Ansicht Queue (Items mit ID/Priorität/Alter), aktuelles Item mit Fortschritt, Mute/quiet hours/rate-limit, Provider-Kette mit Verfügbarkeit; Aktualisierung via Poll; `m` = mute-toggle, `c` = cancel, `q` = quit — Tasten in Footer.
|
|
||||||
- `talk voices` (interaktiv): Liste filterbar (`/`), Enter = Probe abspielen (über Daemon), `s` = als Default in config setzen (mit Bestätigung).
|
|
||||||
- Plain-Pfade von 08 bleiben unangetastet (TTY-Weiche in derselben Command-Funktion).
|
|
||||||
- Tests: Modell-Unit-Tests (Wizard-State-Machine: Schritte, Abbruch, Merge-Entscheidung; Status-Rendering-Funktion; Voices-Filter) — TUI-Renderlogik als reine Funktionen halten; Prozessgrenze: `talk status < /dev/null` → plain, exit 0.
|
|
||||||
- Abnahme: `talk setup` durchläuft am Dev-Host komplett (mit Fake-Provider-Endpunkt demonstriert im Report/Tests); keine TTY-Pfade brechen.
|
|
||||||
|
|
||||||
## Task 10: Skill + AGENTS.md-Snippet + README
|
|
||||||
|
|
||||||
Blockiert durch: 08. Liefert: Etikette-Doku als konsumierbares Skill-Artefakt.
|
|
||||||
|
|
||||||
- `skills/talk/SKILL.md` (Frontmatter analog etablierter Skills: name, description mit Triggern) mit: When to Use (User-Trigger ODER Standing-Order in AGENTS.md — **striktes Opt-in**), When NOT to Use, Usage (CLI-Beispiele: say/wrap/status/mute; stdin-Pipe; --wait/--now/--priority), Etikette (Ansageinhalt = Status/nächster Schritt/was der Nutzer tun soll; max 1–2 Sätze; niemals Langantwort; session-sticky bis Widerruf; Mute/quiet hours respektieren — Daemon erzwingt es ohnehin), Degraded-Verhalten, Pitfalls (Key-Env, kein Ton → doctor).
|
|
||||||
- `skills/talk/AGENTS-snippet.md`: 3-Zeilen-Block für Projekt-AGENTS.md (Standing-Order-Beispiel).
|
|
||||||
- `README.md` ausbauen: Quickstart (Installation Release-Artefakt/Nix, `talk setup`, erster Test), CLI-Referenz (alle Befehle/Flags), Config-Referenz (vollständiges config.toml-Beispiel mit allen Keys aus den Tasks 02–09), Daemon (talkd, Autostart-Hinweise je OS), Provider-Tabellen (Keys, Kosten, Defaults), Fleet-Hinweis (Env-Pattern TALK_*, interne Spiegelung, doctor als Diagnose), Etikette-Sektion, FAQ (kein Ton? Browser/LibreChat-Später, MCP-Anbindung-Beispiel).
|
|
||||||
- Abnahme: README deckt jede CLI-Surface aus den Global Constraints ab; Skill enthält die Opt-in-Policy wörtlich-diszipliniert; keine neuen Code-Änderungen nötig (Docs-only-Task — Tests: `go test ./...` bleibt grün, Markdown-Links intern valide).
|
|
||||||
|
|
||||||
## Task 11: Release-Pipeline + flake.nix
|
|
||||||
|
|
||||||
Blockiert durch: 09, 10. Liefert: getaggte Multi-Platform-Artefakte + Nix-Bezug.
|
|
||||||
|
|
||||||
- `.goreleaser.yaml`: Builds `talk` + `talkd` (zwei Binaries), Targets linux/darwin/windows × amd64/arm64 (Windows-Suffix `.exe`), Archiv je OS; **Asset-Namensvertrag: `talk_<os>_<arch>`** (+ `.zip`/`.tar.gz` je Konvention, Checksums-Datei `checksums.txt`), ldflags: Version-Injection (`-X main.version={{.Version}}` bzw. passend zur T01-Struktur).
|
|
||||||
- `.gitea/workflows/release.yml`: bei Tag `v*`: test → goreleaser → Upload zu Gitea-Releases.
|
|
||||||
- Cross-Compile-Verifikation: einmalig lokal `GOOS/GOARCH`-Matrix bauen (im Report dokumentiert); CGO-Grundsatz:oto braucht gopherus? → **Verpflichtung: oto-Abhängigkeit muss cross-compilierbar sein (reiner Go-Audiopfad oder build-tag-gelöste Plattformpfade); falls CGO nötig würde: Stattdessen plattformnative Fallback-Player als build-tag-Varianten** — im Report die gefundene Lösung dokumentieren; das ist der risikoreichste Punkt dieses Tasks.
|
|
||||||
- `flake.nix`: Package `talk` + `talkd` (buildGoModule), `apps`/`packages`-Outputs, devShell; ersetzt langfristig m3ta-nixpkgs talk/crunch (Hinweis-Kommentar in flake.nix).
|
|
||||||
- Abnahme: `nix build .#talk` funktioniert am Dev-Host; `goreleaser build --snapshot --clean` produziert alle Targets ohne Fehler; Workflow-YAML valide.
|
|
||||||
|
|
||||||
## Task 12: Legacy-Migration (AGENTS-Repo)
|
|
||||||
|
|
||||||
Blockiert durch: 10, 11. **Achtung: greift in ein ANDERES Repo (`~/p/AI/AGENTS`) — ausschließlich die zwei Skill-Dateien `skills/voice-notify/SKILL.md` und `skills/crunch-jobs/SKILL.md` als Redirect-Stubs umschreiben; kein Commit dort ohne die im Brief dokumentierte Reihenfolge; kein Push.**
|
|
||||||
|
|
||||||
- Beide SKILL.md-Stubs: Frontmatter behalten (description kürzt auf „DEPRECATED: replaced by talk 2.0 …"), Body: kurzer Umstiegs-Text (neues Repo, neuer Skill-Name, CLI-Beispiel, Link zur talk-README), keine alten Anleitungen mehr.
|
|
||||||
- `docs/migration-notes.md` im talk-Repo: Checkliste Restarbeiten (PRs #24/#7 in m3ta-nixpkgs als superseded schließen — Nutzer-Aktion; m3ta-nixpkgs talk/crunch deprecaten — Nutzer-Aktion mit Vorlage-Text; Fleet-Ausrollung plant).
|
|
||||||
- Abnahme: Stubs existieren und sind eindeutig; migration-notes listet alle Nutzer-Aktionen; im AGENTS-Repo liegt ein unpushed Commit auf einem neuen Branch `chore/talk2-migration` (kein main-Commit!).
|
|
||||||
|
|
||||||
## Task 13: Altlast-Entsorgung talk.sh (Nutzer-gated)
|
|
||||||
|
|
||||||
Blockiert durch: KEINE Tasks, aber durch **Nutzer-Aktion: ElevenLabs-Key-Rotation im Dashboard**. Ohne bestätigte Rotation NICHT ausführen (destruktiv + sicherheitsrelevant). Wenn der Nutzer Rotation bestätigt hat: `~/talk.sh` löschen (rm), in migration-notes abhaken, Ledger-Eintrag. Dieser Task bleibt parked bis zur Bestätigung.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
.scratch/talk-2-0/plan.md
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# SDD ledger — plan: .scratch/talk-2-0/plan.md
|
|
||||||
|
|
||||||
Repo: ~/p/talk, Branch feat/v1, Start-BASE: (initial commit auf main)
|
|
||||||
Model-Politik: alle Subagents auf zai-coding-plan/glm-5.3#max (Nutzer-Vorgabe).
|
|
||||||
|
|
||||||
## Pre-Flight Scan (Tabelle)
|
|
||||||
|
|
||||||
| Paar/Task | Befund | Ruling |
|
|
||||||
|---|---|---|
|
|
||||||
| T01 ↔ T02–T11 | T01 erzeugt Modul-Layout, das alle konsumieren | Kein Konflikt; T01 zuerst |
|
|
||||||
| T02 ↔ T03 | T02 definiert Provider-Interface, T03 erweitert Implementierungen | Sequenziell (Kante gesetzt) — Interface-Signatur in T02 ist Vertrag |
|
|
||||||
| T04 ↔ T05 | beide berühren internal/queue | Sequenziell; T04 baut Struktur mit Rate-Limit-Platzhalter, T05 verdrahtet |
|
|
||||||
| T04 ↔ T06 | gleiche HTTP-Oberfläche (/speak) | Sequenziell; T06 erweitert Router, rewrites nicht |
|
|
||||||
| T07 ↔ T04 | wrap nutzt enqueue-Pfad | Sequenziell; kein Konflikt |
|
|
||||||
| T08 ↔ T09 | teilen cmd/talk-Dispatch; T09 darf plain-Pfade nicht verändern | Im Brief verankert (TTY-Weiche in derselben Command-Funktion) |
|
|
||||||
| T11 oto-Cross-Compile | Risiko: CGO-Abhängigkeit bricht statische Targets | Plan verankert Ausweg: build-tag-Plattform-Fallback-Player; Report-Pflicht. Load-bearing für Release |
|
|
||||||
| T12 greift in AGENTS-Repo | Side effect außerhalb des Worktrees | Nutzer hat Redirect-Stubs in Grilling R4/Q2 explizit freigegeben → dispatch-fähig; nur Branch chore/talk2-migration, kein Push, keine main-Commits |
|
|
||||||
| T13 talk.sh-Löschung | destruktiv + Key noch nicht rotiert | Parked bis Nutzer Rotation bestätigt. Kein Dispatch |
|
|
||||||
| T02 espeak-Verfügbarkeit | Dev-Host braucht espeak für OS-TTS-Demo | Faktencheck läuft; falls fehlend: Demo über edge/osbuiltin-Fallback dokumentieren, espeak-Installation bleibt Nutzer-Entscheid |
|
|
||||||
|
|
||||||
Keine plan-mandierten Rubrik-Verstöße gefunden (keine assert-losen Tests, keine Duplikations-Vorgaben).
|
|
||||||
|
|
||||||
## Verlauf
|
|
||||||
|
|
||||||
(befüllt im Lauf)
|
|
||||||
Task 1: complete (commits c8f3902..fa4f193, review clean; Approved, 0 Critical/Important)
|
|
||||||
Task 1: minor (deferred): -h-Zweig ungetestet; Version-Test mutiert Package-Var; README-.scratch-Links beim Push prüfen; help-Pseudocommand als additive Surface akzeptiert; "[Flags]"-Kosmetik bei flaglosen Befehlen
|
|
||||||
Task 1: ⚠️ CI-Runner-Validierung → löst sich beim ersten Push (Gitea-Runner führt Workflow aus)
|
|
||||||
Task 2: fix round 1/5 (1 Important + 1 Minor addressed, 0 open; commits 8306c6b..ae9f3b6)
|
|
||||||
Task 2: complete (commits fa4f193..ae9f3b6, review clean nach Fix)
|
|
||||||
Task 2: minor (deferred): oto-Context-Fehler dauerhaft gecached (Task-4-Thema); WAV-chunk int-Konversion 32-bit-theoretisch; bare talk am Terminal ungetestet
|
|
||||||
Task 2: Interface-Notiz für Task 3: NewSink(name string, log io.Writer); DirectSpeaker-Erweiterung + ErrNoByteOutput als letzte Kettenstufe berücksichtigen; oto v3.5.0 + BurntSushi/toml v1.6.0 gepinnt; Cross-Compile nur mit CGO_ENABLED=0 (Release-Task merken)
|
|
||||||
Env-Fakt (Nutzer, 2026-10-18): Windows-Test-VM 192.168.122.115 via WinRM erreichbar (Zugangsdaten vom Nutzer im Chat, bewusst NICHT in Dateien). Ruling: Task 11 erhält echte Windows-Laufzeit-Verifikation (SAPI, Toast, Binary-Smoke) statt nur Cross-Compile; Task 8 doctor darf die VM opportunistisch nutzen. WinRM-Zugriff via ansible/pywinrm zum Zeitpunkt der Nutzung prüfen.
|
|
||||||
Task 3: implementiert (commits 7a843ae..8351d40), Review ausstehend
|
|
||||||
Task 3: fix round 1/5 (1 Important addressed, 0 open; commits 8351d40..3b44605)
|
|
||||||
Task 3: complete (commits ae9f3b6..3b44605, review clean nach Fix)
|
|
||||||
Task 3: Ruling: Real-Key-Abnahme (plan-mandiert) auf Nutzer-Checkpoint NACH ElevenLabs-Key-Rotation verschoben — Keys nachweislich nicht in Env; Ersatzbeweise (wörtliche Contract-Fakes, realer Edge-Lauf mit 17 kB Audio) stark; natürlicher Nachtest via T8 test/doctor. Kostet falls falsch: ein realer API-Vertrag könnte abweichen — Risiko eingegrenzt.
|
|
||||||
Task 3: minor (deferred): parseInterspersed bricht --Shielding; io.ReadAll unbegrenzt; unbekannte Config-Sections ohne Warnung; edgeClockSkew package-global; Kommentar-Typos
|
|
||||||
Task 4-Kontext: Chain (Speak/Synthesize/SpeakDirect, walk einmalig), Player-Interface strukturell == audio.Sink, injizierbar Getenv/Log/Player — Daemon MUSS Chain unverändert wiederverwenden
|
|
||||||
Task 4: complete (commits 3b44605..dc94f45, review clean; Approved, 0 Critical/Important)
|
|
||||||
Task 4: minor (deferred): Stale-Lock-Remove-Race (rename-before-remove/PID-Check); waitForItem ohne Timeout; Lost-202-Doppelansage (Idempotien-Key später); setMute lumped errors; Timing-Margins CI-Flake-Risiko; Port-in-use-Pfad ungetestet
|
|
||||||
Task 4-Kontext für Task 5: RateLimiter-Struktur in internal/queue/rate.go wartet auf Verdrahtung (Seam dokumentiert); /status-Felder erweiterbar; queue rank/loop unverändert lassen
|
|
||||||
Task 5: complete (commits dc94f45..24ceff3, review clean; Approved, 0 Critical/Important)
|
|
||||||
Task 5: minor (deferred): quiet_mode TOML-Validierung fehlt (env hat sie); Worker-zeitige Verwurf/toast-Zweige ungetestet; TestRuhezeitToast schwache Log-Assertion ("ersetzt" statt "toast"); Timing-Margins CI-Flake-Risiko; Degraded respektiert nur Mute (akzeptiert, dokumentiert)
|
|
||||||
Task 6-Kontext: api/server.go Router + types.go StatusResponse (additive Felder via omitempty); Gesetz-Präzedenz mute > now > quiet > rate in rate.go decide(); Prozess-Test-Harness test/ mit Fake-Provider
|
|
||||||
Task 6: complete (commits 24ceff3..30bd535, review clean; Approved, 0 Critical/Important; nach Resume nach externer Unterbrechung)
|
|
||||||
Task 6: minor (deferred): Preflight prüft ACRM nicht; Allow-Header-Inkonsistenz GET /mcp; Origin-Syntax-Validierung; MaxBody-Fehlercode -32700; voice im Tool-Schema nicht beworben
|
|
||||||
Task 6-Kontext: admitSpeak = gemeinsame Aufnahme (Validierung → Mute → Enqueue); MCP + /speak strukturell paritätisch; Remote origin gesetzt (git@code.m3ta.dev) — PUSH BLOCKIERT bis Nutzer ssh-add ~/.ssh/gitea ODER Token; Repo via HTTPS öffentlich lesbar
|
|
||||||
Task 6: Env-Fakt: Remote https://code.m3ta.dev/m3tam3re/talk vom Nutzer angelegt
|
|
||||||
Task 7: implementiert (commits a3424c3..acfba19, 5 Conventional) — Dispatch extern unterbrochen NACH Commits, VOR Report/Return; Report-Datei fehlt. Review nachträglich angestoßen (ohne Implementer-Report, Diff-vs-Brief).
|
|
||||||
Task 7: Handoff-Snapshot für Gitea-Push erstellt (HANDOFF.md + force-add dieses Workspaces)
|
|
||||||
Task 7: review — Approved (0 Critical/Important; Gates-Evidence war einziger Vorbehalt)
|
|
||||||
Task 7: gates evidence: gofmt clean ✓, vet ✓, go test -race → ROT durch TestLawState (Task-5-Zeitbombe, nicht Task 7); Cross-Compile-Smoke AUSSTEHEND → morgen nach TestLawState-Fix komplettieren
|
|
||||||
Task 7: minor (deferred): formatDuration 60s-Grenzfall ungetestet; keine Signalbehandlung talk selbst; --Split vs. Flag-Wert "--"; keine Tages-Einheit; Timing-Headroom CI
|
|
||||||
Task 5: fix round 1/5 PENDING (post-complete discovery): TestLawState (queue_test.go:491) hardcodiert Ruhezeit "18:00-08:00" → Test + CI rot für 14h/Tag (18:00-08:00). Fix: dynamisches garantiert-inaktives Fenster (now+2h→now+3h), Assertion-Stärke halten. Fix-Dispatch 2× extern unterbrochen, NICHT ausgeführt — morgen ERSTE Aktion: frisches Fix-Dispatch (Task-5-Session maschinenlokal).
|
|
||||||
Abendabschluss 2026-10-18: Stand gepusht (main + feat/v1 + SDD-Workspace); offene Front: TestLawState-Fix, Task-7-Cross-Compile-Smoke, Tasks 8-13, Task 13 Nutzer-Gate Key-Rotation, Push-Auth auf Arbeitslaptop (ssh-add ~/.ssh/gitea)
|
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,145 +0,0 @@
|
|||||||
# Review package: 8306c6b..HEAD
|
|
||||||
|
|
||||||
## Commits
|
|
||||||
ae9f3b6 fix(audio): NullSink-Verwurf-Zeile im CLI-Pfad sichtbar machen
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
cmd/talk/say.go | 2 +-
|
|
||||||
cmd/talk/say_test.go | 7 ++++++-
|
|
||||||
internal/audio/audio.go | 12 +++++++-----
|
|
||||||
internal/audio/audio_test.go | 17 +++++++++++++++--
|
|
||||||
internal/langdetect/detect.go | 2 +-
|
|
||||||
internal/langdetect/detect_test.go | 1 +
|
|
||||||
6 files changed, 31 insertions(+), 10 deletions(-)
|
|
||||||
|
|
||||||
## Diff
|
|
||||||
cmd/talk/say.go --- Go
|
|
||||||
106 return exitFailure
|
|
||||||
107 }
|
|
||||||
108
|
|
||||||
109 sink, err := audio.NewSink(cfg.Audio.Sink, stderr)
|
|
||||||
110 if err != nil {
|
|
||||||
111 fmt.Fprintf(stderr, "talk: %v\n", err)
|
|
||||||
112 return exitFailure
|
|
||||||
|
|
||||||
cmd/talk/say_test.go --- 1/2 --- Go
|
|
||||||
52 52
|
|
||||||
53 // TestSayArgumentMitNullSink: `talk 53 // TestSayArgumentMitNullSink: `talk
|
|
||||||
.. "Hallo Welt"` läuft mit .. "Hallo Welt"` läuft mit
|
|
||||||
54 // TALK_AUDIO_SINK=null tonlos durch 54 // TALK_AUDIO_SINK=null tonlos durch
|
|
||||||
.. (Abnahmekriterium) — Synthese läuft .. (Abnahmekriterium) — Synthese läuft
|
|
||||||
55 // (fake espeak wurde gerufen), die 55 // (fake espeak wurde gerufen), und
|
|
||||||
.. Wiedergabe wird verworfen. .. die Wiedergabe wird mit sichtbarer
|
|
||||||
.. 56 // Verwurf-Zeile auf stderr verworfe
|
|
||||||
.. .. n (extern beobachtbares Verhalten de
|
|
||||||
.. .. s
|
|
||||||
.. 57 // null-Sinks auf dem CLI-Pfad).
|
|
||||||
56 func TestSayArgumentMitNullSink(t *t 58 func TestSayArgumentMitNullSink(t *t
|
|
||||||
.. esting.T) { .. esting.T) {
|
|
||||||
57 seen := withFakeEspeak(t) 59 seen := withFakeEspeak(t)
|
|
||||||
58 setGetenv(t, map[string]string{" 60 setGetenv(t, map[string]string{"
|
|
||||||
.. TALK_AUDIO_SINK": "null"}) .. TALK_AUDIO_SINK": "null"})
|
|
||||||
|
|
||||||
cmd/talk/say_test.go --- 2/2 --- Go
|
|
||||||
65 67 if _, err := os.Stat(seen); err != nil {
|
|
||||||
66 68 t.Errorf("Synthese lief nicht (fake espeak nicht gerufen): %v", err)
|
|
||||||
67 69 }
|
|
||||||
.. 70 if !strings.Contains(errBuf.String(), "verworfen (null sink)") {
|
|
||||||
.. 71 t.Errorf("stderr = %q, will Verwurf-Zeile des null-Sinks (Synthese war erfolgreich)", errBuf.String())
|
|
||||||
.. 72 }
|
|
||||||
68 73 }
|
|
||||||
69 74
|
|
||||||
70 75 // TestSaySubcommandWieDefault: `talk say "Text"` verhält sich wie der
|
|
||||||
|
|
||||||
internal/audio/audio.go --- Go
|
|
||||||
24 } 24 }
|
|
||||||
25 25
|
|
||||||
26 // NewSink liefert den Sink zum konf 26 // NewSink liefert den Sink zum konf
|
|
||||||
.. igurierten Namen ("oto" oder "null") .. igurierten Namen ("oto" oder "null")
|
|
||||||
.. . .. .
|
|
||||||
.. 27 // log ist ein optionaler Log-Output
|
|
||||||
.. .. — die CLI übergibt ihr stderr, über
|
|
||||||
.. .. den
|
|
||||||
.. 28 // der null-Sink seine Verwurf-Zeile
|
|
||||||
.. .. schreibt (nil = still verwerfen); d
|
|
||||||
.. .. er
|
|
||||||
27 // Unbekannte Namen sind ein klarer 29 // oto-Sink braucht kein Log. Unbeka
|
|
||||||
.. Fehler. .. nnte Namen sind ein klarer Fehler.
|
|
||||||
28 func NewSink(name string) (Sink, err 30 func NewSink(name string, log io.Wri
|
|
||||||
.. or) { .. ter) (Sink, error) {
|
|
||||||
29 switch name { 31 switch name {
|
|
||||||
30 case NameOto: 32 case NameOto:
|
|
||||||
31 return OtoSink{}, nil 33 return OtoSink{}, nil
|
|
||||||
32 case NameNull: 34 case NameNull:
|
|
||||||
33 return NullSink{}, nil 35 return NullSink{W: log}, nil
|
|
||||||
34 default: 36 default:
|
|
||||||
35 return nil, fmt.Errorf("unbe 37 return nil, fmt.Errorf("unbe
|
|
||||||
.. kannter audio sink %q (erlaubt: %q, .. kannter audio sink %q (erlaubt: %q,
|
|
||||||
.. %q)", name, NameOto, NameNull) .. %q)", name, NameOto, NameNull)
|
|
||||||
36 } 38 }
|
|
||||||
37 } 39 }
|
|
||||||
38 40
|
|
||||||
39 // NullSink verwirft jede Ansage ohn 41 // NullSink verwirft jede Ansage ohn
|
|
||||||
.. e Ton — für CI, Server und Tests. Wu .. e Ton — für CI, Server und Tests. Wu
|
|
||||||
.. rde .. rde
|
|
||||||
40 // ein W gesetzt, loggt Play eine Ze 42 // ein W gesetzt (NewSink reicht daf
|
|
||||||
.. ile darüber (der CLI-Pfad hängt stde .. ür den Log-Output der CLI durch), lo
|
|
||||||
.. rr .. ggt
|
|
||||||
41 // an, damit „läuft ohne Ton durch" 43 // Play eine Zeile darüber, damit „l
|
|
||||||
.. auch sichtbar bleibt). .. äuft ohne Ton durch" sichtbar bleibt
|
|
||||||
.. .. .
|
|
||||||
42 type NullSink struct { 44 type NullSink struct {
|
|
||||||
43 W io.Writer // optionaler Log-Ou 45 W io.Writer // optionaler Log-Ou
|
|
||||||
.. tput; nil = still verwerfen .. tput; nil = still verwerfen
|
|
||||||
44 } 46 }
|
|
||||||
|
|
||||||
internal/audio/audio_test.go --- Go
|
|
||||||
115 115
|
|
||||||
116 116 func TestNewSink(t *testing.T) {
|
|
||||||
117 117 for _, name := range []string{NameOto, NameNull} {
|
|
||||||
118 118 s, err := NewSink(name, nil)
|
|
||||||
119 119 if err != nil {
|
|
||||||
120 120 t.Fatalf("NewSink(%q): %v", name, err)
|
|
||||||
121 121 }
|
|
||||||
122 122 if s.Name() != name {
|
|
||||||
123 123 t.Errorf("NewSink(%q).Name() = %q", name, s.Name())
|
|
||||||
124 124 }
|
|
||||||
125 125 }
|
|
||||||
126 126 if _, err := NewSink("mpv", nil); err == nil {
|
|
||||||
127 127 t.Error("NewSink(\"mpv\") soll fehlschlagen")
|
|
||||||
128 128 }
|
|
||||||
... 129 t.Run("null-Sink loggt über den Factory-Writer", func(t *testing.T) {
|
|
||||||
... 130 var buf bytes.Buffer
|
|
||||||
... 131 s, err := NewSink(NameNull, &buf)
|
|
||||||
... 132 if err != nil {
|
|
||||||
... 133 t.Fatalf("NewSink(%q): %v", NameNull, err)
|
|
||||||
... 134 }
|
|
||||||
... 135 if err := s.Play(context.Background(), []byte("audio")); err != nil {
|
|
||||||
... 136 t.Fatalf("Play: %v", err)
|
|
||||||
... 137 }
|
|
||||||
... 138 if !strings.Contains(buf.String(), "verworfen (null sink)") {
|
|
||||||
... 139 t.Errorf("Log = %q, will Verwurf-Zeile über den Factory-Writer", buf.String())
|
|
||||||
... 140 }
|
|
||||||
... 141 })
|
|
||||||
129 142 }
|
|
||||||
130 143
|
|
||||||
131 144 func TestNullSinkVerwirftUndLoggt(t *testing.T) {
|
|
||||||
|
|
||||||
internal/langdetect/detect.go --- Go
|
|
||||||
68 "finished": {}, "started": {}, "completed": {}, "error": {},
|
|
||||||
69 "warning": {}, "thanks": {}, "you": {}, "your": {},
|
|
||||||
70 "we": {}, "it": {}, "this": {}, "that": {}, "these": {},
|
|
||||||
71 "those": {}, "was": {}, "were": {}, "are": {}, "be": {},
|
|
||||||
72 "been": {}, "has": {}, "have": {}, "had": {}, "can": {},
|
|
||||||
73 "will": {}, "would": {}, "should": {}, "could": {}, "just": {},
|
|
||||||
74 "only": {}, "still": {}, "already": {}, "now": {}, "today": {},
|
|
||||||
|
|
||||||
internal/langdetect/detect_test.go --- Go
|
|
||||||
15 15 {"nur whitespace", " \n\t ", "de"},
|
|
||||||
16 16 {"nur interpunktion", "!!! ??? ...", "de"},
|
|
||||||
17 17 {"deutsch satz", "Der Build wurde erfolgreich abgeschlossen und das Deployment ist fertig.", "de"},
|
|
||||||
.. 18 {"deutsch frage mit was", "Was macht er?", "de"},
|
|
||||||
18 19 {"deutsch umlaute", "Größe früh Ärzteübung — schön daß es klappt", "de"},
|
|
||||||
19 20 {"ß alleine", "weiß", "de"},
|
|
||||||
20 21 {"englisch satz", "The build has finished and the deployment is done.", "en"},
|
|
||||||
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
# Review package: 8351d40..HEAD
|
|
||||||
|
|
||||||
## Commits
|
|
||||||
3b44605 fix(provider): Chain.Speak läuft im Direkt-Endgame nur einmal durch die Kette
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
internal/provider/chain.go | 39 +++++++++++++++++++-----------
|
|
||||||
internal/provider/chain_test.go | 53 +++++++++++++++++++++++++++++++++++++++++
|
|
||||||
2 files changed, 78 insertions(+), 14 deletions(-)
|
|
||||||
|
|
||||||
## Diff
|
|
||||||
internal/provider/chain.go --- 1/2 --- Go
|
|
||||||
205 } 205 }
|
|
||||||
206 return nil, fmt.Errorf("%w 206 return nil, fmt.Errorf("%w
|
|
||||||
... (Stufe %s spricht nur direkt)", Err ... (Stufe %s spricht nur direkt)", Err
|
|
||||||
... NoByteOutput, r.directName) ... NoByteOutput, r.directName)
|
|
||||||
207 } 207 }
|
|
||||||
208 return nil, fmt.Errorf("kette: 208 return nil, r.noTierErr()
|
|
||||||
... keine Stufe konnte sprechen (nicht ...
|
|
||||||
... verfügbar: %s; fehlgeschlagen: %s)" ...
|
|
||||||
... , ...
|
|
||||||
209 joinOr(r.skipped, "keine"), ...
|
|
||||||
... joinOr(r.failed, "keine")) ...
|
|
||||||
210 } 209 }
|
|
||||||
211 210
|
|
||||||
212 // SpeakDirect implementiert Direct 211 // SpeakDirect implementiert Direct
|
|
||||||
... Speaker: Direktansage über die Stuf ... Speaker: Direktansage über die Stuf
|
|
||||||
... e, ... e,
|
|
||||||
|
|
||||||
internal/provider/chain.go --- 2/2 --- Go
|
|
||||||
225 joinOr(r.skipped, "keine"), 224 joinOr(r.skipped, "keine"),
|
|
||||||
... joinOr(r.failed, "keine")) ... joinOr(r.failed, "keine"))
|
|
||||||
226 } 225 }
|
|
||||||
227 226
|
|
||||||
228 // Speak ist die komplette Ansage: 227 // Speak ist die komplette Ansage i
|
|
||||||
... Synthese mit Fallback → Wiedergabe ... n EINEM Ketten-Durchlauf: Synthese
|
|
||||||
... über ... mit
|
|
||||||
229 // den Player; fällt die Kette auf 228 // Fallback → Wiedergabe über den P
|
|
||||||
... die Direkt-Stufe zurück, entscheide ... layer; fällt die Kette auf die Dire
|
|
||||||
... t der ... kt-
|
|
||||||
230 // Player-Name über Ton (alles auße 229 // Stufe zurück, entscheidet der Pl
|
|
||||||
... r „null") oder tonloses Verwerfen ( ... ayer-Name über Ton (alles außer „nu
|
|
||||||
... null: ... ll")
|
|
||||||
231 // CI/Server bleiben fehler- und to 230 // oder tonloses Verwerfen (null: C
|
|
||||||
... nfrei, der Verwurf wird protokollie ... I/Server bleiben fehler- und tonfre
|
|
||||||
... rt). ... i,
|
|
||||||
... 231 // der Verwurf wird protokolliert).
|
|
||||||
... ... Der Durchlauf findet bewusst genau
|
|
||||||
... 232 // einmal statt: fehlgeschlagene Cl
|
|
||||||
... ... oud-Stufen werden nicht ein zweites
|
|
||||||
... ... Mal
|
|
||||||
... 233 // angefragt (keine doppelten POSTs
|
|
||||||
... ... auf bezahlten APIs, keine doppelte
|
|
||||||
... ... n
|
|
||||||
... 234 // Protokoll-Zeilen), und Bytes aus
|
|
||||||
... ... diesem Durchlauf werden abgespielt
|
|
||||||
... ... ,
|
|
||||||
... 235 // statt sie wegzuwerfen und neu zu
|
|
||||||
... ... laufen. Nur ein Fehler aller Stufe
|
|
||||||
... ... n
|
|
||||||
232 // Nur ein Fehler aller Stufen oder 236 // oder eine fehlgeschlagene Wieder
|
|
||||||
... eine fehlgeschlagene Wiedergabe si ... gabe sind Fehler.
|
|
||||||
... nd ...
|
|
||||||
233 // Fehler. ...
|
|
||||||
234 func (c *Chain) Speak(ctx context.C 237 func (c *Chain) Speak(ctx context.C
|
|
||||||
... ontext, text, lang string) error { ... ontext, text, lang string) error {
|
|
||||||
235 data, err := c.Synthesize(ctx, 238 r := c.walk(ctx, text, lang)
|
|
||||||
... text, lang) ...
|
|
||||||
236 switch { 239 switch {
|
|
||||||
237 case err == nil: 240 case r.data != nil:
|
|
||||||
238 if c.cfg.Player == nil { 241 if c.cfg.Player == nil {
|
|
||||||
239 return fmt.Errorf("kett 242 return fmt.Errorf("kett
|
|
||||||
... e: kein Player konfiguriert — Bytes ... e: kein Player konfiguriert — Bytes
|
|
||||||
... können nicht abgespielt werden") ... können nicht abgespielt werden")
|
|
||||||
240 } 243 }
|
|
||||||
241 if err := c.cfg.Player.Play 244 if err := c.cfg.Player.Play
|
|
||||||
... (ctx, data); err != nil { ... (ctx, r.data); err != nil {
|
|
||||||
242 return fmt.Errorf("kett 245 return fmt.Errorf("kett
|
|
||||||
... e: Wiedergabe über %s fehlgeschlage ... e: Wiedergabe über %s fehlgeschlage
|
|
||||||
... n: %w", c.cfg.Player.Name(), err) ... n: %w", c.cfg.Player.Name(), err)
|
|
||||||
243 } 246 }
|
|
||||||
244 return nil 247 return nil
|
|
||||||
245 case errors.Is(err, ErrNoByteOu 248 case r.direct != nil:
|
|
||||||
... tput): ...
|
|
||||||
246 if c.cfg.Player != nil && c 249 if c.cfg.Player != nil && c
|
|
||||||
... .cfg.Player.Name() == NullPlayerNam ... .cfg.Player.Name() == NullPlayerNam
|
|
||||||
... e { ... e {
|
|
||||||
247 chainLogf(c.cfg, "kette 250 chainLogf(c.cfg, "kette
|
|
||||||
... : Direktansage verworfen (audio.sin ... : Direktansage verworfen (audio.sin
|
|
||||||
... k=null)\n") ... k=null)\n")
|
|
||||||
248 return nil 251 return nil
|
|
||||||
249 } 252 }
|
|
||||||
250 return c.SpeakDirect(ctx, t 253 return r.direct.SpeakDirect
|
|
||||||
... ext, lang) ... (ctx, text, lang)
|
|
||||||
251 default: 254 default:
|
|
||||||
252 return err 255 return r.noTierErr()
|
|
||||||
253 } 256 }
|
|
||||||
254 } 257 }
|
|
||||||
... 258
|
|
||||||
... 259 // noTierErr bündelt den Ende-ohne-
|
|
||||||
... ... Ergebnis-Fehler eines Durchlaufs: k
|
|
||||||
... ... eine
|
|
||||||
... 260 // Stufe lieferte Bytes oder Direkt
|
|
||||||
... ... ansage — übersprungene (nicht verfü
|
|
||||||
... ... gbar)
|
|
||||||
... 261 // und fehlgeschlagene Stufen werde
|
|
||||||
... ... n genannt, nie still.
|
|
||||||
... 262 func (r walkResult) noTierErr() err
|
|
||||||
... ... or {
|
|
||||||
... 263 return fmt.Errorf("kette: keine
|
|
||||||
... ... Stufe konnte sprechen (nicht verfü
|
|
||||||
... ... gbar: %s; fehlgeschlagen: %s)",
|
|
||||||
... 264 joinOr(r.skipped, "keine"),
|
|
||||||
... ... joinOr(r.failed, "keine"))
|
|
||||||
... 265 }
|
|
||||||
255 266
|
|
||||||
256 // chainLogf schreibt eine Protokol 267 // chainLogf schreibt eine Protokol
|
|
||||||
... l-Zeile, wenn ein Log gesetzt ist. ... l-Zeile, wenn ein Log gesetzt ist.
|
|
||||||
257 func chainLogf(cfg ChainConfig, for 268 func chainLogf(cfg ChainConfig, for
|
|
||||||
... mat string, args ...any) { ... mat string, args ...any) {
|
|
||||||
|
|
||||||
internal/provider/chain_test.go --- Go
|
|
||||||
341 341 }
|
|
||||||
342 342 }
|
|
||||||
343
|
|
||||||
344 // TestChainDirektEndgameEinDurchlauf (Review-Fix): Im DirectSpeaker-
|
|
||||||
345 // Endgame läuft die Kette genau EINMAL — die fehlgeschlagene Cloud-Stufe
|
|
||||||
346 // wird nur einmal angefragt (keine doppelten POSTs auf bezahlten APIs,
|
|
||||||
347 // keine verdoppelte Latenz), jede Stufe protokolliert ihren Fehlschlag
|
|
||||||
348 // genau einmal, und die Direktansage kommt zustande.
|
|
||||||
349 func TestChainDirektEndgameEinDurchlauf(t *testing.T) {
|
|
||||||
350 eleven, elHits := countingTTSServer(t, 500, []byte("boom"))
|
|
||||||
351 // elevenlabs: Key + Fake (500). openai: Endpunkt, aber KEIN Key →
|
|
||||||
352 // übersprungen. edge: toter Loopback-Port. osbuiltin: PATH nur mit
|
|
||||||
353 // fake spd-say (kein espeak) → keine Bytes, Direktansage.
|
|
||||||
354 dir := t.TempDir()
|
|
||||||
355 seen := filepath.Join(dir, "seen.txt")
|
|
||||||
356 fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
|
|
||||||
357 t.Setenv("PATH", dir)
|
|
||||||
358
|
|
||||||
359 var log bytes.Buffer
|
|
||||||
360 ch, err := NewChain(ChainConfig{
|
|
||||||
361 Getenv: chainEnv(map[string]string{
|
|
||||||
362 EnvElevenLabsKey: "k",
|
|
||||||
363 EnvElevenLabsEndpoint: eleven,
|
|
||||||
364 EnvOpenAIEndpoint: eleven, // ohne Key ohnehin übersprungen
|
|
||||||
365 EnvEdgeEndpoint: "ws://127.0.0.1:1/",
|
|
||||||
366 }),
|
|
||||||
367 Player: &fakePlayer{name: "oto"},
|
|
||||||
368 Log: &log,
|
|
||||||
369 })
|
|
||||||
370 if err != nil {
|
|
||||||
371 t.Fatalf("NewChain: %v", err)
|
|
||||||
372 }
|
|
||||||
373
|
|
||||||
374 if err := ch.Speak(context.Background(), "Hallo Welt", "de"); err != nil {
|
|
||||||
375 t.Fatalf("Speak (Direkt-Endgame): %v", err)
|
|
||||||
376 }
|
|
||||||
377 if got := elHits.Load(); got != 1 {
|
|
||||||
378 t.Errorf("elevenlabs wurde %d× angefragt, want genau 1 (kein zweiter Durchlauf)", got)
|
|
||||||
379 }
|
|
||||||
380 for _, tier := range []string{"elevenlabs", "edge"} {
|
|
||||||
381 if got := strings.Count(log.String(), "Stufe "+tier+" fehlgeschlagen"); got != 1 {
|
|
||||||
382 t.Errorf("„Stufe %s fehlgeschlagen“ steht %d× im Log, want genau 1:\n%s", tier, got, log.String())
|
|
||||||
383 }
|
|
||||||
384 }
|
|
||||||
385 if got := strings.Count(log.String(), "Stufe openai übersprungen"); got != 1 {
|
|
||||||
386 t.Errorf("openai-Skip steht %d× im Log, want genau 1", got)
|
|
||||||
387 }
|
|
||||||
388 args, err := os.ReadFile(seen)
|
|
||||||
389 if err != nil {
|
|
||||||
390 t.Fatalf("fake spd-say nicht gerufen: %v", err)
|
|
||||||
391 }
|
|
||||||
392 if got := strings.TrimSpace(string(args)); got != "-w -l de Hallo Welt" {
|
|
||||||
393 t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt")
|
|
||||||
394 }
|
|
||||||
395 }
|
|
||||||
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,663 +0,0 @@
|
|||||||
# Review package: c8f3902..HEAD
|
|
||||||
|
|
||||||
## Commits
|
|
||||||
fa4f193 ci: add gitea actions test workflow (vet, gofmt, test, build)
|
|
||||||
33b97a5 feat(cli): add subcommand dispatch, --version and stub commands
|
|
||||||
24dfe4d chore: bootstrap go module, license, readme and package skeleton
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
.gitea/workflows/test.yml | 30 ++++++
|
|
||||||
.gitignore | 10 ++
|
|
||||||
LICENSE | 21 ++++
|
|
||||||
README.md | 49 +++++++++
|
|
||||||
cmd/talk/cli.go | 259 +++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
cmd/talk/cli_test.go | 146 +++++++++++++++++++++++++
|
|
||||||
cmd/talk/main.go | 21 ++++
|
|
||||||
cmd/talkd/main.go | 13 +++
|
|
||||||
go.mod | 3 +
|
|
||||||
internal/api/doc.go | 4 +
|
|
||||||
internal/audio/doc.go | 5 +
|
|
||||||
internal/config/doc.go | 7 ++
|
|
||||||
internal/langdetect/doc.go | 4 +
|
|
||||||
internal/notify/doc.go | 4 +
|
|
||||||
internal/provider/doc.go | 6 ++
|
|
||||||
internal/queue/doc.go | 6 ++
|
|
||||||
16 files changed, 588 insertions(+)
|
|
||||||
|
|
||||||
## Diff
|
|
||||||
.gitea/workflows/test.yml --- YAML
|
|
||||||
1 name: test
|
|
||||||
2
|
|
||||||
3 on:
|
|
||||||
4 push:
|
|
||||||
5 pull_request:
|
|
||||||
6
|
|
||||||
7 jobs:
|
|
||||||
8 test:
|
|
||||||
9 runs-on: ubuntu-latest
|
|
||||||
10 steps:
|
|
||||||
11 - name: Checkout
|
|
||||||
12 uses: actions/checkout@v4
|
|
||||||
13 - name: Go einrichten
|
|
||||||
14 uses: actions/setup-go@v5
|
|
||||||
15 with:
|
|
||||||
16 go-version-file: go.mod
|
|
||||||
17 - name: go vet
|
|
||||||
18 run: go vet ./...
|
|
||||||
19 - name: gofmt-Check
|
|
||||||
20 run: |
|
|
||||||
21 unformatted=$(gofmt -l .)
|
|
||||||
22 if [ -n "$unformatted" ]; then
|
|
||||||
23 echo "Folgende Dateien sind nicht gofmt-formatiert:"
|
|
||||||
24 echo "$unformatted"
|
|
||||||
25 exit 1
|
|
||||||
26 fi
|
|
||||||
27 - name: go test
|
|
||||||
28 run: go test ./...
|
|
||||||
29 - name: go build
|
|
||||||
30 run: go build ./...
|
|
||||||
31
|
|
||||||
|
|
||||||
.gitignore --- Text
|
|
||||||
1 # Go-Binaries
|
|
||||||
2 /talk
|
|
||||||
3 /talkd
|
|
||||||
4 /talk.exe
|
|
||||||
5 /talkd.exe
|
|
||||||
6 *.test
|
|
||||||
7 *.out
|
|
||||||
8
|
|
||||||
9 # Release-Artefakte (GoReleaser, Task 11)
|
|
||||||
10 /dist/
|
|
||||||
11
|
|
||||||
|
|
||||||
LICENSE --- Text
|
|
||||||
1 MIT License
|
|
||||||
2
|
|
||||||
3 Copyright (c) 2026 m3tam3re
|
|
||||||
4
|
|
||||||
5 Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
6 of this software and associated documentation files (the "Software"), to deal
|
|
||||||
7 in the Software without restriction, including without limitation the rights
|
|
||||||
8 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
9 copies of the Software, and to permit persons to whom the Software is
|
|
||||||
10 furnished to do so, subject to the following conditions:
|
|
||||||
11
|
|
||||||
12 The above copyright notice and this permission notice shall be included in all
|
|
||||||
13 copies or substantial portions of the Software.
|
|
||||||
14
|
|
||||||
15 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
16 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
17 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
18 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
19 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
20 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
21 SOFTWARE.
|
|
||||||
22
|
|
||||||
|
|
||||||
README.md --- Text
|
|
||||||
1 # talk
|
|
||||||
2
|
|
||||||
3 > **Status: WIP** — aktive Entwicklung (talk 2.0). Binde Spezifikation:
|
|
||||||
4 > [`.scratch/talk-2-0/spec.md`](.scratch/talk-2-0/spec.md) ·
|
|
||||||
5 > Implementierungsplan: [`.scratch/talk-2-0/plan.md`](.scratch/talk-2-0/plan.md)
|
|
||||||
6
|
|
||||||
7 **talk** macht Skripte und KI-Agenten hörbar: Ein Aufruf — `talk "Build fertig"` —
|
|
||||||
8 spricht eine kurze Nachricht über eine TTS-Provider-Kette
|
|
||||||
9 (ElevenLabs → OpenAI → Edge-TTS → OS-Stimme), serialisiert durch den Daemon
|
|
||||||
10 `talkd`. Cross-platform (Windows/macOS/Linux, amd64/arm64), statische Binaries,
|
|
||||||
11 keine Runtime-Abhängigkeiten.
|
|
||||||
12
|
|
||||||
13 ## Befehle
|
|
||||||
14
|
|
||||||
15 | Befehl | Zweck |
|
|
||||||
16 | --- | --- |
|
|
||||||
17 | `talk "Text"` | Ansage — Abkürzung für `talk say`, non-blocking |
|
|
||||||
18 | `talk say "Text"` | Ansage (Text als Argument oder von stdin) |
|
|
||||||
19 | `talk wrap -- <Befehl>` | Befehl ausführen, Erfolg/Misserfolg + Dauer ansagen |
|
|
||||||
20 | `talk mute` / `talk unmute` | Ansagen dauerhaft stummschalten / Stummschaltung aufheben |
|
|
||||||
21 | `talk status` | Daemon-Status (Queue, Mute, Provider, Version) |
|
|
||||||
22 | `talk cancel` | laufende und geplante Ansagen abbrechen |
|
|
||||||
23 | `talk voices` | verfügbare Stimmen anzeigen |
|
|
||||||
24 | `talk test` | Test-Ansage ausgeben |
|
|
||||||
25 | `talk doctor` | Selbstdiagnose des Setups |
|
|
||||||
26 | `talk setup` | geführte Ersteinrichtung (Wizard) |
|
|
||||||
27
|
|
||||||
28 Say-Flags (gelten auch für `talk "Text"`): `--wait`, `--now`, `--priority`,
|
|
||||||
29 `--voice`, `--lang`, `--provider`.
|
|
||||||
30
|
|
||||||
31 **Hinweis:** Alle Befehle sind in diesem Stand Stubs und melden
|
|
||||||
32 `not implemented yet` (Exit-Code ≠ 0). Die Implementierung entsteht
|
|
||||||
33 Aufgabenweise gemäß Plan; `--version` und `--help` funktionieren.
|
|
||||||
34
|
|
||||||
35 ## Entwicklung
|
|
||||||
36
|
|
||||||
37 ```console
|
|
||||||
38 go build ./... # bauen
|
|
||||||
39 go test ./... # Tests
|
|
||||||
40 go vet ./... # statische Analyse
|
|
||||||
41 gofmt -l . # Formatierung prüfen (leerer Output = ok)
|
|
||||||
42 ```
|
|
||||||
43
|
|
||||||
44 CI: `.gitea/workflows/test.yml` (Gitea Actions) läuft vet, gofmt-Check, Tests
|
|
||||||
45 und Build bei jedem Push und Pull Request.
|
|
||||||
46
|
|
||||||
47 ## Lizenz
|
|
||||||
48
|
|
||||||
49 MIT — siehe [LICENSE](LICENSE).
|
|
||||||
50
|
|
||||||
|
|
||||||
cmd/talk/cli.go --- Go
|
|
||||||
1 package main
|
|
||||||
2
|
|
||||||
3 import (
|
|
||||||
4 "errors"
|
|
||||||
5 "flag"
|
|
||||||
6 "fmt"
|
|
||||||
7 "io"
|
|
||||||
8 )
|
|
||||||
9
|
|
||||||
10 // Exit-Codes.
|
|
||||||
11 const (
|
|
||||||
12 exitOK = 0 // Erfolg (auch --help, --version)
|
|
||||||
13 exitFailure = 1 // Befehl fehlgeschlagen oder noch nicht implementiert
|
|
||||||
14 exitUsage = 2 // Falsche Benutzung (unbekannte Flags, kein Befehl)
|
|
||||||
15 )
|
|
||||||
16
|
|
||||||
17 // command ist ein Eintrag der Befehlstabelle. Die Tabelle ist bewusst
|
|
||||||
18 // datengetrieben: Spätere Tasks (z. B. die TUI-Befehle aus Task 09) ergänzen
|
|
||||||
19 // hier Einträge bzw. Register-Funktionen, ohne die Dispatch-Logik in run()
|
|
||||||
20 // anzufassen.
|
|
||||||
21 type command struct {
|
|
||||||
22 name string // Subcommand-Name (bindende CLI-Surface aus der Spec)
|
|
||||||
23 short string // Kurzbeschreibung für `talk --help`
|
|
||||||
24 args string // Positionsargumente-Hinweis für die Usage-Zeile
|
|
||||||
25 detail string // Beschreibung für `talk <Befehl> --help`
|
|
||||||
26 register func(fs *flag.FlagSet) // hängt die Flags des Befehls an den FlagSet
|
|
||||||
27 }
|
|
||||||
28
|
|
||||||
29 // commands ist die vollständige Befehlstabelle. Die Namen sind Teil der
|
|
||||||
30 // bindenden CLI-Surface (Spec: „CLI-Surface (exakt)“) — nicht umbenennen.
|
|
||||||
31 var commands = []command{
|
|
||||||
32 {
|
|
||||||
33 name: "say",
|
|
||||||
34 short: "Text ansagen (Default-Befehl)",
|
|
||||||
35 args: `"Text"`,
|
|
||||||
36 detail: "Spricht den Text über die Provider-Kette aus. Ohne Text-Argument wird stdin gelesen. `talk \"Text\"` ist die Abkürzung für `talk say \"Text\"`.",
|
|
||||||
37 register: addSayFlags,
|
|
||||||
38 },
|
|
||||||
39 {
|
|
||||||
40 name: "wrap",
|
|
||||||
41 short: "Befehl ausführen und Ergebnis ansagen",
|
|
||||||
42 args: "-- <Befehl> [Argumente …]",
|
|
||||||
43 detail: "Führt den Befehl aus (stdin/stdout/stderr werden durchgereicht) und spricht danach Erfolg bzw. Misserfolg und Dauer.",
|
|
||||||
44 },
|
|
||||||
45 {
|
|
||||||
46 name: "mute",
|
|
||||||
47 short: "Ansagen dauerhaft stummschalten",
|
|
||||||
48 detail: "Aktiviert den persistenten Mute-State des Daemons (Datei im Config-Verzeichnis).",
|
|
||||||
49 },
|
|
||||||
50 {
|
|
||||||
51 name: "unmute",
|
|
||||||
52 short: "Stummschaltung aufheben",
|
|
||||||
53 detail: "Hebt den persistenten Mute-State des Daemons wieder auf.",
|
|
||||||
54 },
|
|
||||||
55 {
|
|
||||||
56 name: "status",
|
|
||||||
57 short: "Daemon-Status anzeigen",
|
|
||||||
58 detail: "Zeigt Queue, aktuelles Item, Mute-State, aktiven Provider und Daemon-Version.",
|
|
||||||
59 },
|
|
||||||
60 {
|
|
||||||
61 name: "cancel",
|
|
||||||
62 short: "Laufende und geplante Ansagen abbrechen",
|
|
||||||
63 detail: "Bricht die laufende Ansage ab und leert die Queue des Daemons.",
|
|
||||||
64 },
|
|
||||||
65 {
|
|
||||||
66 name: "voices",
|
|
||||||
67 short: "Verfügbare Stimmen anzeigen",
|
|
||||||
68 detail: "Listet die Stimmen aller Provider (Defaults + Config-Overrides) als Tabelle.",
|
|
||||||
69 },
|
|
||||||
70 {
|
|
||||||
71 name: "test",
|
|
||||||
72 short: "Test-Ansage ausgeben",
|
|
||||||
73 detail: "Spricht eine feste Testphrase und zeigt den verwendeten Provider samt Latenz.",
|
|
||||||
74 },
|
|
||||||
75 {
|
|
||||||
76 name: "doctor",
|
|
||||||
77 short: "Setup-Diagnose anzeigen",
|
|
||||||
78 detail: "Prüft Daemon, Audio-Sink, Provider-Keys und Config und berichtet zeilenweise mit ✔/✘/⚠.",
|
|
||||||
79 },
|
|
||||||
80 {
|
|
||||||
81 name: "setup",
|
|
||||||
82 short: "Geführte Ersteinrichtung (Wizard)",
|
|
||||||
83 detail: "Interaktiver Wizard: Provider-Keys, Stimmen probehören, config.toml schreiben. Ohne TTY: plain.",
|
|
||||||
84 },
|
|
||||||
85 }
|
|
||||||
86
|
|
||||||
87 // addSayFlags registriert die Flags des say-Befehls. Sie gelten auch für den
|
|
||||||
88 // Default-Pfad `talk "Text"` (gleiche Flag-Oberfläche, festgelegt in der
|
|
||||||
89 // Spec). Das Verhalten entsteht in Task 02 (direkter Pfad) bzw. Task 04
|
|
||||||
90 // (Daemon-Pfad).
|
|
||||||
91 func addSayFlags(fs *flag.FlagSet) {
|
|
||||||
92 fs.Bool("wait", false, "warten, bis die Ansage fertig abgespielt ist")
|
|
||||||
93 fs.Bool("now", false, "Ansage sofort ausgeben (springt die Warteschlange)")
|
|
||||||
94 fs.String("priority", "normal", "Priorität: low | normal | high")
|
|
||||||
95 fs.String("voice", "", "Stimme als provider-spezifische ID")
|
|
||||||
96 fs.String("lang", "", "Sprache: de | en (Default: automatische Erkennung)")
|
|
||||||
97 fs.String("provider", "", "Provider pinnen: elevenlabs | openai | edge | osbuiltin")
|
|
||||||
98 }
|
|
||||||
99
|
|
||||||
100 // run ist der Einstiegspunkt der CLI (Argumente ohne Programmnamen). Alle
|
|
||||||
101 // Ausgaben gehen auf die übergebenen Writer, damit run direkt und ohne
|
|
||||||
102 // Prozessstart testbar ist. Rückgabe ist der Exit-Code.
|
|
||||||
103 func run(argv []string, stdout, stderr io.Writer) int {
|
|
||||||
104 if len(argv) == 0 {
|
|
||||||
105 // Task 02 macht aus dem nackten Aufruf ggf. einen stdin-Leser
|
|
||||||
106 // (Text kommt dann über die Pipe); bis dahin: Benutzungsfehler.
|
|
||||||
107 writeUsage(stderr)
|
|
||||||
108 return exitUsage
|
|
||||||
109 }
|
|
||||||
110
|
|
||||||
111 if argv[0] == "help" {
|
|
||||||
112 if len(argv) > 1 {
|
|
||||||
113 if cmd, ok := lookupCommand(argv[1]); ok {
|
|
||||||
114 writeCommandHelp(cmd, stdout)
|
|
||||||
115 return exitOK
|
|
||||||
116 }
|
|
||||||
117 fmt.Fprintf(stderr, "talk: unbekannter Befehl %q\n", argv[1])
|
|
||||||
118 return exitUsage
|
|
||||||
119 }
|
|
||||||
120 writeUsage(stdout)
|
|
||||||
121 return exitOK
|
|
||||||
122 }
|
|
||||||
123
|
|
||||||
124 if cmd, ok := lookupCommand(argv[0]); ok {
|
|
||||||
125 return runCommand(cmd, argv[1:], stdout, stderr)
|
|
||||||
126 }
|
|
||||||
127
|
|
||||||
128 // Kein bekannter Befehl: Default-Pfad `talk "Text"` (mappt auf say)
|
|
||||||
129 // bzw. Root-Flags (--version, --help). Führt aktuell auf den say-Stub.
|
|
||||||
130 return runDefault(argv, stdout, stderr)
|
|
||||||
131 }
|
|
||||||
132
|
|
||||||
133 func lookupCommand(name string) (command, bool) {
|
|
||||||
134 for _, cmd := range commands {
|
|
||||||
135 if cmd.name == name {
|
|
||||||
136 return cmd, true
|
|
||||||
137 }
|
|
||||||
138 }
|
|
||||||
139 return command{}, false
|
|
||||||
140 }
|
|
||||||
141
|
|
||||||
142 // newCommandFlagSet baut den FlagSet eines Befehls: eigene Flags plus ein
|
|
||||||
143 // explizites --help. Die Default-Usage wird stummgeschaltet, damit Hilfetext
|
|
||||||
144 // ausschließlich über writeCommandHelp (eine Quelle) ausgegeben wird.
|
|
||||||
145 func newCommandFlagSet(cmd command, out io.Writer) (*flag.FlagSet, *bool) {
|
|
||||||
146 fs := flag.NewFlagSet("talk "+cmd.name, flag.ContinueOnError)
|
|
||||||
147 fs.SetOutput(out)
|
|
||||||
148 fs.Usage = func() {}
|
|
||||||
149 if cmd.register != nil {
|
|
||||||
150 cmd.register(fs)
|
|
||||||
151 }
|
|
||||||
152 help := fs.Bool("help", false, "Hilfe zu diesem Befehl anzeigen")
|
|
||||||
153 return fs, help
|
|
||||||
154 }
|
|
||||||
155
|
|
||||||
156 // runCommand führt einen benannten Subcommand aus — in diesem Stand als Stub.
|
|
||||||
157 func runCommand(cmd command, args []string, stdout, stderr io.Writer) int {
|
|
||||||
158 fs, help := newCommandFlagSet(cmd, stderr)
|
|
||||||
159 if err := fs.Parse(args); err != nil {
|
|
||||||
160 if errors.Is(err, flag.ErrHelp) { // z. B. nacktes -h
|
|
||||||
161 writeCommandHelp(cmd, stdout)
|
|
||||||
162 return exitOK
|
|
||||||
163 }
|
|
||||||
164 // Die Fehlermeldung selbst hat der FlagSet bereits ausgegeben.
|
|
||||||
165 fmt.Fprintf(stderr, "Hinweis: `talk %s --help` zeigt die Verwendung.\n", cmd.name)
|
|
||||||
166 return exitUsage
|
|
||||||
167 }
|
|
||||||
168 if *help {
|
|
||||||
169 writeCommandHelp(cmd, stdout)
|
|
||||||
170 return exitOK
|
|
||||||
171 }
|
|
||||||
172 return notImplemented(cmd.name, stderr)
|
|
||||||
173 }
|
|
||||||
174
|
|
||||||
175 // runDefault behandelt Aufrufe ohne bekannten Subcommand: `talk "Text"`,
|
|
||||||
176 // `talk --version`, `talk --help` sowie say-Flags vor dem Text.
|
|
||||||
177 func runDefault(argv []string, stdout, stderr io.Writer) int {
|
|
||||||
178 fs := flag.NewFlagSet("talk", flag.ContinueOnError)
|
|
||||||
179 fs.SetOutput(stderr)
|
|
||||||
180 fs.Usage = func() {}
|
|
||||||
181 showVersion := fs.Bool("version", false, "Version anzeigen")
|
|
||||||
182 help := fs.Bool("help", false, "diese Hilfe anzeigen")
|
|
||||||
183 addSayFlags(fs)
|
|
||||||
184
|
|
||||||
185 if err := fs.Parse(argv); err != nil {
|
|
||||||
186 if errors.Is(err, flag.ErrHelp) {
|
|
||||||
187 writeUsage(stdout)
|
|
||||||
188 return exitOK
|
|
||||||
189 }
|
|
||||||
190 fmt.Fprintln(stderr, "Hinweis: `talk --help` zeigt die Verwendung.")
|
|
||||||
191 return exitUsage
|
|
||||||
192 }
|
|
||||||
193 if *showVersion {
|
|
||||||
194 fmt.Fprintln(stdout, versionString())
|
|
||||||
195 return exitOK
|
|
||||||
196 }
|
|
||||||
197 if *help {
|
|
||||||
198 writeUsage(stdout)
|
|
||||||
199 return exitOK
|
|
||||||
200 }
|
|
||||||
201 return notImplemented("say", stderr)
|
|
||||||
202 }
|
|
||||||
203
|
|
||||||
204 // versionString rendert die Versionsausgabe: `talk version <version>` plus
|
|
||||||
205 // Commit-Hash in Klammern, falls per ldflags injiziert.
|
|
||||||
206 func versionString() string {
|
|
||||||
207 if commit == "" {
|
|
||||||
208 return fmt.Sprintf("talk version %s", version)
|
|
||||||
209 }
|
|
||||||
210 return fmt.Sprintf("talk version %s (%s)", version, commit)
|
|
||||||
211 }
|
|
||||||
212
|
|
||||||
213 // notImplemented meldet einen noch nicht implementierten Befehl auf stderr
|
|
||||||
214 // und liefert den dafür vorgesehenen Exit-Code (non-zero).
|
|
||||||
215 func notImplemented(name string, stderr io.Writer) int {
|
|
||||||
216 fmt.Fprintf(stderr, "talk %s is not implemented yet\n", name)
|
|
||||||
217 return exitFailure
|
|
||||||
218 }
|
|
||||||
219
|
|
||||||
220 // writeUsage schreibt die Gesamt-Hilfe; die Befehlsliste wird direkt aus der
|
|
||||||
221 // Befehlstabelle generiert, damit Hilfe und Dispatch nicht auseinanderlaufen.
|
|
||||||
222 func writeUsage(w io.Writer) {
|
|
||||||
223 fmt.Fprint(w, `talk — Sprach-Ausgaben für Skripte und KI-Agenten
|
|
||||||
224
|
|
||||||
225 Usage:
|
|
||||||
226 talk [Flags] "Text" Text ansagen (Abkürzung für: talk say)
|
|
||||||
227 talk <Befehl> [Flags] Befehl ausführen
|
|
||||||
228 talk help [Befehl] Hilfe anzeigen
|
|
||||||
229
|
|
||||||
230 Befehle:
|
|
||||||
231 `)
|
|
||||||
232 for _, cmd := range commands {
|
|
||||||
233 fmt.Fprintf(w, " %-8s %s\n", cmd.name, cmd.short)
|
|
||||||
234 }
|
|
||||||
235 fmt.Fprint(w, `
|
|
||||||
236 Flags:
|
|
||||||
237 --version Version anzeigen
|
|
||||||
238 --help diese Hilfe anzeigen
|
|
||||||
239
|
|
||||||
240 Die Flags von say gelten auch für talk "Text"; Details: talk say --help
|
|
||||||
241 `)
|
|
||||||
242 }
|
|
||||||
243
|
|
||||||
244 // writeCommandHelp schreibt die Hilfe eines einzelnen Befehls inkl. Flags.
|
|
||||||
245 func writeCommandHelp(cmd command, w io.Writer) {
|
|
||||||
246 fmt.Fprintf(w, "Usage:\n talk %s", cmd.name)
|
|
||||||
247 if cmd.args != "" {
|
|
||||||
248 fmt.Fprintf(w, " %s", cmd.args)
|
|
||||||
249 }
|
|
||||||
250 fmt.Fprintln(w, " [Flags]")
|
|
||||||
251 fmt.Fprintln(w)
|
|
||||||
252 if cmd.detail != "" {
|
|
||||||
253 fmt.Fprintf(w, "%s\n\n", cmd.detail)
|
|
||||||
254 }
|
|
||||||
255 fmt.Fprintln(w, "Flags:")
|
|
||||||
256 fs, _ := newCommandFlagSet(cmd, w)
|
|
||||||
257 fs.PrintDefaults()
|
|
||||||
258 fmt.Fprintln(w, "\nStatus: noch nicht implementiert (WIP).")
|
|
||||||
259 }
|
|
||||||
260
|
|
||||||
|
|
||||||
cmd/talk/cli_test.go --- Go
|
|
||||||
1 package main
|
|
||||||
2
|
|
||||||
3 import (
|
|
||||||
4 "bytes"
|
|
||||||
5 "strings"
|
|
||||||
6 "testing"
|
|
||||||
7 )
|
|
||||||
8
|
|
||||||
9 // wantSubcommands ist die bindende CLI-Surface aus der Spec — bewusst als
|
|
||||||
10 // hartkodierte Liste getestet (nicht aus der Befehlstabelle abgeleitet),
|
|
||||||
11 // damit die Tests anschlagen, wenn Tabelle und Spec auseinanderlaufen.
|
|
||||||
12 var wantSubcommands = []string{
|
|
||||||
13 "say", "wrap", "mute", "unmute", "status", "cancel", "voices", "test", "doctor", "setup",
|
|
||||||
14 }
|
|
||||||
15
|
|
||||||
16 func TestHelpListetAlleSubcommands(t *testing.T) {
|
|
||||||
17 var out, errBuf bytes.Buffer
|
|
||||||
18 code := run([]string{"--help"}, &out, &errBuf)
|
|
||||||
19 if code != exitOK {
|
|
||||||
20 t.Fatalf("--help: Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
|
|
||||||
21 }
|
|
||||||
22 lines := strings.Split(out.String(), "\n")
|
|
||||||
23 for _, name := range wantSubcommands {
|
|
||||||
24 found := false
|
|
||||||
25 for _, ln := range lines {
|
|
||||||
26 if strings.HasPrefix(strings.TrimLeft(ln, " "), name+" ") {
|
|
||||||
27 found = true
|
|
||||||
28 break
|
|
||||||
29 }
|
|
||||||
30 }
|
|
||||||
31 if !found {
|
|
||||||
32 t.Errorf("--help listet Befehl %q nicht (Output: %q)", name, out.String())
|
|
||||||
33 }
|
|
||||||
34 }
|
|
||||||
35 }
|
|
||||||
36
|
|
||||||
37 func TestVersionDefault(t *testing.T) {
|
|
||||||
38 var out, errBuf bytes.Buffer
|
|
||||||
39 code := run([]string{"--version"}, &out, &errBuf)
|
|
||||||
40 if code != exitOK {
|
|
||||||
41 t.Fatalf("--version: Exit-Code = %d, want %d", code, exitOK)
|
|
||||||
42 }
|
|
||||||
43 if got, want := out.String(), "talk version dev\n"; got != want {
|
|
||||||
44 t.Errorf("--version Output = %q, want %q", got, want)
|
|
||||||
45 }
|
|
||||||
46 }
|
|
||||||
47
|
|
||||||
48 func TestVersionStringMitCommit(t *testing.T) {
|
|
||||||
49 oldCommit := commit
|
|
||||||
50 commit = "c0ffee1"
|
|
||||||
51 defer func() { commit = oldCommit }()
|
|
||||||
52
|
|
||||||
53 if got, want := versionString(), "talk version dev (c0ffee1)"; got != want {
|
|
||||||
54 t.Errorf("versionString() = %q, want %q", got, want)
|
|
||||||
55 }
|
|
||||||
56 }
|
|
||||||
57
|
|
||||||
58 func TestSubcommandStubs(t *testing.T) {
|
|
||||||
59 for _, name := range wantSubcommands {
|
|
||||||
60 t.Run(name, func(t *testing.T) {
|
|
||||||
61 var out, errBuf bytes.Buffer
|
|
||||||
62 code := run([]string{name}, &out, &errBuf)
|
|
||||||
63 if code == exitOK {
|
|
||||||
64 t.Errorf("talk %s: Exit-Code 0, want non-zero für Stub", name)
|
|
||||||
65 }
|
|
||||||
66 if !strings.Contains(errBuf.String(), "not implemented yet") {
|
|
||||||
67 t.Errorf("talk %s: stderr = %q, will Hinweis auf nicht implementiert", name, errBuf.String())
|
|
||||||
68 }
|
|
||||||
69 if !strings.Contains(errBuf.String(), name) {
|
|
||||||
70 t.Errorf("talk %s: stderr nennt den Befehlsnamen nicht: %q", name, errBuf.String())
|
|
||||||
71 }
|
|
||||||
72 if out.Len() != 0 {
|
|
||||||
73 t.Errorf("talk %s: unerwartete stdout-Ausgabe %q", name, out.String())
|
|
||||||
74 }
|
|
||||||
75 })
|
|
||||||
76 }
|
|
||||||
77 }
|
|
||||||
78
|
|
||||||
79 func TestDefaultAufrufMapptAufSay(t *testing.T) {
|
|
||||||
80 var out, errBuf bytes.Buffer
|
|
||||||
81 code := run([]string{"Hallo Welt"}, &out, &errBuf)
|
|
||||||
82 if code != exitFailure {
|
|
||||||
83 t.Fatalf(`talk "Hallo Welt": Exit-Code = %d, want %d`, code, exitFailure)
|
|
||||||
84 }
|
|
||||||
85 if !strings.Contains(errBuf.String(), "talk say is not implemented yet") {
|
|
||||||
86 t.Errorf("Default-Aufruf mappt offenbar nicht auf say, stderr = %q", errBuf.String())
|
|
||||||
87 }
|
|
||||||
88 }
|
|
||||||
89
|
|
||||||
90 func TestSayHelpZeigtFlags(t *testing.T) {
|
|
||||||
91 var out, errBuf bytes.Buffer
|
|
||||||
92 code := run([]string{"say", "--help"}, &out, &errBuf)
|
|
||||||
93 if code != exitOK {
|
|
||||||
94 t.Fatalf("talk say --help: Exit-Code = %d, want %d", code, exitOK)
|
|
||||||
95 }
|
|
||||||
96 for _, f := range []string{"-wait", "-now", "-priority", "-voice", "-lang", "-provider"} {
|
|
||||||
97 if !strings.Contains(out.String(), f) {
|
|
||||||
98 t.Errorf("talk say --help zeigt Flag %s nicht (Output: %q)", f, out.String())
|
|
||||||
99 }
|
|
||||||
100 }
|
|
||||||
101 }
|
|
||||||
102
|
|
||||||
103 func TestHelpSubcommand(t *testing.T) {
|
|
||||||
104 t.Run("ohne Argument", func(t *testing.T) {
|
|
||||||
105 var out, errBuf bytes.Buffer
|
|
||||||
106 if code := run([]string{"help"}, &out, &errBuf); code != exitOK {
|
|
||||||
107 t.Fatalf("talk help: Exit-Code = %d, want %d", code, exitOK)
|
|
||||||
108 }
|
|
||||||
109 for _, name := range wantSubcommands {
|
|
||||||
110 if !strings.Contains(out.String(), name) {
|
|
||||||
111 t.Errorf("talk help listet %q nicht", name)
|
|
||||||
112 }
|
|
||||||
113 }
|
|
||||||
114 })
|
|
||||||
115 t.Run("mit Befehl", func(t *testing.T) {
|
|
||||||
116 var out, errBuf bytes.Buffer
|
|
||||||
117 if code := run([]string{"help", "mute"}, &out, &errBuf); code != exitOK {
|
|
||||||
118 t.Fatalf("talk help mute: Exit-Code = %d, want %d", code, exitOK)
|
|
||||||
119 }
|
|
||||||
120 if !strings.Contains(out.String(), "talk mute") {
|
|
||||||
121 t.Errorf("talk help mute zeigt Usage von mute nicht: %q", out.String())
|
|
||||||
122 }
|
|
||||||
123 })
|
|
||||||
124 }
|
|
||||||
125
|
|
||||||
126 func TestOhneArgumenteZeigtUsage(t *testing.T) {
|
|
||||||
127 var out, errBuf bytes.Buffer
|
|
||||||
128 code := run(nil, &out, &errBuf)
|
|
||||||
129 if code != exitUsage {
|
|
||||||
130 t.Fatalf("talk ohne Argumente: Exit-Code = %d, want %d", code, exitUsage)
|
|
||||||
131 }
|
|
||||||
132 if !strings.Contains(errBuf.String(), "Usage") {
|
|
||||||
133 t.Errorf("talk ohne Argumente: Usage fehlt auf stderr: %q", errBuf.String())
|
|
||||||
134 }
|
|
||||||
135 }
|
|
||||||
136
|
|
||||||
137 func TestUnbekanntesFlag(t *testing.T) {
|
|
||||||
138 var out, errBuf bytes.Buffer
|
|
||||||
139 code := run([]string{"--bogus"}, &out, &errBuf)
|
|
||||||
140 if code != exitUsage {
|
|
||||||
141 t.Fatalf("talk --bogus: Exit-Code = %d, want %d", code, exitUsage)
|
|
||||||
142 }
|
|
||||||
143 if !strings.Contains(errBuf.String(), "bogus") {
|
|
||||||
144 t.Errorf("talk --bogus: Fehlermeldung fehlt auf stderr: %q", errBuf.String())
|
|
||||||
145 }
|
|
||||||
146 }
|
|
||||||
147
|
|
||||||
|
|
||||||
cmd/talk/main.go --- Go
|
|
||||||
1 // talk ist die CLI für Sprach-Ausgaben (talk 2.0).
|
|
||||||
2 //
|
|
||||||
3 // Dieser Stand (Task 01) liefert das Gerüst: Subcommand-Dispatch, --version
|
|
||||||
4 // und alle Befehle als Stubs. Die Funktionalität entsteht in den Tasks 02–09.
|
|
||||||
5 package main
|
|
||||||
6
|
|
||||||
7 import "os"
|
|
||||||
8
|
|
||||||
9 // Build-Informationen. Werden beim Release per -ldflags injiziert, z. B.
|
|
||||||
10 //
|
|
||||||
11 // go build -ldflags "-X main.version=1.0.0 -X main.commit=abc1234" ./cmd/talk
|
|
||||||
12 //
|
|
||||||
13 // Defaults: version "dev", commit leer (siehe auch .goreleaser.yaml, Task 11).
|
|
||||||
14 var (
|
|
||||||
15 version = "dev"
|
|
||||||
16 commit = ""
|
|
||||||
17 )
|
|
||||||
18
|
|
||||||
19 func main() {
|
|
||||||
20 os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
|
||||||
21 }
|
|
||||||
22
|
|
||||||
|
|
||||||
cmd/talkd/main.go --- Go
|
|
||||||
1 // talkd ist der Daemon für talk 2.0 (Queue, Mute-State, HTTP-API, MCP).
|
|
||||||
2 //
|
|
||||||
3 // Dieser Stand (Task 01) ist ein Platzhalter: talkd startet, loggt eine Zeile
|
|
||||||
4 // und beendet sich mit Exit-Code 0. Die eigentliche Daemon-Implementierung
|
|
||||||
5 // (HTTP-Server auf 127.0.0.1, serialisierte Ansage-Queue, persistentes Mute)
|
|
||||||
6 // entsteht in Task 04.
|
|
||||||
7 package main
|
|
||||||
8
|
|
||||||
9 import "log"
|
|
||||||
10
|
|
||||||
11 func main() {
|
|
||||||
12 log.Println("talkd: Platzhalter — Daemon-Implementierung folgt in Task 04")
|
|
||||||
13 }
|
|
||||||
14
|
|
||||||
|
|
||||||
go.mod --- Text
|
|
||||||
1 module code.m3ta.dev/m3tam3re/talk
|
|
||||||
2
|
|
||||||
3 go 1.26.8
|
|
||||||
4
|
|
||||||
|
|
||||||
internal/api/doc.go --- Go
|
|
||||||
1 // Package api implementiert die HTTP-Oberfläche des Daemons auf 127.0.0.1:
|
|
||||||
2 // POST /speak, GET /status, POST /cancel, POST /mute, POST /unmute (Task 04),
|
|
||||||
3 // CORS für /speak und den MCP-Endpoint (Streamable HTTP, Task 06).
|
|
||||||
4 package api
|
|
||||||
5
|
|
||||||
|
|
||||||
internal/audio/doc.go --- Go
|
|
||||||
1 // Package audio implementiert die Audio-Ausgabe von talk über das
|
|
||||||
2 // Sink-Interface: Playback embedded via oto (PCM/WAV) und ein null-Sink,
|
|
||||||
3 // das verwirft (CI, Server, Tests). Auswahl per Config audio.sink.
|
|
||||||
4 // Implementierung folgt in Task 02.
|
|
||||||
5 package audio
|
|
||||||
6
|
|
||||||
|
|
||||||
internal/config/doc.go --- Go
|
|
||||||
1 // Package config implementiert die Config-Auflösung von talk: Defaults +
|
|
||||||
2 // optionale config.toml im platform-nativen Verzeichnis (~/.config/talk,
|
|
||||||
3 // ~/Library/Application Support/talk, %APPDATA%\talk) + Env-Overrides
|
|
||||||
4 // (TALK_*); Env schlägt Datei. Enthält Voice-Mapping voice.<provider>.<lang>
|
|
||||||
5 // und Provider-Endpunkte (Fake-bar für Tests). Implementierung folgt in
|
|
||||||
6 // Task 02.
|
|
||||||
7 package config
|
|
||||||
8
|
|
||||||
|
|
||||||
internal/langdetect/doc.go --- Go
|
|
||||||
1 // Package langdetect erkennt die Sprache eines Ansage-Texts als reine
|
|
||||||
2 // Funktion Detect(text) → "de" | "en" (Default "de"; Heuristik über
|
|
||||||
3 // Common-Word-Listen und Umlaute/ß). Implementierung folgt in Task 02.
|
|
||||||
4 package langdetect
|
|
||||||
5
|
|
||||||
|
|
||||||
internal/notify/doc.go --- Go
|
|
||||||
1 // Package notify implementiert Best-effort-Desktop-Notifications parallel
|
|
||||||
2 // zum Audio (beeep: notify-send / macOS Notification Center / Windows Toast),
|
|
||||||
3 // mit Abbrechen-Button, wo das OS ihn hergibt. Implementierung folgt.
|
|
||||||
4 package notify
|
|
||||||
5
|
|
||||||
|
|
||||||
internal/provider/doc.go --- Go
|
|
||||||
1 // Package provider implementiert die TTS-Provider-Kette von talk:
|
|
||||||
2 // ElevenLabs → OpenAI TTS → Edge-TTS → OS built-in (macOS say, Windows SAPI,
|
|
||||||
3 // Linux espeak/speech-dispatcher), überschreibbar per Config/Flag, mit
|
|
||||||
4 // Fallback bei Fehlern. Implementierung folgt in Task 02 (osbuiltin) und
|
|
||||||
5 // Task 03 (Cloud-Provider, Kette).
|
|
||||||
6 package provider
|
|
||||||
7
|
|
||||||
|
|
||||||
internal/queue/doc.go --- Go
|
|
||||||
1 // Package queue implementiert die Ansage-Queue des Daemons: serialisierte
|
|
||||||
2 // Abarbeitung (eine Ansage gleichzeitig), Prioritäten (high > normal > low,
|
|
||||||
3 // FIFO je Level), now-Items springen vor, Rate-Limit mit prioritätsbewusstem
|
|
||||||
4 // Verwerfen. Implementierung folgt in Task 04 (Queue) und Task 05
|
|
||||||
5 // (Rate-Limit, Ruhezeiten).
|
|
||||||
6 package queue
|
|
||||||
7
|
|
||||||
|
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,14 +0,0 @@
|
|||||||
## Task 1: Repo-Skeleton & CI-Backbone
|
|
||||||
|
|
||||||
Keine Blocker. Liefert: initialisierbares, grünes Go-Repo.
|
|
||||||
|
|
||||||
- `git` bereits initialisiert (main); dieser Task entsteht auf Branch `feat/v1` (besteht bereits).
|
|
||||||
- Go-Modul `code.m3ta.dev/m3tam3re/talk` (go.mod, aktuelle stabile Go-Version des Dev-Hosts).
|
|
||||||
- Verzeichnisgerüst: `cmd/talk/`, `cmd/talkd/`, `internal/{provider,queue,audio,notify,config,api,langdetect}/` (jeweils mit placehalterfreiem Miniinhalt oder leer + doc.go — kein toter Code).
|
|
||||||
- `cmd/talk/main.go`: Subcommand-Gerüst (cobra oder stdlib flag — Entscheidung: **stdlib `flag` + eigene Subcommand-Dispatch**, keine externen CLI-Abhängigkeiten in T01) mit `--version` (zeigt `dev` + Commit via ldflags-Variablen, default `dev`) und allen Subcommands als `not implemented`-Stubs mit korrektem `--help`.
|
|
||||||
- `cmd/talkd/main.go`: startet, loggt eine Zeile, exit 0 (Platzhalter).
|
|
||||||
- `.gitea/workflows/test.yml`: `go vet ./...`, `go test ./...`, `go build ./...` bei push/PR.
|
|
||||||
- `.gitignore` (Go-Binaries, `dist/`), `LICENSE` (MIT, Copyright 2026 m3tam3re), `README.md`-Gerüst (Projektname, Ein-Satz-Zweck, Status WIP, Hinweis auf Spec).
|
|
||||||
- Mind. ein sinnvoller Test, der die CI-Struktur verifiziert (z. B. Version-String-Test).
|
|
||||||
- Abnahme: `go vet && go test && go build` grün; Workflow-Datei valider YAML; README/LICENSE vorhanden; Commit(s) conventional.
|
|
||||||
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
# Task 1 Report: Repo-Skeleton & CI-Backbone
|
|
||||||
|
|
||||||
**Status:** DONE
|
|
||||||
**Branch:** `feat/v1` (Basis `c8f3902`, nie gepusht)
|
|
||||||
**Commits:**
|
|
||||||
|
|
||||||
- `24dfe4d` chore: bootstrap go module, license, readme and package skeleton
|
|
||||||
- `33b97a5` feat(cli): add subcommand dispatch, --version and stub commands
|
|
||||||
- `fa4f193` ci: add gitea actions test workflow (vet, gofmt, test, build)
|
|
||||||
|
|
||||||
## Implementiert
|
|
||||||
|
|
||||||
### Go-Modul & Layout
|
|
||||||
|
|
||||||
- `go.mod`: `module code.m3ta.dev/m3tam3re/talk`, `go 1.26.8` (aktuelle stabile
|
|
||||||
Version des Dev-Hosts; CI liest sie per `go-version-file: go.mod`). Keine
|
|
||||||
externen Dependencies, keine go.sum.
|
|
||||||
- Verzeichnisgerüst komplett:
|
|
||||||
- `cmd/talk/` (3 Dateien), `cmd/talkd/` (1 Datei)
|
|
||||||
- `internal/{provider,queue,audio,notify,config,api,langdetect}/doc.go` —
|
|
||||||
jeweils nur Package-Doku mit Zweck + Task-Verweis, kein toter Code.
|
|
||||||
|
|
||||||
### CLI (`cmd/talk/`)
|
|
||||||
|
|
||||||
- **`main.go`**: ldflags-Variablen `version = "dev"`, `commit = ""` verdrahtet
|
|
||||||
(Paket `main`, injizierbar per `-X main.version=… -X main.commit=…`);
|
|
||||||
`main()` delegiert an `run()` mit injizierbaren Writer/Return-Code-Design.
|
|
||||||
- **`cli.go`**: datengetriebene Befehlstabelle (alle 10 Subcommands aus der
|
|
||||||
bindenden CLI-Surface: say, wrap, mute, unmute, status, cancel, voices,
|
|
||||||
test, doctor, setup) + Dispatch:
|
|
||||||
- `talk "Text"` → Default-Pfad, mappt auf say (Stub vermeldet
|
|
||||||
`talk say is not implemented yet`).
|
|
||||||
- `talk <Befehl>` → Subcommand-Stub: non-zero (Exit 1) + klare
|
|
||||||
`not implemented yet`-Meldung auf stderr.
|
|
||||||
- `--version` → `talk version dev` (bzw. `talk version <v> (<commit>)` wenn
|
|
||||||
Commit injiziert — manuell verifiziert: `talk version 1.0.0-test (abc1234)`).
|
|
||||||
- `--help` / `help [Befehl]` / `<Befehl> --help` → Hilfe aus derselben
|
|
||||||
Tabelle generiert (keine Drift zwischen Hilfe und Dispatch möglich).
|
|
||||||
- say-Flags gemäß Spec bereits registriert und dokumentiert:
|
|
||||||
`--wait`, `--now`, `--priority`, `--voice`, `--lang`, `--provider` (gelten
|
|
||||||
auch für den Default-Pfad `talk "Text" --wait …`).
|
|
||||||
- Exit-Code-Konvention: 0 = OK (inkl. help/version), 1 = Stub/Fehler,
|
|
||||||
2 = Benutzungsfehler (unbekannte Flags, Aufruf ohne Argumente).
|
|
||||||
- Erweiterbar für Task 09 (Bubble Tea): neuer Eintrag in `commands` bzw.
|
|
||||||
austauschbares `register`/Run-Verhalten, ohne `run()`-Dispatch-Umbau.
|
|
||||||
|
|
||||||
### Daemon-Stub (`cmd/talkd/main.go`)
|
|
||||||
|
|
||||||
Startet, loggt eine Zeile (`talkd: Platzhalter — Daemon-Implementierung folgt
|
|
||||||
in Task 04`), beendet mit Exit 0. Verifiziert.
|
|
||||||
|
|
||||||
### CI (`.gitea/workflows/test.yml`)
|
|
||||||
|
|
||||||
Push + PR; Steps: checkout@v4 → setup-go@v5 (`go-version-file: go.mod`) →
|
|
||||||
`go vet ./...` → gofmt-Check (`gofmt -l .`, fail bei nicht-leerem Output) →
|
|
||||||
`go test ./...` → `go build ./...`. YAML validiert (nixpkgs-yq-Parser).
|
|
||||||
|
|
||||||
### Meta
|
|
||||||
|
|
||||||
- `.gitignore`: `/talk`, `/talkd`, `.exe`-Varianten, `*.test`, `*.out`, `/dist/`.
|
|
||||||
- `LICENSE`: MIT, `Copyright (c) 2026 m3tam3re`.
|
|
||||||
- `README.md` (deutsch): Zweck (1 Satz), Status WIP + Spec/Plan-Verweis,
|
|
||||||
Befehlstabelle, say-Flags, Entwicklungs-/CI-Hinweise, Lizenz.
|
|
||||||
|
|
||||||
## Getestet (Ergebnisse)
|
|
||||||
|
|
||||||
`cmd/talk/cli_test.go` — 9 Black-Box-Tests über die `run()`-Seam (in-process,
|
|
||||||
kein Subprocess-Bedarf, alle extern beobachtbares Verhalten):
|
|
||||||
|
|
||||||
| Test | Assertion |
|
|
||||||
| --- | --- |
|
|
||||||
| `TestHelpListetAlleSubcommands` | `--help` listet alle 10 Befehle (zeilenpräzise, hartkodierte Soll-Liste aus der Spec, nicht aus der Tabelle abgeleitet) |
|
|
||||||
| `TestVersionDefault` | `--version` → Exit 0, exakt `talk version dev\n` |
|
|
||||||
| `TestVersionStringMitCommit` | Commit-Injection → `talk version dev (c0ffee1)` |
|
|
||||||
| `TestSubcommandStubs` | alle 10 Befehle: Exit non-zero, stderr enthält Befehlsnamen + `not implemented yet`, stdout leer |
|
|
||||||
| `TestDefaultAufrufMapptAufSay` | `talk "Hallo Welt"` → `talk say is not implemented yet` |
|
|
||||||
| `TestSayHelpZeigtFlags` | `say --help` zeigt alle 6 say-Flags |
|
|
||||||
| `TestHelpSubcommand` | `help` + `help mute` → Exit 0, korrekte Hilfe |
|
|
||||||
| `TestOhneArgumenteZeigtUsage` | nackter Aufruf → Usage auf stderr, Exit 2 |
|
|
||||||
| `TestUnbekanntesFlag` | `--bogus` → Exit 2 + Fehlermeldung |
|
|
||||||
|
|
||||||
Vollständige Abnahme vor Commit und final auf dem Commit-Stand:
|
|
||||||
`gofmt -l .` leer · `go vet ./...` grün · `go test -count=1 ./...` grün ·
|
|
||||||
`go build ./...` grün · Working Tree clean.
|
|
||||||
|
|
||||||
Manuelle Smoke-Tests: `--version`, `--help`, `say --help`, `talk "Hallo Welt"`
|
|
||||||
(Exit 1), `talk mute` (Exit 1), `talk --bogus` (Exit 2), `talkd` (loggt, Exit
|
|
||||||
0), ldflags-Injection.
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
- Vollständigkeit gegen Brief: jedes Punkt abgedeckt (Modul ✓, Gerüst ✓,
|
|
||||||
Dispatch ✓, Version ✓, talkd ✓, CI ✓, .gitignore/LICENSE/README ✓,
|
|
||||||
sinnvoller Test ✓, Conventional Commits ✓, kein Push ✓, `.superpowers/`/
|
|
||||||
`.scratch/` unangetastet ✓).
|
|
||||||
- YAGNI-Check: Say-Flags im Stub bewusst registriert — sie sind Teil der
|
|
||||||
bindenden CLI-Surface und machen `<Befehl> --help` korrekt (Brief fordert
|
|
||||||
„korrektes --help“); keine weiteren Vorab-Strukturen gebaut.
|
|
||||||
- Test-Hygiene: nur extern beobachtbares Verhalten (Exit-Codes, stdout,
|
|
||||||
stderr); keine Implementationsdetails; Spec-Liste hartcodiert als
|
|
||||||
Drift-Wächter.
|
|
||||||
|
|
||||||
## Concerns / Hinweise für Folgetasks
|
|
||||||
|
|
||||||
1. **Stdlib-flag-Parsing-Stil:** Flags müssen vor dem Text stehen
|
|
||||||
(`talk --wait "Text"`, nicht `talk "Text" --wait`) — stdlib-Limitierung.
|
|
||||||
Task 02 sollte das Parsing-Verhalten explizit festlegen.
|
|
||||||
2. **Nackter `talk`-Aufruf** ist aktuell Benutzungsfehler (Exit 2); Task 02
|
|
||||||
ändert das ggf. zu stdin-Lesung (Kommentar im Code markiert die Stelle).
|
|
||||||
3. **`--version` nur auf Root-Ebene** (`talk --version`); Subcommand-Flagsets
|
|
||||||
kennen es nicht (`talk say --version` → Exit 2) — bewusst, Standard bei
|
|
||||||
Subcommand-CLIs.
|
|
||||||
4. `.gitea/workflows/test.yml` ist noch nie in echter Gitea-Runner-Umgebung
|
|
||||||
gelaufen (Syntax validiert, Semantik nach bekannten Actions-Konventionen);
|
|
||||||
erster echter Push zeigt es.
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
## Task 2: Erste Ansage end-to-end (OS-TTS)
|
|
||||||
|
|
||||||
Blockiert durch: 01. Liefert: `talk "Hallo Welt"` spricht über OS-TTS.
|
|
||||||
|
|
||||||
- `internal/langdetect`: reine Funktion `Detect(text string) string` → `"de"` | `"en"` (Default `"de"`); Heuristik: Common-Word-Listen + Umlaute/ß; tischgesteuerte Tests (de/en/gemischt/leer).
|
|
||||||
- `internal/provider`: Interface `Provider` mit `Name() string`, `Available(ctx) bool`, `Synthesize(ctx, text string, lang string) ([]byte, error)` (Rückgabe: dekodierbare Audio-Bytes, Format von der Implementierung dokumentiert; osbuiltin liefert WAV/PCM).
|
|
||||||
- Implementierung `osbuiltin`: Linux über `espeak`/`speech-dispatcher`-Fallback (externer Prozess — OK, es ist der Notnagel-Provider; wenn Binary fehlt: `Available() == false`), macOS über `say` (Ausgabe in temporäre WAV-Datei), Windows über SAPI (PowerShell `System.Speech`). Fehlende Binary/Runtime → klarer Fehler, kein Panik.
|
|
||||||
- `internal/audio`: `Sink`-Interface (`Play(ctx, data []byte) error`, `Name() string`); Implementierungen `oto` (embedded Playback, PCM/WAV) und `null` (verwirft, loggt). Auswahl per Config `audio.sink = "oto"|"null"` (Default `oto`).
|
|
||||||
- `internal/config`: minimale Auflösung: Defaults + TOML-Datei (falls vorhanden) + Env-Overrides (`TALK_AUDIO_SINK`); Platform-Pfad-Auflösung (`~/.config/talk` | `~/Library/Application Support/talk` | `%APPDATA%\talk`, respektive `XDG_CONFIG_HOME`/Env); tischgesteuerte Unit-Tests für Merge + Pfadlogik (Pfade injizierbar).
|
|
||||||
- `cmd/talk`: `say`-Pfad implementiert: Text (Arg oder stdin) → langdetect → Providerauswahl (hier: osbuiltin, Kette kommt in Task 03; Struktur so, dass `--provider`-Flag schon existiert und `osbuiltin` default ist) → Synthesize → Sink.Play. Blocking (Daemon kommt in Task 04).
|
|
||||||
- Abnahme: `talk "Hallo Welt"` erzeugt hörbare Ausgabe auf dem Dev-Host (Linux; espeak vorhanden oder als Dev-Abhängigkeit dokumentiert); `TALK_AUDIO_SINK=null` läuft ohne Ton durch; `go test ./...` grün (langdetect + config + provider-mit-fake-proc wo sinnvoll).
|
|
||||||
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
# 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)
|
|
||||||
```
|
|
||||||
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
## Task 3: Provider-Kette mit Cloud-Anbietern
|
|
||||||
|
|
||||||
Blockiert durch: 02. Liefert: Vier-Stufen-Fallback mit Keys + Fake-barkeit.
|
|
||||||
|
|
||||||
- Provider-Implementierungen (je `internal/provider/<name>`):
|
|
||||||
- `elevenlabs`: POST `<endpoint>/v1/text-to-speech/<voiceID>`, Header `xi-api-key`, Body `{text, model_id: "eleven_multilingual_v2", voice_settings:{stability:0.5,similarity_boost:0.75}}`, Antwort = Audio-Bytes (mp3). Key: `TALK_ELEVENLABS_KEY`; Default-Voice-ID `KXxZd16DiBqt82nbarJx`. Endpunkt konfigurierbar (Default `https://api.elevenlabs.io`).
|
|
||||||
- `openai`: POST `<endpoint>/v1/audio/speech`, Bearer-Key `TALK_OPENAI_KEY`, Body `{model:"gpt-4o-mini-tts"|"tts-1", voice, input, response_format:"mp3"}` — Entscheidungsnotiz: Modell-String konfigurierbar, Default `tts-1`. Endpunkt Default `https://api.openai.com`.
|
|
||||||
- `edge`: Microsoft Edge-TTS-Endpunkt (kostenlos, kein Key) — WebSocket/REST nach aktuellem bekannten Protokoll (edge-tts-kompatibel, Stimmen z. B. `de-DE-KatjaNeural`/`en-US-AriaNeural`); Endpunkt konfigurierbar; bei Protokoll-Änderung: klarer Fehler.
|
|
||||||
- `osbuiltin`: existiert (Task 02).
|
|
||||||
- Kette `internal/provider/chain.go`: Resolver liefert geordnete Kette laut Config (Default-Reihenfolge oben); `--provider`-Flag und `provider = "…"`-Config pinnen; `Available()` prüft Key/Endpunkt; Synthesize fällt bei Fehler (HTTP != 2xx, Timeout, Netz) auf den nächsten durch; letzter Fehler wird geloggt/gemeldet, nie still.
|
|
||||||
- Voice-Mapping: `voice.<provider>.<lang>` aus config.toml; Defaults je Provider+Sprache hinterlegt (neutral, dokumentiert).
|
|
||||||
- Tests: jeder Cloud-Provider gegen `httptest`-Fake (200 mit Bytes; 500 → Fehler; 401 → Fehler) **außer edge** (WebSocket-Protokoll — edge wird über Interface + Fehlerpfad-Mock und eine als `testing.Short` überspringbare Integration-Testmarkierung behandelt; Dokumentationspflicht im Report, welche Teile fake-bar getestet sind); Kettentests: erster 500 → zweiter getroffen → Bytes.
|
|
||||||
- Abnahme: `talk "Test" --provider=elevenlabs` mit echtem Key funktioniert am Dev-Host ( einmalig manuell, im Report dokumentiert); ohne jeden Key wählt die Kette edge bzw. osbuiltin; `go test ./...` grün.
|
|
||||||
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
# Task 3 Report: Provider-Kette mit Cloud-Anbietern
|
|
||||||
|
|
||||||
Status: **DONE** · Branch `feat/v1` · Commits `7a843ae..8351d40` (5 Commits auf `ae9f3b6`)
|
|
||||||
|
|
||||||
## Geliefert
|
|
||||||
|
|
||||||
- **`internal/provider/elevenlabs.go`** — POST `<endpoint>/v1/text-to-speech/<voiceID>`, Header `xi-api-key` (Key ausschließlich `TALK_ELEVENLABS_KEY`), Body `{text, model_id: "eleven_multilingual_v2", voice_settings: {stability: 0.5, similarity_boost: 0.75}}` (Brief-Werte wörtlich), Antwort-Bytes (MP3) unverändert. Default-Endpunkt `https://api.elevenlabs.io`.
|
|
||||||
- **`internal/provider/openai.go`** — POST `<endpoint>/v1/audio/speech`, `Authorization: Bearer <TALK_OPENAI_KEY>`, Body `{model, voice, input, response_format: "mp3"}`. Modell konfigurierbar (`[providers.openai] model`, Default `tts-1` — Entscheidungsnotz aus dem Brief umgesetzt). Default-Endpunkt `https://api.openai.com`.
|
|
||||||
- **`internal/provider/edge.go` + `edgedrm.go`** — Microsofts „Read Aloud"-WSS-Protokoll (edge-tts-kompatibel), kostenlos, kein Key. Siehe „Edge-Protokoll-Notizen" unten.
|
|
||||||
- **`internal/provider/chain.go`** — geordnete Vier-Stufen-Kette `elevenlabs → openai → edge → osbuiltin` (`DefaultOrder`); Pin per `--provider` (schlägt `provider = "…"` aus der config.toml); `Available()` rein lokal (Key/Endpunkt/Binary, keine Netz-Calls); Fallback bei HTTP ≠ 2xx, Timeout, Netz — jede übersprungene/fehlgeschlagene Stufe wird ins `Log` geschrieben (nie still), am Ende zusätzlich Fehler mit allen Stufen. **Chain implementiert Provider UND DirectSpeaker**: `Speak(ctx, text, lang)` orchestriert Bytes → `Player.Play` bzw. letzte Stufe Direktansage; bei Player-Name „null" wird die Direktansage tonlos verworfen (loggt, exit clean). Der `Player`-Vertrag ist strukturell identisch zu `audio.Sink` (`Play`+`Name`) — CLI und Task-04-Daemon übergeben ihr `audio.NewSink(name, log)`-Ergebnis unverändert; die Kette importiert das audio-Paket nicht.
|
|
||||||
- **`internal/provider/voices.go`** — Voice-Auflösung mit dokumentierter Rangfolge: `--voice` (VoicePinner) > `voice.<provider>.<lang>` aus config.toml > neutrale Repo-Defaults > en-Default. Neutrale Defaults: elevenlabs „Rachel" `21m00Tcm4TlvDq8ikWAM` (de+en), openai `alloy` (de+en), edge `de-DE-KatjaNeural` / `en-US-AriaNeural`.
|
|
||||||
- **Config (`internal/config`)** — `provider` (Pin, Env `TALK_PROVIDER`), `[providers.<name>] endpoint/model`, `[voice.<provider>] <lang> = id`. TOML-Key bewusst Mehrzahl `providers`, damit Pin und Tabelle koexistieren. Endpunkt-Env-Overrides (`TALK_ELEVENLABS_ENDPOINT` etc.) liest die Provider-Schicht (Env > Datei > Default) — config-Paket bleibt frei von Provider-Namen, Tests kommen ohne Config-Datei aus.
|
|
||||||
- **CLI (`cmd/talk/say.go` + `cli.go`)** — say baut die Kette aus Config+Flags und ruft `chain.Speak`; der Task-02-Direktansage-/null-Sink-Sonderweg steckt jetzt in der Kette. **Zusätzlich gefundener und gefixter CLI-Bug**: Go-stdlib-flag stoppt beim ersten Nicht-Flag-Argument — `talk "Test" --provider=elevenlabs` (Abnahme-Aufrufform!) hätte das Flag als Text geschluckt. `parseInterspersed` parst Flags überall (Regressionstest vorhanden).
|
|
||||||
- **Audio (`internal/audio/mp3.go`)** — MP3-Dekodierung, siehe Entscheidung unten.
|
|
||||||
|
|
||||||
## Entscheidungsnotiz: MP3-Dekodierung (Brief-Frage)
|
|
||||||
|
|
||||||
**Entschieden: audio-Paket dekodiert MP3 mit reinem Go-Dekoder `github.com/hajimehoshi/go-mp3 v0.3.4`.**
|
|
||||||
|
|
||||||
Begründung: (1) Nur OpenAI kann alternatives `response_format` (wav/pcm) anfordern — ElevenLabs und Edge-TTS liefern MP3 als einziges Format; eine einheitliche MP3-Pipeline hält die Kette symmetrisch statt zwei Wege zu pflegen. (2) Reinheits-Nachweis: kein `import "C"` im Modul (grep-geprüft), CGO-frei. (3) Cross-Compile-Nachweis: `CGO_ENABLED=0 go build` für **alle 6 Targets** (linux/darwin/windows × amd64/arm64, talk + talkd) grün — die Verpflichtung aus den Global Constraints bleibt erfüllt. (4) Ausgabe-Vertrag des Dekoders: immer 16-Bit-LE-PCM mit 2 Kanälen (Mono wie Edge 24 kHz wird gespiegelt), Sample-Rate aus dem ersten Frame — passt direkt an den oto-Sink, der dafür eine Format-Weiche (`decodeAudio`: RIFF→WAV-Parser, ID3/Frame-Sync→MP3) bekommen hat. Fixtures in `mp3_test.go` sind echte MP3s (einmalig per ffmpeg erzeugt, base64 eingebettet): mono 24 kHz 48 kbit/s (= exakt Edges Ausgabeformat) und stereo 44,1 kHz.
|
|
||||||
|
|
||||||
## Edge-Protokoll-Notizen (Dokumentationspflicht aus dem Brief)
|
|
||||||
|
|
||||||
Stand der Referenz (rany2/edge-tts, master, 2026-10 abgerufen): Das Protokoll hat sich seit dem klassischen Stand geändert — mein erster Real-Endpoint-Versuch mit dem alten Schema (plain SHA-256-Sec-MS-GEC v1) bekam **HTTP 403**. Aktuell nötig:
|
|
||||||
|
|
||||||
1. **DRM-Token Sec-MS-GEC v2**: `<16 Hex CS64>-<64 Hex SHA256-Variante>` über „5-Minuten-gerundete Windows-Filetime-Ticks + TrustedClientToken". Beide Digests sind verschleierte Varianten (CS64-Checksumme + modifizierte SHA-256 mit Mid-Block-Einstieg) — `edgedrm.go` ist eine wörtliche Portierung und gegen 4 Referenzvektoren der Python-Implementierung getestet (inkl. Zeitfenster-Raster). Sec-MS-GEC-Version `2-143.0.3650.75`.
|
|
||||||
2. **MUID-Cookie** (`Cookie: muid=<32 Zufalls-Hex groß>`) im Handshake, dazu Origin chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold und Edge-Chrome-143-User-Agent.
|
|
||||||
3. Query: `TrustedClientToken`, `ConnectionId`, `Sec-MS-GEC`, `Sec-MS-GEC-Version` (Default-Endpunkt konfigurierbar; Schema-Validierung ws/wss mit klarem Fehler).
|
|
||||||
4. Nachrichten wie bekannt: `speech.config` (Output `audio-24khz-48kbitrate-mono-mp3`), SSML-Anfrage (X-Timestamp mit Referenz-Bug-Nachstell-Z), Binär-Rahmen (2-Byte-BE-Header-Länge, `Path:audio`), Ende bei `Path:turn.end`. Bei 403: Uhrenkorrektur aus dem Date-Header + genau ein Retry (wie die Referenz).
|
|
||||||
5. **Bei künftiger Protokoll-Änderung**: alle Fehlerpfade nennen explizit „Netz/Timeout oder das Edge-TTS-Protokoll hat sich geändert" — nie still.
|
|
||||||
|
|
||||||
**Real-Verifikation (einmalig, manuell)**: `TALK_TEST_REAL_EDGE=1 go test -run TestEdgeIntegrationEcht` → **PASS, 17136 Bytes echtes Audio** vom echten Microsoft-Endpunkt; zusätzlich `talk "Sprachtest über Edge TTS" --provider=edge` mit `TALK_AUDIO_SINK=null` → „Ansage über edge", 26784 Bytes verworfen, Exit 0. Ohne jeden Key wählt die Kette damit real edge.
|
|
||||||
|
|
||||||
## Was wie fake-bar getestet ist (Brief-Pflicht)
|
|
||||||
|
|
||||||
- **elevenlabs/openai**: `httptest`-Fakes — 200 (Bytes unverändert; Methode/Pfad inkl. Voice-ID, Header, JSON-Body wörtlich geprüft), 500, 401; Available mit/ohne Key; Voice-Rangfolge (--voice > Mapping > Default > en-Fallback); leerer Text. Für die Kette zusätzlich zählende Fakes (erster 500 → zweiter getroffen → Bytes).
|
|
||||||
- **edge** (vom Brief explizit von httptest ausgenommen): Interface-/Fehlerpfad-Tests (toter Loopback-Port, falsches Schema, Nullwert-Available), reine Protokollfunktionen (SSML, Escaping inkl. Steuerzeichen, speech.config, Request-URL/Token-Redaction, CS64/SHA-Referenzvektoren, Binär-Rahmen-Split), ein **Loopback-WSS-Fake** (gorilla-Upgrader auf 127.0.0.1: voller Protokoll-Durchlauf ohne echtes Netz; prüft Query-Token, Origin, beide Client-Nachrichten, Audio-Payload; Abbruch-Variante → Protokoll-Fehler) und die **als `testing.Short` überspringbare Integrations-Markierung** `TestEdgeIntegrationEcht` (Doppel-Gate: `-short` UND `TALK_TEST_REAL_EDGE=1`; einmalig manuell ausgeführt, s. o.).
|
|
||||||
- **Kette**: Fallback 500→Stufe 2, alles kaputt (Fehler nennt Stufen), ohne Keys → edge(osbuiltin)-Pfad mit fake espeak, Pin (nur gepinnte Stufe wird angefragt), Bytes→Player, Direktansage mit ton-Player (spd-say-ARGS) und null-Player (Verwurf, spd-say NICHT gerufen), ErrNoByteOutput-Durchreichen, Default-Reihenfolge, Voice-Pin-Durchreichung.
|
|
||||||
- **CLI** (`cmd/talk`): Cloud-End-to-End über die Env-Seam (`--provider=elevenlabs` gegen Fake, null-Sink-Verwurfzeile), `--voice` im Request-Pfad, 500-Fallback bis osbuiltin, Flag-nach-Text, gepinnter Provider ohne Key → klarer Fehler. Tests fassen nie echte Endpoints: Die Env-Seam injiziert jedem Test einen toten Loopback-Port für edge.
|
|
||||||
- **Kein Standard-Test hängt am Netz** (auch nicht an Microsoft); `go test -short ./...` grün.
|
|
||||||
|
|
||||||
## Verifikation
|
|
||||||
|
|
||||||
- `gofmt -l .` leer · `go vet ./...` grün · `go test ./...` grün (80 Testfälle) · `go build ./...` grün.
|
|
||||||
- Cross-Compile: `CGO_ENABLED=0` linux/darwin/windows × amd64/arm64, je `talk` + `talkd` — 6/6 OK.
|
|
||||||
- `go.mod`: +`github.com/hajimehoshi/go-mp3 v0.3.4`, +`github.com/gorilla/websocket v1.5.3` (beide reines Go, MIT/BSD).
|
|
||||||
- Daemon `cmd/talkd` unangetastet.
|
|
||||||
|
|
||||||
## Echter-Key-Check (Abnahmekriterium)
|
|
||||||
|
|
||||||
`talk "Test" --provider=elevenlabs` mit echtem ElevenLabs-/OpenAI-Key: **nicht durchgeführt** — `TALK_ELEVENLABS_KEY` und `TALK_OPENAI_KEY` sind in der Dev-Host-Umgebung nicht gesetzt (geprüft, nicht gefragt). Ersatz-Evidenz: Request-Vertrag beider Provider ist wörtlich gegen Fakes getestet (Pfad, Header, Body inkl. model_id/voice_settings/response_format); der Edge-Provider wurde zusätzlich real verifiziert (s. o.). Nachreichende einmalige Abnahme mit Key empfohlen (gleiche Form wie der CLI-Test, nur mit echtem Endpunkt).
|
|
||||||
|
|
||||||
## Abweichungen & Begründungen
|
|
||||||
|
|
||||||
1. **ElevenLabs-Default-Stimme ist „Rachel" (21m00Tcm4TlvDq8ikWAM), nicht KXxZd16DiBqt82nbarJx.** Brief/Plan nennen KXxZ… als „Default-Voice-ID"; die Spec (bindende Autorität, „bei Konflikt gewinnt die Spec") führt KXxZ… ausdrücklich als **privaten** Default („nicht Repo-Default", Further Notes) und verlangt „neutrale Defaults im Repo". Umsetzung: neutraler Repo-Default Rachel; die persönliche Stimme gehört in die eigene config.toml (`[voice.elevenlabs] de/en = "KXxZ…"` — so auch im Code-Kommentar und README dokumentiert). Ein-Wort-Flip, falls der Controller es anders will.
|
|
||||||
2. **TOML-Key `providers` (Mehrzahl)** statt `[provider.<name>]`: `provider = "…"` (Pin, Singular) und eine Einstellungs-Tabelle können in TOML nicht denselben Schlüssel belegen — Mehrzahl löst den Konflikt ohne Spec-Verstoß (Spec schreibt keinen Tabellennamen vor).
|
|
||||||
3. **Interspersed-Flags-Fix in der CLI** (kleiner Umfang, `cli.go`): ohne ihn wäre das Abnahmekriterium `talk "Test" --provider=elevenlabs` (Flags nach dem Text) funktional unmöglich gewesen. Ändert Task-01-Dispatch minimal, alle Task-01/02-Tests bleiben grün, neuer Regressionstest deckt die Form ab.
|
|
||||||
4. **`TALK_PROVIDER` als Env-Override des Pins** (kleine Ergänzung über Brief): konsistent mit „Env schlägt Datei", erleichtert Task-04-Prozessgrenzen-Tests. Analog Endpunkt-Env-Overrides `TALK_*_ENDPOINT` (Test-Seam laut Spec-Testing-Entscheidung).
|
|
||||||
|
|
||||||
## Bedenken / Risiken für Folgetasks
|
|
||||||
|
|
||||||
- Edge-DRM ist ein undokumentierter Endpoint: weitere Microsoft-Änderungen brechen die Stufe (klar benannte Fehler + Kette fällt auf osbuiltin). Referenzvektor-Tests machen Regressionen sofort sichtbar.
|
|
||||||
- `chain.Speak` blockiert (wie Task 02) — non-blocking kommt mit dem Daemon (Task 04), der die Kette unverändert übernimmt (Player/Log/Getenv injizierbar).
|
|
||||||
- Default-Kette versucht edge real, wenn elevenlabs/openai ohne Key sind: gewollt (User Story 10), aber Offline-Hosts warten die edgeIOTimeout (20 s) nur dann ab, wenn auch edge nicht erreichbar ist und osbuiltin die Ansage macht. Verschwiegener Träger: Fehler wird protokolliert.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Review-Fix: Chain.Speak läuft im DirectSpeaker-Endgame nur noch einmal
|
|
||||||
|
|
||||||
**Fund (Important):** `Speak` lief die Kette zweimal — `Speak` → `Synthesize` (Durchlauf 1) und bei ErrNoByteOutput mit Nicht-null-Player → `SpeakDirect` (eigener kompletter Durchlauf 2). Folge: fehlgeschlagene Cloud-Stufen wurden erneut angefragt (doppelte POSTs auf bezahlten APIs), doppelte „Stufe X fehlgeschlagen"-Zeilen im Log, verdoppelte Worst-Case-Latenz, und eine transiente Erholung in Durchlauf 2 hätte den Fehler „liefert Bytes — über Synthesize …" produziert statt die geholten Bytes abzuspielen.
|
|
||||||
|
|
||||||
**Änderung (`internal/provider/chain.go`):** `Speak` macht jetzt EINEN `walk` und entscheidet aus dessen Ergebnis: Bytes → Player.Play; nur-Direkt-Stufe → `r.direct.SpeakDirect` (null-Player → Verwurf-Log + clean exit, unverändert); nichts → gebündelter Stufen-Fehler. Die Fehleraggregation steckt jetzt im geteilten Helper `walkResult.noTierErr()`, den `Synthesize` und `Speak` gemeinsam nutzen (identische Meldung wie zuvor). `Synthesize` und `SpeakDirect` behalten ihre öffentlichen Verträge unverändert (SpeakDirect macht als eigenständiger Einstieg weiter seinen eigenen Durchlauf — von `Speak` wird es nicht mehr gerufen). Externe Verträge unverändert: je Stufe genau eine Fehlschlag-Zeile, gleiche Fehler-Aggregation, DirectSpeaker nur bei ErrNoByteOutput mit Nicht-null-Player, null-Sink → Verwurf + Exit 0.
|
|
||||||
|
|
||||||
**Deckende Tests (`internal/provider/chain_test.go`):**
|
|
||||||
- NEU `TestChainDirektEndgameEinDurchlauf`: elevenlabs-Fake mit 500 wird im Direkt-Endgame **genau 1×** angefragt (Zähler), „Stufe elevenlabs fehlgeschlagen" und „Stufe edge fehlgeschlagen" stehen je **genau 1×** im Log, openai-Skip genau 1×, spd-say erhält `-w -l de Hallo Welt`.
|
|
||||||
- Bestehende Kettentests (Fallback 500→Stufe 2, Pin, Bytes→Player, Direkt mit/ohne null-Player, ErrNoByteOutput-Durchreichen, Voice-Pin) unverändert grün — sie pinnen das unveränderte Außenverhalten.
|
|
||||||
|
|
||||||
**Befehl / Output:**
|
|
||||||
```
|
|
||||||
go test -count=1 ./internal/provider/ -run 'TestChain' -v # 12/12 PASS (inkl. neuem Test)
|
|
||||||
go test -count=1 ./... # ok cmd/talk, audio, config, langdetect, provider
|
|
||||||
gofmt -l . ; go vet ./... ; go build ./... # leer / grün / grün
|
|
||||||
```
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
## Task 4: talkd-Daemon + Queue + Mute
|
|
||||||
|
|
||||||
Blockiert durch: 02, 03. Liefert: Daemon-betriebene, serialisierte Ansagen mit persistentem Mute.
|
|
||||||
|
|
||||||
- `cmd/talkd`: Daemon-Prozess; HTTP-Server auf `127.0.0.1:<port>` (Port Default `7800`, konfigurierbar `TALK_PORT`/config `server.port`); Endpunkte:
|
|
||||||
- `POST /speak` — JSON `{text, priority?, provider?, voice?, lang?, now?}` → enqueued, Antwort `202 {id}`; bei Mute: `202 {muted:true}` (verworfen, dokumentiertes Verhalten).
|
|
||||||
- `GET /status` — JSON: Queue-Länge, aktuelles Item, Mute-State, aktiver Provider, Version.
|
|
||||||
- `POST /cancel` — bricht aktive Ansage ab + leert Queue, Antwort `200 {cancelled:n}`.
|
|
||||||
- `POST /mute`, `POST /unmute` — toggelt persistenten Mute-State (Datei `mute` im Config-Dir).
|
|
||||||
- `internal/queue`: serialisierte Abarbeitung (eine Ansage gleichzeitig), Priorität (high > normal > low; gleiches Level: FIFO), `now`-Items springen vor; IDs (z. B. fortlaufend/zufällig kurz); konfigurierbares Rate-Limit existiert als Struktur, Limit-Wiring folgt Task 05 (Default unendlich in T04-Tests, dokumentiert).
|
|
||||||
- CLI-Anbindung: `talk say` (und Default) erkennt Daemon via `GET /status`; läuft er: enqueue + sofort zurück (non-blocking Default); `--wait` pollt bis abgearbeitet; `--now` setzt `now:true`; `mute`/`unmute`/`status`/`cancel` werden Daemon-Aufrufe mit plain Output.
|
|
||||||
- Degraded Mode: Daemon antwortet nicht → Lock-File `talk.lock` im Config-Dir, sequenzielles lokale Abspielen (best-effort; Kollision = warten), mute wird respektiert.
|
|
||||||
- Tests an der **Prozessgrenze** (primäre Seam): talkd mit Fake-Provider-Endpunkt + `audio.sink=null` starten; per CLI/HTTP enqueue; assert: Serialisierung (2 schnell nacheinander → nacheinander abgespielt, an Fake+Sink beobachtbar), Priorität, Mute-Persistenz über Daemon-Restart, cancel, non-blocking-Verhalten (say kehrt vor Abspielende zurück), Degraded Mode (Daemon tot → Lock-File-Pfad).
|
|
||||||
- Abnahme: alle Prozessgrenzen-Tests grün; `talkd &` + 3 parallele `talk`-Aufrufe → drei Ansagen nacheinander; `talk mute` → Daemon-Restart → weiter stumm.
|
|
||||||
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# Task 4 Report: talkd-Daemon + Queue + Mute
|
|
||||||
|
|
||||||
**Status:** DONE
|
|
||||||
**Branch:** feat/v1
|
|
||||||
**Commits:** 7 (716f6b2 → dc94f45, auf 3b44605)
|
|
||||||
**Verifikation:** `go vet ./...` ✓ · `gofmt -l .` leer ✓ · `go test ./... -count=1` ✓ (inkl. `-race` für cmd/internal, `count=5` für die Prozessgrenzen-Tests gegen Flakiness) · `go build ./...` ✓ · Cross-Compile-Smoke `CGO_ENABLED=0` für darwin/windows/amd64+arm64 und linux/arm64 ✓
|
|
||||||
|
|
||||||
## Geliefert
|
|
||||||
|
|
||||||
### internal/queue (queue.go, mute.go, rate.go)
|
|
||||||
- **Queue:** Worker-Loop mit serialisierter Abarbeitung (genau eine Ansage gleichzeitig). Reihenfolge: `now` > `high` > `normal` > `low`, FIFO je Level (Enqueue-Seq als Tiebrecher). IDs: 6 Hex-Zeilen (crypto/rand, Fallback praktisch unerreichbar).
|
|
||||||
- **MuteStore:** persistenter Mute-State als Datei `mute` im Config-Dir, Inhalt `true`/`false`, atomar geschrieben (Temp-Datei + Rename, MkdirAll bei Bedarf). Fehlende/unlesbare Datei = nicht stumm (nie „stumm aus Versehen“). Da der Daemon die Datei bei jeder /speak-Anfrage neu liest, wirkt auch eine offline (CLI, Degraded Mode) geschriebene Datei sofort.
|
|
||||||
- **RateLimiter (Seam für Task 05):** Struktur mit `Allow`/`Reserve`/`Next` steht, ist getestet (internal/queue/queue_test.go) und bewusst NOCH NICHT in den Worker-Loop verdrahtet — Task-04-Default unbegrenzt, wie der Brief verlangt. Die Seam ist in rate.go wörtlich dokumentiert (Task 05: Parking/Discard/burst-Entscheidung + config `queue.rate_limit`).
|
|
||||||
|
|
||||||
### internal/api (types.go, server.go, client.go)
|
|
||||||
- Endpunkte exakt nach Brief, methodengebunden (ServeMux-Muster → 405/404 von selbst):
|
|
||||||
- `POST /speak {text, priority?, provider?, voice?, lang?, now?}` → `202 {"id":"…"}`; bei Mute `202 {"id":"","muted":true}` (verworfen, dokumentiert). Validierung → 400 mit `{"error":…}`: kaputtes JSON, leerer Text, Müll-Priority, unbekannter Provider, Müll-Lang; Body-Limit 64 KiB.
|
|
||||||
- `GET /status` → `{version, queue, queue_ids, current (null im Leerlauf), muted, provider}`. `queue_ids` ist die dokumentierte Ergänzung (wartende IDs in Abarbeitungs-Reihenfolge) — die CLI braucht sie für `--wait`.
|
|
||||||
- `POST /cancel` → `200 {"cancelled":n}` (n = laufende + wartende).
|
|
||||||
- `POST /mute` / `POST /unmute` → `200 {"muted":bool}`.
|
|
||||||
- **Client** (CLI-Seite): `Status/Speak/Cancel/SetMute`. `api.HTTPError` (unerwarteter Status) ist von Netz-Fehlern unterscheidbar — die CLI degeneriert nur bei Netz-Fehlern und zeigt HTTP-Fehler ehrlich an.
|
|
||||||
|
|
||||||
### cmd/talkd (main.go ersetzt den Platzhalter)
|
|
||||||
- HTTP nur auf `127.0.0.1:<port>`, Port aus config `[server] port` / Env `TALK_PORT` / Default 7800. Port belegt → klarer Fehler mit Hinweis.
|
|
||||||
- Ansagen laufen über die **unveränderte Provider-Kette** (internal/provider wurde nicht angefasst): pro Item wird `provider.NewChain` gebaut (Pin/Voice sind Anfrage-Properties), `chain.Speak(ctx, …)` spielt mit Fallback + Sink. Sprache: Item-Pin oder langdetect im Daemon.
|
|
||||||
- `/status` „aktiver Provider“: Pin des laufenden Items, sonst erste verfügbare Stufe einer Basiskette (Available ist billig, kein Netz-Call); im Leerlauf der zuletzt aktive. Die Kette meldet die erfolgreiche Stufe nicht selbst — deshalb Auflösung über Available (dokumentierte Entscheidung, s. „Offene Punkte“).
|
|
||||||
- Signale (SIGINT/SIGTERM): erst `srv.Shutdown` (keine neuen Anfragen), dann `q.Close()` (laufende Ansage abbrechen, Worker-Exit abwarten).
|
|
||||||
- Mute ohne Config-Dir: Start-Warnung, Daemon läuft weiter (Mute dann deaktiviert, `/mute` → 500).
|
|
||||||
|
|
||||||
### cmd/talk (daemon.go, degraded.go, control.go, say.go refaktoriert)
|
|
||||||
- **say/Default:** Daemon-Erkennung via `GET /status` (1 s Budget). Läuft er: enqueue + sofort zurück (non-blocking Default; stdout = Item-ID, eine Zeile); `--wait` pollt /status (100 ms), bis die Ansage weder läuft noch wartet; `--now` → `now:true`; bei Mute → stdout `muted`, Exit 0.
|
|
||||||
- **Degraded Mode:** Daemon nicht erreichbar → stderr-Hinweis, dann lokal: Mute-Datei respektieren, Lock-Datei `talk.lock` im Config-Dir (O_CREATE|O_EXCL, reine stdlib; Kollision = warten, max 10 s, danach ungeschützt mit Warnung; verwaistes Lock nach 60 s entfernen), dann dieselbe Kette blockierend im CLI-Prozess. Ohne Config-Dir: ohne Lock/Mute weiterlaufen (Best-effort, konsequent zu config.Load).
|
|
||||||
- **mute/unmute:** Daemon-Aufruf (`POST /mute`/`/unmute`), stdout `muted`/`unmuted`. Daemon tot: Mute-Datei direkt schreiben (dieselbe Quelle, die der Daemon liest — greift sofort und überlebt Restarts).
|
|
||||||
- **status:** plain Block (`daemon: … (version …)`, `queue: N`, `current: …`, `mute: an|aus`, `provider: …`). Daemon tot → Exit 1 mit Hinweis.
|
|
||||||
- **cancel:** `POST /cancel`, stdout `cancelled: N`. Daemon tot → Exit 1.
|
|
||||||
- Handler-Signatur der Befehlstabelle um `stdout` erweitert (say/Steuer-Befehle geben Ergebnisse aus); `--priority` wird früh validiert; `--lang`-Validierung unverändert früh.
|
|
||||||
|
|
||||||
### config
|
|
||||||
- `Server.Port` (`[server] port`), Env `TALK_PORT` (validiert 1–65535, klarer Fehler bei Müll), Default 7800. Tischgesteuerte Tests ergänzt.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
**Prozessgrenze (primäre Seam), `test/`-Paket:** talk + talkd werden einmal pro Lauf gebaut und als echte Prozesse gefahren; ElevenLabs-Fake per httptest (protokolliert Ankünfte separat und Antworten mit Zeiten + Completed-Flag für Abbrüche), `TALK_AUDIO_SINK=null`, `TALK_EDGE_ENDPOINT` auf toten Loopback (Kette fällt nie auf echte Fremd-Endpunkte), Config-Dir isoliert (HOME/XDG auf Temp), **Ports: immer freie ephemere Ports per TALK_PORT — nie 7800, nie ein echter Daemon**. Getestet:
|
|
||||||
- Serialisierung (2+1 schnelle Enqueues → nacheinander, nicht überlappend, beobachtbar an Fake-Ankunfts-/Antwort-Zeiten + null-Sink-Verwurf-Zeilen im Daemon-Log)
|
|
||||||
- Priorität (queue_ids in Abarbeitungs-Reihenfolge + echte Abspiel-Reihenfolge high→normal→low, FIFO je Level)
|
|
||||||
- `--now` über die CLI springt vor wartende normal-Items (unterbricht nicht den Laufenden)
|
|
||||||
- cancel: bricht die Synthese ab (Fake sieht den Abbruch), leert die Queue, `cancelled:2`, Daemon danach gesund
|
|
||||||
- non-blocking (say kehrt in <500 ms zurück, Ansage läuft weiter) + `--wait` (kehrt erst nach Ende zurück)
|
|
||||||
- Mute-Persistenz über Daemon-Restart (Abnahmekriterium) + offline geschriebene Mute-Datei wirkt im laufenden Daemon
|
|
||||||
- Degraded Mode: Daemon tot → Lock-Pfad (Lock danach freigegeben), Lock-Serialisierung zweier paralleler CLIs, mute respektiert
|
|
||||||
- Steuer-Befehle über CLI mit/ohne Daemon (plain Output, Exit-Codes)
|
|
||||||
- 3 parallele talk-Aufrufe → 3 Ansagen nacheinander (Abnahmekriterium, wörtlich)
|
|
||||||
- HTTP-Vertrag: 400-Validierung, 405, Status-Felder im Leerlauf (version dev, provider elevenlabs)
|
|
||||||
|
|
||||||
**Unit (Neben-Seams):** Queue-Abarbeitung/Current/Pending/Cancel/Close/IDs über steuerbaren Speaker (deterministisch, kein Timing), MuteStore-Roundtrip, RateLimiter-Semantik, config-Port-Auflösung, CLI-Steuerbefehle ohne Daemon (in-process über die run-Seam).
|
|
||||||
|
|
||||||
**Test-Isolation der Bestands-Tests:** `setGetenv`-Grundausstattung hat jetzt `TALK_PORT=1` (analog zum toten TALK_EDGE_ENDPOINT) — die vorhandenen say-Tests laufen deterministisch durch den Degraded Mode und können nie einen zufällig laufenden echten talkd treffen.
|
|
||||||
|
|
||||||
## Concurrency-Design (geforderte Notizen)
|
|
||||||
|
|
||||||
**Serialisierung.** Ein einziger Worker-Goroutine (`loop`) entnimmt Items; nur er ruft den Speaker. Enqueue (HTTP-Handler, beliebig parallel) hängt unter `mu` an die Liste und signalisiert `notEmpty`. Wait/Signal folgt dem klassischen Muster: Der Worker prüft die Predicate (`len(items)==0 && !closed`) unter demselben Mutex, unter dem Enqueue anhängt — verlorene Wakeups sind strukturell ausgeschlossen; das Signal ist bewusst ein `sync.Cond` (kein Kanal-Select nötig, kein Missed-Wake-Reasoning).
|
|
||||||
|
|
||||||
**Cancel.** `Cancel()` nimmt `mu`, verwirft die Warteschlange und ruft bei laufender Ansage `cancelCur()`. Pop und das Setzen von `current`/`cancelCur` geschehen im Worker unter EINEM Lock-Halten — es gibt kein Fenster, in dem ein Item weder „wartend“ noch „laufend“ ist und der Cancelisierung entwischen könnte. Der Abbruch wirkt kooperativ: Der Speaker (Provider-Kette) erhält den ctx in alle Synthese-Calls (HTTP via NewRequestWithContext, Prozesse via CommandContext) — die Kette bricht ab, ohne dass der Worker etwas tun müsste. Nach Rückkehr des Speakers räumt der Worker `current` unter `mu` ab. Zählt ein Item als abgebrochen, das in dem Moment just fertig wurde, ist das Punkt-in-Zeit-Semantik (kosmetisch, dokumentiert).
|
|
||||||
|
|
||||||
**Shutdown.** `Close()` setzt `closed`, verwirft wartende Items, bricht die laufende Ansage ab (_cancelCur_) und broadcastet; danach wartet Close auf `done` (geschlossen vom Worker-Loop nach dessen letztem Durchlauf). Enqueue nach Close → `ErrClosed` → API 503. Die Daemon-Shutdown-Reihenfolge ist: HTTP-Listener dicht (`srv.Shutdown`, keine neuen Anfragen) → `q.Close()` (Abbruch + Worker-Exit abwarten) → Prozessende. So kann nie eine Ansage den Daemon überleben, und ein Handler kann nie in eine geschlossene Queue enqueuen, ohne die 503 zu sehen. `Close` ist idempotent (idempotenter Zweitaufruf wartet nur noch auf `done`).
|
|
||||||
|
|
||||||
**Bewusste Einfachheit:** EIN Mutex für alles Queue-interne (Items, current, cancelCur, closed) statt feiner Locks; lineare Pop-Suche (n ist klein); `logf` nie unter dem Mutex (Aufrufer achten darauf). `-race` über cmd/internal grün, Queue-Tests zusätzlich 3× wiederholt.
|
|
||||||
|
|
||||||
## Entscheidungen & Dokumentationspflichten
|
|
||||||
|
|
||||||
- **Mute-Discard-Punkt:** Mute wird bei `/speak` geprüft (202 `{muted:true}`, Item verworfen) — wie im Brief. Bereits laufende Ansagen werden durch `talk mute` NICHT abgebrochen (cancel ist der explizite Befehl dafür); das ist die schlichte, dokumentierte Lesart des Briefs.
|
|
||||||
- **„Aktiver Provider“ in /status:** Die Kette (unveränderlich, Task-03-Bestand) meldet die erfolgreiche Stufe nicht zurück. Auflösung daher über Pin des Items bzw. erste `Available()`-Stufe der Basiskette (billig, ohne Netz-Call); im Leerlauf der zuletzt aktive. Für doctor/status ausreichend; Task 09 (Status-Dashboard) kann das ggf. verfeinern.
|
|
||||||
- **CLI-stdout-Formate (plain, skriptbar):** say → Item-ID (eine Zeile) bzw. `muted`; mute/unmute → `muted`/`unmuted`; cancel → `cancelled: N`; status → Block mit `daemon:/queue:/current:/mute:/provider:`. In README dokumentiert.
|
|
||||||
- **Degraded-Lock:** Warten max 10 s, danach ungeschützt mit Warnung („best-effort“); verwaiste Locks (Crash) gelten nach 60 s als verfallen und werden entfernt. Bewusst stdlib-only (O_EXCL statt flock — Letzteres ist auf Windows nicht portabel).
|
|
||||||
- **status/cancel ohne Daemon = Exit 1** (sie sind Daemon-Anfragen); say ohne Daemon = Degraded Mode Exit 0.
|
|
||||||
|
|
||||||
## Selbst-Review (3b44605..HEAD, 26 Dateien, +3166/−102)
|
|
||||||
|
|
||||||
- `internal/provider` und `internal/audio`: **null Zeilen geändert** (Bindung „Kette unverändert“ eingehaltenn).
|
|
||||||
- Bestehende Tests nur minimal angefasst: `setGetenv`-Basis (TALK_PORT=1, Isolation), `wantStubCommands` (vier Befehle sind jetzt real). Kein Assert geschwächt.
|
|
||||||
- Ein im Review gefundener Wortlaut-Fehler („nicht erreichbar“ bei HTTP-500 in setMute) ist gefixt (Commit e1a3981 amendiert).
|
|
||||||
- Bekannte kosmetische Grenzen: Cancel-Zählung bei exakt gleichzeitigem Natürliche-Ende (s. o.); `--wait` ohne Timeout (bewusst — Escape ist Ctrl-C); Daemon-Probe-Budget 1 s kann bei pathologisch langsamen Loopbacks einen unnötigen Degraded-Lauf auslösen (ohne Doppl-Ansage, da kein enqueue stattfand).
|
|
||||||
|
|
||||||
## Offene Punkte für Folge-Tasks
|
|
||||||
|
|
||||||
- **Task 05:** RateLimiter in den Worker-Loop verdrahten (`queue.rate_limit`, Parking/Discard/`allow_high_burst`), Ruhezeiten; `talk status` um deren State ergänzen. Struktur + `Next()` sind vorbereitet.
|
|
||||||
- **Task 06:** CORS (nur /speak) und MCP — Handler-Konstruktion in `api.NewHandler` ist dafür die eine Stelle.
|
|
||||||
- .scratch/ unangetastet; kein Push erfolgt.
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
## Task 5: Rate-Limit + Ruhezeiten
|
|
||||||
|
|
||||||
Blockiert durch: 04. Liefert: Daemon-Gesetz gegen Spam.
|
|
||||||
|
|
||||||
- Rate-Limit in Queue: Default **1 Ansage / 30 s** (konfigurierbar `queue.rate_limit = "30s"`); Tokens/Zeitfenster-Logik; `now:true` umgeht Warteschlangen-Wartezeit nicht das Rate-Limit; bei Überschreitung: Items mit `priority=low` werden **verworfen**, `normal` wartet, `high` darf konfigurierbar verstoßen (`queue.allow_high_burst = true|false`, Default true).
|
|
||||||
- `quiet_hours`: Config `quiet_hours = "18:00-08:00"` (lokale Zeit, 24h, über Mitternacht); Verhalten configbar: `quiet_mode = "discard"|"toast"` (Default `discard`); Mute (manuell) hat immer Vorrang; `now:true` bricht auch quiet hours (bewusster Ausbruch, dokumentiert).
|
|
||||||
- Tests an der Prozessgrenze: kurzes Rate-Limit (z. B. 1/s) konfigurieren; 5 enqueues → 1 abgespielt, low verworfen, normal geparkt; quiet_hours-Fenster künstlich um die aktuelle Zeit legen → discard/toast-Verhalten.
|
|
||||||
- Abnahme: Tests grün; `talk status` zeigt Rate-Limit- und Quiet-Hours-State.
|
|
||||||
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# Task-5-Report: Rate-Limit + Ruhezeiten
|
|
||||||
|
|
||||||
Status: **DONE** · Branch `feat/v1` · Basis `dc94f45` (Task 4)
|
|
||||||
Commits: `b20e6dd` (config) → `5385a4b` (queue) → `b56c8ab` (api/talkd/cli) → `5979d45` (Prozessgrenzen-Tests) → `24ceff3` (README). Kein Push, `.scratch/` unangetastet.
|
|
||||||
|
|
||||||
## Timing-Semantik (verbindlich, wer wartet / wer verfällt / was now darf)
|
|
||||||
|
|
||||||
Rangordnung: **mute (API, immer) > now > Ruhezeit > Rate-Limit.**
|
|
||||||
|
|
||||||
| Situation | low | normal | high | now:true |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| Rate-Fenster voll (Start der letzten Ansage < `queue.rate_limit` her) | **verworfen** — schon beim Eintritt, 202 `{id:"",discarded:"rate_limit"}`; kam es bei freiem Fenster rein, verfällt es beim Worker-Durchlauf (Log-Zeile) | **parkt** in der Queue, spielt zum Slot (`rate_limit.next_slot` in /status; Slot-Wecker weckt den Worker) | mit `allow_high_burst=true` (Default): **spielt sofort** (bewusster Verstoß); `false`: parkt wie normal | **spielt sofort** — umgeht auch das Rate-Limit (korrigierte Brief-Entscheidung umgesetzt) |
|
|
||||||
| Ruhezeit aktiv (`quiet_hours`, lokal, über Mitternacht) | **verworfen**, 202 `{discarded:"quiet_hours"}` | dito | dito — Burst ist ein Rate-Recht, kein Ruhe-Recht | **spielt sofort** — bricht auch die Ruhezeit (dokumentierter Ausbruch) |
|
|
||||||
| `quiet_mode="toast"` | / | / | / | / : statt Verwurf **Toast-Ersetzung**, 202 `{discarded:"quiet_hours",toast:true}` |
|
|
||||||
| Fenster frei + keine Ruhezeit | spielt | spielt | spielt | spielt + springt vor |
|
|
||||||
|
|
||||||
Weitere Regeln, dokumentiert und getestet:
|
|
||||||
|
|
||||||
- **Jede gestartete Ansage bucht das Fenster neu** (`Reserve` vor dem Abspielen) — auch eine per now oder high-burst ausgebrochene und auch eine fehlgeschlagene (kaputter Provider darf nicht zum Maschinengewehr werden). Fenster = Mindestabstand zwischen Ansage-**Starts**.
|
|
||||||
- While parked bleibt ein normal/high-Item voll sichtbar (`queue_ids`, `--wait` funktioniert); neu eintreffende now-/burst-fähige Items werden vorgezogen (Rank-Ordnung unverändert: now > high > normal > low, FIFO je Level).
|
|
||||||
- `--wait` auf ein verworfenes Item endet sauber (ID verschwindet aus der Queue — dasselbe Muster wie bei cancel).
|
|
||||||
|
|
||||||
## Umsetzung
|
|
||||||
|
|
||||||
- `internal/queue/quiet.go` (neu): `QuietHours` — Parse `HH:MM-HH:MM` (leer = aus, Start==End = aus), `Active` (Tag-/Nachtfenster, Start inklusiv, End exklusiv), `Until` (Fenster-Ende für /status), `String` (Roundtrip).
|
|
||||||
- `internal/queue/rate.go`: `RateLimiter` (aus T4, `Next()` liefert jetzt Nullzeit bei nie gebuchtem Fenster laut Doku), dazu `Laws` + reine `decide`-Ordnung, `Admission` (Enqueue-Ergebnis mit Verwurfs-Grund), `LawState` (/status-Sicht), Grund-Konstanten `rate_limit`/`quiet_hours`.
|
|
||||||
- `internal/queue/queue.go`: Loop erweitert (nicht umgeschrieben — Rank/Serialisierung/Mute unangetastet): `readyLocked`/`takeLocked` (parken ohne Entnahme), `armWakeLocked` (Slot-Wecker broadcastet die Cond; Wait-Bedingung prüft die Gesetze neu → verlorene Signale harmlos), `Enqueue` entscheidet Ruhezeit/low-Budget schon beim Eintritt (damit die 202 den Grund nennen kann). Lock-Ordnung Queue → RateLimiter durchgehend gewahrt; Race-Detektor grün.
|
|
||||||
- `internal/config`: `[queue] rate_limit = "30s"` (Dauer-String, `"0s"` = unbegrenzt; TOML-Decode via `time.Duration`-TextUnmarshaler, verifiziert), `allow_high_burst` (Default true; partielle `[queue]`-Tabellen behalten Defaults — getestet), `quiet_hours`/`quiet_mode` (Default aus/discard). Env: `TALK_RATE_LIMIT`, `TALK_ALLOW_HIGH_BURST`, `TALK_QUIET_HOURS` (`off` = aus), `TALK_QUIET_MODE`. Validierung gegen `queue.ParseQuietHours` (eine Quelle; Import config→queue, kein Zyklus).
|
|
||||||
- `internal/api`: `/speak` antwortet bei Verwurf 202 `{id:"",discarded:…}` (+`toast:true`) — analog zum Mute-Muster, kein Fehler; `/status` additiv `rate_limit {interval, allow_high_burst, next_slot}` + `quiet_hours {window, active, mode, until}` (RFC3339 bzw. null). Kein Feld gebrochen.
|
|
||||||
- `cmd/talkd`: verwired `Laws` aus der Config, Startup-Log nennt das Gesetz.
|
|
||||||
- CLI: `say` meldet `discarded: <grund>` (+ ` (toast)`) plain auf stdout mit Exit 0 (wie `muted`); `talk status` zeigt `rate-limit: … (high-burst …), nächster slot …` und `ruhezeit: … (modus …, aktiv bis …/inaktiv)`.
|
|
||||||
- README: kompletter Abschnitt zu Semantik + Config/Env + Rangordnung; Endpunkt-Tabelle aktualisiert.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- **Prozessgrenze** (`test/ratelimit_test.go`, echte Binaries, Fake-Provider, sink=null, ephemeral Ports): kurzes Fenster (1 s) → 5 Ansagen: genau 1 spielt sofort, low verfällt beim Eintritt (HTTP **und** CLI-Output), normal parkt (queue_ids + next_slot sichtbar) und spielt frühestens zum Slot (Timing am Fake gemessen, ≥ 850 ms Abstand); high-burst an (spielt im Fenster) / aus (parkt, cancel räumt, Daemon gesund — das Warten-Aus mit langem Fenster deckt der Park-Test); `now` bricht durch Rate-Limit **und** Ruhezeit; Ruhezeit-Fenster künstlich um die lokale Jetztzeit (±1 min) → discard (auch high, kein Provider-Kontakt, Log-Zeile, until in /status) und toast (Toast-Protokoll-Zeile); `/status`- + `talk status`-Felder (Defaults 30 s/Burst an/Slot frei, gebuchter Slot nach Ansage).
|
|
||||||
- **Unit** (`internal/queue`): QuietHours-Parse/Active/Until/String (tischgesteuert inkl. Mitternacht); Queue-Gesetze mit gesteuerter Spielerin (low verfällt, normal parkt + spielt, burst ein/aus, now-Ausbruch + Folgebuchung, Ruhezeit discard/toast auch für high, LawState).
|
|
||||||
- **Config** (`internal/config`): Defaults, volle/partielle TOML, Env-Overrides inkl. `off`, klare Fehler für Müll.
|
|
||||||
- Harness: Basis-Env `TALK_RATE_LIMIT=0s` — die Task-04-Semantik-Tests (Serialisierung, Priorität, …) erwarten dichtes Durchspielen und blieben unverändert; Gesetz-Tests überschreiben bewusst.
|
|
||||||
|
|
||||||
Verifikation: `gofmt -l .` leer · `go vet ./...` grün · `go test ./...` grün · `go test -race -count=1 ./...` grün (queue+test zusätzlich `-count=2` zur Flakiness-Probe) · `go build ./...` grün · Cross-Compile-Smoke `CGO_ENABLED=0 GOOS=darwin/windows GOARCH=amd64` für talk+talkd erfolgreich.
|
|
||||||
|
|
||||||
## Entscheidungen & Hinweise
|
|
||||||
|
|
||||||
1. **`now:true` umgeht auch das Rate-Limit** — die CORRECTION im Brief ist bindend umgesetzt und überall (Code-Kommentare, README, Tests) konsistent dokumentiert; ein now-Bruch startet trotzdem ein neues Fenster.
|
|
||||||
2. **Ruhezeiten sind per Default aus** (leer). Ein Default-Fenster „18:00–08:00" würde jeden Cron/Test in den Abendstunden stilllegen (CI läuft zu jeder Uhrzeit) — Opt-in wie Mute, im Config-Kommentar begründet.
|
|
||||||
3. **`quiet_mode="toast"` ist derzeit eine Daemon-Protokoll-Zeile** über den Notification-Pfad (Log-Writer), keine OS-Benachrichtigung — die Brief-Vorgabe „log line via notification path if trivial, else document limitation": der Notification-Pfad existiert noch nicht (internal/notify ist doc-only), also die dokumentierte Einschränkung. Die Ersetzung sitzt im Gesetz an genau einer Stelle (`verdictToast`), die OS-Anbindung ersetzt nur das Log-Ziel.
|
|
||||||
4. **Degraded Mode respektiert weiterhin nur Mute**, nicht Rate-Limit/Ruhezeit (Task-4-Umfang unverändert; der Brief fordert das Gesetz für den Daemon). Der Lock-File-Pfad bleibt sequenziell-best-effort. Für V1 akzeptiert; falls gewünscht, als Folge-Task.
|
|
||||||
5. Verwurf beim **Eintritt** (low über Budget, Ruhezeit) vs. **Worker-Durchlauf**: Eintritt, damit die 202-Antwort den Grund nennen kann (so der Brief); was bei freiem Fenster eintritt und erst später verfällt, verfällt im Worker mit Log-Zeile — beides getestet bzw. in der Semantik-Tabelle dokumentiert.
|
|
||||||
6. Stale Slot-Wecker nach Close sind harmlos (Broadcast ohne Waiter; Code-Kommentar erläutert es).
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
## Task 6: CORS-Tür + MCP-Endpoint
|
|
||||||
|
|
||||||
Blockiert durch: 04. Liefert: Browser- und MCP-Anbindung auf localhost.
|
|
||||||
|
|
||||||
- CORS: `POST /speak` (und nur dieser) beantwortet `OPTIONS`-Preflight und setzt `Access-Control-Allow-Origin` aus Config-Liste `server.allowed_origins = ["https://chat.az-gruppe.com"]` (Wildcard `*` erlaubt aber nicht Default; leere Liste = kein CORS-Header). Tests: Preflight 204 + Header; nicht gelistete Origin → kein ACAO-Header.
|
|
||||||
- MCP: Streamable-HTTP-Endpoint `POST /mcp` (und ggf. `GET /mcp` für Stream) auf localhost; Implementierung des MCP-Protokolls (initialize, tools/list, tools/call) ohne schwergewichtige SDK — Decision: **eigene schlanke Implementierung des JSON-RPC-Subsets** (initialize, notifications/initialized, tools/list, tools/call), kein externer MCP-SDK-Dependency-Zwang; falls eine begründete leichte SDK existiert, im Report begründen.
|
|
||||||
- Ein Tool `speak`: Input-Schema `{text: string, priority?, provider?, lang?, now?}` → verhält sich wie POST /speak; Response = MCP-Tool-Result (Text: "queued: <id>" o. Ä.).
|
|
||||||
- `GET /status` um Felder `mcp: true, cors: [origins]` ergänzt (Compat).
|
|
||||||
- Tests: MCP-Handshake + tools/call an der Prozessgrenze (HTTP-Payloads literal getestet); CORS-Preflights.
|
|
||||||
- Abnahme: curl-Preflight von gelisteter Origin ok; ein MCP-fähiger Client (z. B. manuelle JSON-RPC-Curl-Sequenz im Test/Report) sieht `speak` und enqueued erfolgreich.
|
|
||||||
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
# Task 6 Report: CORS-Tür + MCP-Endpoint
|
|
||||||
|
|
||||||
**Status:** DONE
|
|
||||||
**Datum:** 2026-10-18
|
|
||||||
**Basis:** 24ceff3 (Tasks 1–5 verifiziert) → HEAD `30bd535`
|
|
||||||
**Commits (feat/v1, kein Push, .scratch/ unberührt):**
|
|
||||||
|
|
||||||
1. `cef6e6b` feat(config): CORS-Erlaubnisliste server.allowed_origins (TALK_ALLOWED_ORIGINS)
|
|
||||||
2. `156c20d` feat(api): CORS-Tür für POST /speak — OPTIONS-Preflight und ACAO aus Config
|
|
||||||
3. `a577597` feat(api): MCP-Endpoint POST /mcp — schlankes eigenes JSON-RPC-Subset mit speak-Tool
|
|
||||||
4. `150c374` test: Prozessgrenzen-Tests für CORS-Tür und MCP-Handshake
|
|
||||||
5. `30bd535` docs(readme): CORS-Konfiguration und MCP-Endpoint mit curl-Handshake dokumentiert
|
|
||||||
|
|
||||||
## Was geliefert wurde
|
|
||||||
|
|
||||||
### 1. CORS-Tür (nur POST /speak)
|
|
||||||
|
|
||||||
- **`internal/api/cors.go`** (neu): `OPTIONS /speak` beantwortet Browser-Preflights mit
|
|
||||||
`204 No Content` + `Access-Control-Allow-Origin` (exakt gematchte Origin bzw. literales
|
|
||||||
`*` bei Wildcard-Konfiguration), `Access-Control-Allow-Methods: POST, OPTIONS`,
|
|
||||||
`Access-Control-Allow-Headers: Content-Type`, `Access-Control-Max-Age: 600`.
|
|
||||||
Echte (non-preflight) `POST /speak`-Antworten tragen ACAO, wenn die Anfrage-Origin
|
|
||||||
gelistet ist. `Vary: Origin` steht immer auf /speak (Cache-Korrektheit).
|
|
||||||
- **Ungelistete Origin:** sauberer 204/202 **ohne** jegliche CORS-Header — die Blockade
|
|
||||||
macht der Browser; HTTP bleibt für Nicht-Browser-Clients sauber erreichbar.
|
|
||||||
- **Leere Liste (Default):** gar keine CORS-Header — Tür zu, bis konfiguriert.
|
|
||||||
- **Nur /speak:** `OPTIONS /status` → 405 (kein Preflight-Support), `GET /status` ohne
|
|
||||||
ACAO; kein anderer Endpunkt sendet CORS-Header (Prozessgrenzen-Test `TestCORSNurAufSpeak`).
|
|
||||||
- **`internal/config`:** `server.allowed_origins` (TOML-Liste) + Env-Override
|
|
||||||
`TALK_ALLOWED_ORIGINS` (kommagetrennt, Whitespace toleriert; `off` leert die Liste).
|
|
||||||
Wildcard `*` ist explizit erlaubt, NICHT Default. Leere Einträge → klarer Fehler.
|
|
||||||
Unit-Tests: `TestLoadAllowedOrigins` (Defaults, TOML, Env schlägt Datei, off,
|
|
||||||
Wildcard, Fehlerfälle).
|
|
||||||
- **`GET /status`:** additives Feld `cors: [origins]` — immer vorhanden, `[]` (nicht
|
|
||||||
`null`) bei geschlossener Tür (Compat-Anforderung des Briefs; Test `TestStatusCorsFeld`).
|
|
||||||
|
|
||||||
### 2. MCP-Endpoint (Streamable HTTP)
|
|
||||||
|
|
||||||
- **`internal/api/mcp.go`** (neu): `POST /mcp` — **eigene schlanke Implementierung des
|
|
||||||
JSON-RPC-2.0-Subsets, kein SDK-Dependency** (bindende Decision des Briefs; es wurde
|
|
||||||
auch keins in Betracht gezogen — der benötigte Umfang sind ~370 Zeilen inkl. Doku,
|
|
||||||
ein SDK wäre schwerer als der Payload). Methoden: `initialize`,
|
|
||||||
`notifications/initialized`, `ping`, `tools/list`, `tools/call`.
|
|
||||||
- **Ein Tool `speak`:** Input-Schema exakt nach Brief — `{text: string, priority?,
|
|
||||||
provider?, lang?, now?}`, `required: ["text"]`, enums für priority/lang (Test prüft
|
|
||||||
Exactly-those-5-Properties).
|
|
||||||
- **`tools/call` = `POST /speak`:** Beide Türen fahren denselben Aufnahme-Pfad
|
|
||||||
`admitSpeak` (Refactoring in `internal/api/server.go`): Validierung → Mute-Check →
|
|
||||||
Enqueue inkl. Gesetz (Rate-Limit-Budget, Ruhezeit; Rangordnung mute > now > quiet >
|
|
||||||
rate aus Task 05 bleibt unverändert in `internal/queue`). Kein Contract-Drift möglich.
|
|
||||||
- **Ergebnis-Texte:** Erfolg → `queued: <id>`; Mute → `muted: Ansage verworfen (Mute
|
|
||||||
ist aktiv)`; Verwurf → `discarded: rate_limit …` / `discarded: quiet_hours …`
|
|
||||||
(+ Toast-Hinweis). **Bewusst `isError: false`** bei muted/discarded (deliberate
|
|
||||||
Decision laut Brief: dokumentiertes Aufnahme-Ergebnis, kein Protokollfehler — der
|
|
||||||
Agent soll den Grund lesen, nicht crashen).
|
|
||||||
- **`GET /status`:** additives Feld `mcp: true` (Test `TestStatusMCPFeld`).
|
|
||||||
- Daemon-Log-Zeile nennt jetzt cors-Liste und mcp.
|
|
||||||
|
|
||||||
## MCP-Protokoll-Entscheidungen (dokumentationspflichtig)
|
|
||||||
|
|
||||||
1. **Version-Handling:** `initialize` **echot die angefragte `protocolVersion`**
|
|
||||||
(akzeptiert sie); fehlt sie, antwortet talkd mit `2025-06-18`. Inhaltlich
|
|
||||||
implementiert ist der 2025-06-18-Umfang: Initialize-Ergebnis mit
|
|
||||||
`protocolVersion`, `capabilities: {tools: {}}`, `serverInfo: {name: "talkd",
|
|
||||||
version: <daemon-version>}`; Streamable HTTP auf einem Endpoint; keine Batches
|
|
||||||
(seit 2025-06-18 entfernt → `-32600` mit klarer Meldung). Getestet: Echo von
|
|
||||||
`2025-06-18` und `2025-03-26`.
|
|
||||||
2. **GET /mcp:** talkd pusht nie (statische Tool-Liste, keine Server-Notifications,
|
|
||||||
kein Sampling) — server-initiierte SSE-Streams entfallen daher komplett. `GET /mcp`
|
|
||||||
antwortet **`405 Method Not Allowed` mit `Allow: POST, OPTIONS`** — exakt die im
|
|
||||||
Streamable-HTTP-Transport vorgesehene Antwort für Server ohne Server-Streams.
|
|
||||||
`DELETE /mcp` (Session-Terminierung) fällt auf den auto-405 des Routers.
|
|
||||||
3. **Content-Types:** Antworten auf Requests: `200 OK` + `application/json` — auch für
|
|
||||||
JSON-RPC-Fehlerobjekte (der HTTP-Teil war erfolgreich; getestet in
|
|
||||||
`TestMCPFehlerPfade`). Notifications: `202 Accepted` mit leerem Body (JSON-RPC 2.0:
|
|
||||||
nie eine Antwort). Requests: der Daemon erzwingt kein `Content-Type` (curl-freundlich,
|
|
||||||
strikter wäre SDK-Komfort ohne Vertragsgewinn).
|
|
||||||
4. **Zustandslos, keine Session-Enforcement:** Jeder POST ist unabhängig; `Mcp-Session-Id`
|
|
||||||
wird ignoriert (nie gesetzt), und die Initialize-vor-allem-Regel wird NICHT erzwungen —
|
|
||||||
ein curl kann `tools/call` direkt rufen. MCP-Clients fahren den Handshake freiwillig;
|
|
||||||
Tests fahren ihn als volle Sequenz.
|
|
||||||
5. **JSON-RPC-Subset-Details:** Notification = `id` fehlt oder ist explizit `null`
|
|
||||||
(→ 202; unbekannte Notifications werden still ignoriert). Fehlercodes: kaputtes
|
|
||||||
JSON `-32700`, falsche jsonrpc-Version/Batch `-32600`, unbekannte Methode `-32601`,
|
|
||||||
Validierung/unbekanntes Tool `-32602`, interner Fehler (enqueue, HTTP-Äquivalent
|
|
||||||
503) `-32603`. HTTP-400-Validierung mapped also auf `-32602` + deutscher Klartext
|
|
||||||
(identische Meldungen wie POST /speak). `id` wird roh geechot (number/string/
|
|
||||||
null-sicher via `json.RawMessage`). `ping` → leeres Result (billig, hilft Clients).
|
|
||||||
Body-Limit 64 KiB wie /speak.
|
|
||||||
6. **Kein CORS auf /mcp** (bindend: CORS nur auf POST /speak) — MCP-Clients sind keine
|
|
||||||
Browser. Sollte später ein Browser-MCP-Client entstehen, muss die Tür explizit
|
|
||||||
erweitert werden (bewusste Grenze, Spec: „LibreChat-Frontend-Anbindung out of scope").
|
|
||||||
|
|
||||||
## Tests (Prozessgrenze, primäre Seam — harness erweitert, nicht ersetzt)
|
|
||||||
|
|
||||||
`test/harness_test.go`: `statusBody` +`mcp`/`cors`; neue Helfer `doRequest` (rohe
|
|
||||||
HTTP-Requests mit Headern), `preflight`, `mcpPost` (literale JSON-RPC-Payloads, kein
|
|
||||||
Client-SDK), `mcpDecode`.
|
|
||||||
|
|
||||||
- **`test/cors_test.go`:** Preflight von gelisteter Origin → 204 + alle Header
|
|
||||||
(Abnahmekriterium); ungeliste Origin → kein ACAO; echter POST mit/ohne gelistete
|
|
||||||
Origin; leere Liste → gar keine CORS-Header (Default); Wildcard `*` → ACAO `*`;
|
|
||||||
CORS nur auf /speak (OPTIONS /status 405, GET /status ohne ACAO); `cors`-Feld in
|
|
||||||
/status (gelistet + `[]` bei leerer Liste).
|
|
||||||
- **`test/mcp_test.go`:** kompletter Handshake als literal JSON-RPC über HTTP —
|
|
||||||
initialize (Version-Echo 2025-06-18 und 2025-03-26, capabilities.tools, serverInfo)
|
|
||||||
→ notifications/initialized (202, leerer Body) → tools/list (genau speak, Schema mit
|
|
||||||
required=[text], genau die 5 Properties) → tools/call (`queued: <id>`, isError
|
|
||||||
falsch, **Ansage am Fake-Provider sichtbar** = echte Queue-Aufnahme, Abnahmekriterium).
|
|
||||||
Gesetze: muted → isError:false + „muted …", keine Synthese; rate_limit (30 s) →
|
|
||||||
zweite low verworfen als „discarded: rate_limit …"; quiet_hours → „discarded:
|
|
||||||
quiet_hours …". Fehlerpfade: text fehlt/leer, bad priority, unbekanntes Tool,
|
|
||||||
unbekannte Methode, kaputtes JSON, falsche jsonrpc-Version, Batch — je korrekter
|
|
||||||
Code + Meldungsanteil, Status 200; ping; GET /mcp → 405 + Allow; unbekannte
|
|
||||||
Notification still 202. Alle Daemons auf ephemeralen Ports (TALK_PORT), isoliertes
|
|
||||||
HOME/XDG, sink=null, Fake-Provider.
|
|
||||||
|
|
||||||
## Verifikation
|
|
||||||
|
|
||||||
- `gofmt -l .` leer; `go vet ./...` grün; `go build ./...` grün;
|
|
||||||
`go test ./...` komplett grün (inkl. aller Tasks 1–5-Tests, keine Regressionen —
|
|
||||||
die CLI dekodiert /status mit den additiven Feldern ohne Anpassung).
|
|
||||||
- **Cross-Compile-Smoke** (CGO_ENABLED=0): `GOOS=darwin GOARCH=amd64` und
|
|
||||||
`GOOS=windows GOARCH=amd64` für `talk` UND `talkd` gebaut — erfolgreich.
|
|
||||||
|
|
||||||
## Self-Review (git diff 24ceff3..HEAD)
|
|
||||||
|
|
||||||
- Ein Verantwortlicher pro Datei: cors.go (CORS), mcp.go (MCP), server.go (Routing +
|
|
||||||
admitSpeak-Refactoring), config.go (Liste + Env), Tests getrennt nach(cors|mcp).
|
|
||||||
- `admitSpeak` ist die einzige Aufnahme-Stelle — POST /speak-Verhalten unverändert
|
|
||||||
(alle Task-04/05-Prozesstests laufen unverändert grün).
|
|
||||||
- Keine Abnahme-Lücken gegenüber dem Brief: alle vier bindenden Testszenarien
|
|
||||||
(preflight gelistet / ungelistet / leere Liste / \*), Handshake + tools/call mit
|
|
||||||
Fake-Beobachtung, /status-Felder, curl-Sequenz in README.
|
|
||||||
|
|
||||||
## Concerns / Grenzen
|
|
||||||
|
|
||||||
- `notifications/initialized` MIT id (falsch benutzter Client) bekommt ein leeres
|
|
||||||
Result statt Fehler (tolerante Wahl, dokumentiert im Code).
|
|
||||||
- CORS-Preflight wird bewusst auch bei fehlendem `Origin`-Header mit 204 beantwortet
|
|
||||||
(kein CORS-Kontext, keine Header) — harmlos, dokumentiert.
|
|
||||||
- OS-Toast in Ruhezeit bleibt wie in Task 5 eine Log-Zeile (Notification-Anbindung
|
|
||||||
folgt später) — betrifft MCP-/CORS-Pfad nicht.
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
## Task 7: `talk wrap`
|
|
||||||
|
|
||||||
Blockiert durch: 04. Liefert: Befehlsummwicklung mit Ansage.
|
|
||||||
|
|
||||||
- `talk wrap -- <befehl> [args…]`: führt Befehl aus (stdin durchreichen, stdout/stderr durchreichen), misst Dauer, Ansage nach Ende: Erfolg: „<label> erfolgreich. Dauer: X." / Fehler: „<label> fehlgeschlagen, Code N." — Label = Config `wrap.label` oder der Basisname des Befehls; Ansage läuft über den Daemon-Pfad (enqueue, non-blocking; `--wait`-Flag respektiert).
|
|
||||||
- Exit-Code des gewrappten Befehls wird durchgereicht (talk-Fehler ≠ Befehls-Fehler: wrap exited mit Befehls-Code; wenn die Ansage selbst scheitert: Warning auf stderr, Befehls-Code bleibt).
|
|
||||||
- Dauer-Formatierung: menschlich („3 Minuten 42 Sekunden", lokalisiert de/en nach langdetect des Labels).
|
|
||||||
- Tests: Unit für Formatierung + Prozessgrenze: `talk wrap -- sleep 1` mit Fake/Sink null → enqueue-Call mit Erfolgs-Ansage verifiziert; fehlschlagender Befehl → Code 2 durchgereicht + Fehler-Ansage.
|
|
||||||
- Abnahme: `talk wrap -- sleep 2` annonciert Erfolg + Dauer; Exit-Codes korrekt.
|
|
||||||
|
|
||||||
Reference in new issue
Block a user