# talk > **Status: WIP** — aktive Entwicklung (talk 2.0). Binde Spezifikation: > [`.scratch/talk-2-0/spec.md`](.scratch/talk-2-0/spec.md) · > Implementierungsplan: [`.scratch/talk-2-0/plan.md`](.scratch/talk-2-0/plan.md) **talk** macht Skripte und KI-Agenten hörbar: Ein Aufruf — `talk "Build fertig"` — spricht eine kurze Nachricht über eine TTS-Provider-Kette (ElevenLabs → OpenAI → Edge-TTS → OS-Stimme), serialisiert durch einen Hintergrund-Daemon, den talk beim ersten Aufruf selbst startet. Ein statisches Binary, cross-platform (Windows/macOS/Linux, amd64/arm64), keine Runtime-Abhängigkeiten. ## Befehle | Befehl | Zweck | | --- | --- | | `talk "Text"` | Ansage — Abkürzung für `talk say`, non-blocking | | `talk say "Text"` | Ansage (Text als Argument oder von stdin) | | `talk wrap -- ` | Befehl ausführen, Erfolg/Misserfolg + Dauer ansagen | | `talk mute` / `talk unmute` | Ansagen dauerhaft stummschalten / Stummschaltung aufheben | | `talk status` | Daemon-Status (Queue, Mute, Provider, Version) | | `talk cancel` | laufende und geplante Ansagen abbrechen | | `talk daemon run\|start\|stop` | Daemon im Vordergrund betreiben / im Hintergrund starten / stoppen (normalerweise nicht nötig, siehe unten) | | `talk voices` | verfügbare Stimmen anzeigen | | `talk test` | Test-Ansage ausgeben | | `talk doctor` | Selbstdiagnose des Setups | | `talk setup` | geführte Ersteinrichtung (Wizard) | Say-Flags (gelten auch für `talk "Text"`): `--wait`, `--now`, `--priority`, `--voice`, `--lang`, `--provider`. **Hinweis:** In diesem Stand sind implementiert: `say` (und `talk "Text"`) mit der kompletten Provider-Kette und Daemon-Anbindung (non-blocking, Autostart, siehe unten), `mute`, `unmute`, `status`, `cancel`, `daemon` sowie `wrap`. Die übrigen Befehle sind Stubs und melden `not implemented yet` (Exit-Code ≠ 0). ## Daemon Es gibt genau ein Binary. Der Daemon ist `talk daemon run` — derselbe Code, im Hintergrund. Er besitzt die Ansage-Queue und spricht seriell ab — genau eine Ansage gleichzeitig, Reihenfolge `now` > `high` > `normal` > `low` (FIFO je Level). Der HTTP-Server lauscht nur auf `127.0.0.1`; Port: Default `7800`, config `[server] port`, Env `TALK_PORT` (schlagen den Default). **Autostart:** Läuft kein Daemon, startet `talk say` (und `talk wrap`) ihn beim ersten Sprechbefehl selbst — losgelöst vom Terminal, mit Protokoll in `daemon.log` und Prozess-ID in `daemon.pid` im Config-Dir; stderr meldet einmalig „Daemon gestartet". Der Daemon läuft dann weiter (Leerlauf kostet nichts Nennenswertes), bis `talk daemon stop`, Abmelden oder Neustart. Parallele erste Aufrufe sind harmlos: Verliert einer das Port-Rennen, benutzt er den Daemon des anderen. Wer den Daemon selbst verwaltet (systemd, launchd, Task Scheduler mit `talk daemon run`), schaltet den Autostart ab: ```toml [daemon] autostart = false # Env TALK_AUTOSTART=false ``` Steuerbefehle (`mute`, `unmute`, `status`, `cancel`) starten den Daemon bewusst nicht: `talk status` sagt dann „läuft nicht" (Exit 1), `talk mute` schreibt die Mute-Datei direkt. | Endpunkt | Wirkung | | --- | --- | | `POST /speak` | `{text, priority?, provider?, voice?, lang?, now?}` → `202 {"id":"…"}` (+ `"truncated":true`, wenn auf `max_text` gekürzt); bei Mute: `202 {"id":"","muted":true}`; bei Gesetz-Verwurf: `202 {"id":"","discarded":"rate_limit"\|"quiet_hours"}` (+ `"toast":true` im Toast-Modus) — verworfen, dokumentiertes Verhalten | | `OPTIONS /speak` | CORS-Preflight → `204` mit Freigabe-Headern, wenn die `Origin` in `server.allowed_origins` steht (siehe unten); sonst `204` ohne CORS-Header | | `GET /status` | `{"version", "queue", "queue_ids", "current", "muted", "provider", "rate_limit": {"interval", "allow_high_burst", "next_slot"}, "quiet_hours": {"window", "active", "mode", "until"}, "limits": {"max_text", "max_age"}, "mcp": true, "cors": [origins]}` | | `POST /cancel` | bricht die laufende Ansage ab + leert die Queue → `200 {"cancelled":n}` | | `POST /mute` / `POST /unmute` | persistenter Mute-State → `200 {"muted":bool}` | | `POST /shutdown` | fährt den Daemon herunter (`talk daemon stop`) → `200 {"ok":true}` | | `POST /mcp` | MCP (Streamable HTTP): JSON-RPC `initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call` — ein Tool `speak` (siehe unten) | | `GET /mcp` | `405` — der Daemon bietet keine server-initiierten Streams an | - **CLI-Verhalten:** `talk say` (und `talk "Text"`) erkennt den Daemon via `GET /status`. Läuft er: enqueue + sofortige Rückkehr (non-blocking, stdout erhält die Item-ID); `--wait` pollt bis zur Fertigstellung; `--now` springt vor die Warteschlange; bei Mute sagt die CLI `muted`, bei Gesetz-Verwurf `discarded: `. `talk mute`/`unmute`/`status`/ `cancel` sind Daemon-Aufrufe mit plain Output. - **Mute ist persistent** (Datei `mute` im Config-Dir, Inhalt `true`/ `false`) und überlebt Daemon-Restarts. `talk mute` ohne laufenden Daemon schreibt die Datei direkt — der Daemon liest sie bei jeder Ansage neu, der Zustand greift also auch offline. - **Direkt-Pfad:** Ist kein Daemon erreichbar und keiner startbar (Autostart aus, Port fremd belegt, Start gescheitert), spricht `talk` direkt im eigenen Prozess — blockierend, ohne Warteschlange (parallele Aufrufe können überlappen), mit Hinweis und Grund auf stderr. Mute wird auch hier respektiert. Nichts bricht still. ### Rate-Limit + Ruhezeiten (Task 05) Der Daemon erzwingt das Anti-Spam-Gesetz als Backstop unter allem Prompt-Verhalten. **Rate-Limit:** Jede *gestartete* Ansage bucht ein Zeitfenster — Default **1 Ansage / 30 s**. Bis zum Fenster-Ablauf gilt: - `priority=low` wird **verworfen** (schon beim Eintritt, die 202-Antwort nennt `discarded: "rate_limit"`); war das Fenster beim Eintritt noch frei, entscheidet der Worker später. - `normal` **parkt** in der Queue und spielt zum freien Slot (in `/status` als `rate_limit.next_slot` sichtbar). - `priority=high` darf mit `queue.allow_high_burst = true` (Default) **bewusst verstoßen**; ohne Burst-Recht wartet high wie normal. - `now:true` **umgeht auch das Rate-Limit** — der dokumentierte Ausbruch für Dringendes (startet trotzdem ein neues Fenster). **Ruhezeiten:** `quiet_hours = "18:00-08:00"` (lokale Zeit, überspannt Mitternacht; leer/`off` = aus — bewusst kein Default-Fenster, Ruhezeiten sind Opt-in wie Mute). In der Ruhezeit verfällt Audio, auch für high (Burst ist ein Rate-Recht, kein Ruhe-Recht): - `quiet_mode = "discard"` (Default): still verwerfen. - `quiet_mode = "toast"`: Benachrichtigung statt Audio — derzeit eine Protokoll-Zeile über den Notification-Pfad (OS-Toast folgt mit der Notification-Anbindung). - `now:true` **bricht auch die Ruhezeit** (bewusster Ausbruch). **Länge und Alter:** Zwei weitere Gesetze gegen das, was Prompts gern tun — die Langantwort vorlesen und Ansagen stapeln: - `max_text` (Default **400 Zeichen**, etwa zwei bis drei Sätze): Längere Texte werden beim Eintritt an einer Wortgrenze **gekürzt** und so gesprochen; die Antwort meldet `"truncated": true`, die CLI warnt auf stderr, das MCP-Ergebnis sagt es im Text. Der Nutzer hört den Anfang statt zehn Minuten Vortrag, das API-Budget bleibt begrenzt. - `max_age` (Default **2 Minuten**): Was länger in der Queue parkt (Rate- Limit, Stau), ist keine Nachricht mehr und wird beim Fälligwerden **verworfen** (`discarded: "expired"` im Protokoll; die 202-Antwort beim Eintritt bleibt eine Zusage, denn beim Eintritt ist nichts veraltet). `now:true` wartet nie lange und ist ausgenommen. **Rangordnung:** manuelles Mute gewinnt immer > `now` > veraltet (`max_age`) > Ruhezeit > Rate-Limit; `max_text` greift beim Eintritt auf alles. Konfiguration (config.toml, Env schlägt Datei): ```toml [queue] rate_limit = "30s" # Dauer; "0s" = unbegrenzt (TALK_RATE_LIMIT) allow_high_burst = true # high darf verstoßen (TALK_ALLOW_HIGH_BURST) max_text = 400 # Zeichen; 0 = unbegrenzt (TALK_MAX_TEXT) max_age = "2m" # Wartezeit; "0s" = unbegrenzt (TALK_MAX_AGE) quiet_hours = "18:00-08:00" # leer = aus; Env TALK_QUIET_HOURS ("off" = aus) quiet_mode = "discard" # discard | toast (TALK_QUIET_MODE) ``` `talk status` zeigt den Gesetz-State: `rate-limit: 30s (high-burst erlaubt), nächster slot …`, `ruhezeit: 18:00-08:00 (modus discard, aktiv bis 08:00)` und `limits: max. 400 Zeichen, max. Wartezeit 2m0s`. ### CORS-Tür + MCP-Endpoint (Task 06) **CORS** gilt bewusst nur für `POST /speak` — genau die eine Tür, die eine spätere Browser-Anbindung (z. B. LibreChat) braucht; Steuer-Endpunkte bleiben browserseitig unerreicht. Die Erlaubnis ist eine exakte Origin- Liste; `"*"` ist erlaubt, aber nicht Default; **leere Liste (Default) = gar keine CORS-Header = der Browser blockt**: ```toml [server] allowed_origins = ["https://chat.az-gruppe.com"] # Env TALK_ALLOWED_ORIGINS ``` - `OPTIONS /speak` (Preflight) von gelisteter Origin → `204` mit `Access-Control-Allow-Origin` (die Origin bzw. `*`), `-Methods: POST, OPTIONS`, `-Headers: Content-Type`, `Max-Age: 600`. - Echter `POST /speak` mit gelisteter `Origin` trägt denselben ACAO-Header. - **Die Tür ist serverseitig zu, nicht nur browserseitig.** CORS allein verhindert nur das *Lesen* der Antwort; einen POST mit `text/plain`-Body oder einen Formular-POST schickt jede Webseite ohne Preflight ab. Deshalb lehnt der Daemon jede Anfrage mit fremdem `Origin`-Header mit `403` ab, die nicht POST /speak von einer gelisteten Origin ist — also ungelistete Origins auf /speak, jeden Browser-Zugriff auf `/status`, `/mute`, `/unmute`, `/cancel` und `/mcp`, auch von gelisteten Origins. Nur der Preflight `OPTIONS /speak` bleibt für ungelistete Origins ein `204` ohne CORS-Header (ohne Wirkung; der Browser verwirft den geplanten POST). CLI, curl und MCP-Clients senden keinen `Origin`-Header und sind nicht betroffen. **MCP** (`POST /mcp`, Streamable HTTP): schlanke eigene Implementierung des JSON-RPC-Subsets ohne SDK — `initialize` (angefragte `protocolVersion` wird geechot; Ergebnis `protocolVersion`, `capabilities: {tools:{}}`, `serverInfo`), `notifications/initialized` (Notification → `202` ohne Body), `ping`, `tools/list`, `tools/call`. Der Server ist zustandslos (keine Session-IDs) und pusht nichts (daher `GET /mcp` → `405`). Ein Tool: ```json {"name": "speak", "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}, "priority": {"enum": ["low","normal","high"]}, "provider": {"type": "string"}, "lang": {"enum": ["de","en"]}, "now": {"type": "boolean"}}, "required": ["text"]}} ``` `tools/call` fährt exakt denselben Aufnahme-Pfad wie `POST /speak` (inklusive Mute, Rate-Limit, Ruhezeiten). Ergebnis ist immer Text-Content: `"queued: "` bei Aufnahme; `muted`/`discarded: ` mit Erklärung — bewusst `isError: false`, denn ein Verwurf ist das korrekte dokumentierte Ergebnis, kein Protokollfehler. Validierungsfehler (fehlender Text, ungültige Priorität, unbekanntes Tool) sind dagegen JSON-RPC-Fehler (`-32602`). Handshake als curl-Sequenz: ```sh curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"speak","arguments":{"text":"Build fertig"}}}' ``` ### Provider-Kette (Task 03) Ohne Pin läuft die Vier-Stufen-Fallback-Kette **ElevenLabs → OpenAI TTS → Edge-TTS → OS built-in**: Bei fehlendem Key, HTTP-Fehler, Timeout oder Netz-Problem fällt die Kette automatisch auf die nächste Stufe; jeder Fallback wird auf stderr protokolliert (nie still). Pinnen per `--provider=` oder `provider = ""` in der config.toml. | Stufe | Key (Env) | Default-Endpunkt | Audio | | --- | --- | --- | --- | | `elevenlabs` | `TALK_ELEVENLABS_KEY` | `https://api.elevenlabs.io` | mp3 | | `openai` | `TALK_OPENAI_KEY` | `https://api.openai.com` | mp3 | | `edge` | — (kostenlos) | `wss://speech.platform.bing.com/...` | mp3 | | `osbuiltin` | — | lokale OS-TTS | WAV/PCM | Keys laufen ausschließlich über Env-Variablen (nie in die config.toml). Endpunkte sind konfigurierbar — Datei `[providers.] endpoint = "…"` bzw. Env `TALK_ELEVENLABS_ENDPOINT` / `TALK_OPENAI_ENDPOINT` / `TALK_EDGE_ENDPOINT` (Env schlägt Datei; so zeigen Tests/CI die Kette auf Fake-Server). Das OpenAI-Modell ist konfigurierbar (`[providers.openai] model`, Default `tts-1`). Stimmen: neutralen Repo-Defaults je Provider+Sprache (ElevenLabs „Rachel", OpenAI „alloy", Edge `de-DE-KatjaNeural`/`en-US-AriaNeural`) kann man pro Sprache überordnen — config.toml: ```toml [voice.elevenlabs] de = "21m00Tcm4TlvDq8ikWAM" # eigene Stimme je Sprache en = "21m00Tcm4TlvDq8ikWAM" [voice.edge] de = "de-DE-KatjaNeural" ``` Rangfolge: `--voice`-Flag > `voice..` > Repo-Default. MP3 wird von der Binärdatei selbst dekodiert (reiner Go-Dekoder, kein ffmpeg, CGO-frei). ### OS-TTS auf Linux (Dev-Abhängigkeit espeak) Der OS-Provider `osbuiltin` nutzt auf Linux zwei Wege: - **Audio-Bytes** (Wiedergabe über den eingebauten oto-Sink): benötigt `espeak` (oder `espeak-ng`). Auf NixOS als Dev-Abhängigkeit: `nix shell nixpkgs#espeak-ng`. - **Direktansage** ohne espeak: `spd-say` (speech-dispatcher) — spielt selbst über den Daemon ab, kann aber nach `spd-say --help` keine Bytes in Dateien/Pipes liefern. `talk` nutzt diesen Weg automatisch, wenn kein espeak vorhanden ist. `TALK_AUDIO_SINK=null` (oder `audio.sink = "null"` in der config.toml) schaltet jede Tongabe ab — für CI und Server. ## `talk wrap` — Befehle annoncieren (Task 07) `talk wrap -- [Argumente …]` führt den Befehl aus und macht das Ergebnis hörbar: stdin/stdout/stderr werden durchgereicht — stdout gehört allein dem Befehl, Informationszeilen von talk (z. B. die Item-ID der Ansage) gehen auf stderr, damit Pipes und Redirects sauber bleiben. Die Wandzeit wird gemessen und nach dem Ende des Befehls angesagt: - Erfolg: „sleep erfolgreich. Dauer: 2 Sekunden." - Misserfolg: „sh fehlgeschlagen, Code 2." (ohne Dauer) ```console talk wrap -- sleep 2 talk wrap --wait -- make test ``` - **`--` schirmt den Befehl komplett ab:** Das erste allein stehende `--` beendet die talk-Flag-Auswertung — alles danach geht unverändert an den Befehl. `talk wrap -- foo --bar` reicht `--bar` an foo weiter, statt es als talk-Flag zu fehlzuinterpretieren. talk-Flags für die Ansage stehen deshalb davor (`talk wrap --wait -- …`); ohne `--` gelten die üblichen Regeln (Flags überall, auch nach Positionalargumenten). - **Label** der Ansage: `wrap.label` in der config.toml (Env `TALK_WRAP_LABEL` schlägt die Datei) oder der Basisname des Befehls (`/usr/bin/make` → „make"). Die Sprache erkennt langdetect am Label (de/en, deutscher Default); `--lang` überschreibt. - **Exit-Code-Politik:** wrap exitet immer mit dem Code des Befehls — scheitert die Ansage selbst, bleibt der Befehls-Code trotzdem (nur eine Warning auf stderr; talk-Fehler ≠ Befehls-Fehler). Nicht startbare Befehle (Binary nicht gefunden) zählen als 127 (Shell-Konvention), Signaltode portabel als 1. Benutzungsfehler von talk selbst (kein Befehl, ungültige Flags) scheitern vor dem Start des Befehls. - **Ansageweg:** derselbe wie say — Daemon (enqueue, non-blocking; `--wait` respektiert; Autostart), ohne Daemon der Direkt-Pfad. wrap erbt die say-Flags (`--wait`, `--now`, `--priority`, `--voice`, `--lang`, `--provider`) und reicht sie an die Ansage durch. ```toml [wrap] label = "Deployment" # Ansage-Label statt Befehls-Basisname (TALK_WRAP_LABEL) ``` ## Entwicklung ```console go build ./... # bauen go test ./... # Tests go vet ./... # statische Analyse gofmt -l . # Formatierung prüfen (leerer Output = ok) ``` CI: `.gitea/workflows/test.yml` (Gitea Actions) läuft vet, gofmt-Check, Tests und Build bei jedem Push und Pull Request. ## Lizenz MIT — siehe [LICENSE](LICENSE).