m3ta-chironandClaude Fable 5.1 92333ff1c3 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>
2026-10-24 09:59:04 +02:00

talk

Status: WIP — aktive Entwicklung (talk 2.0). Spezifikation mit Entscheidungs-Nachtrag: docs/spec.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> 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:

[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: <grund>. 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):

[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:

[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:

{"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: <id>" bei Aufnahme; muted/discarded: <grund> 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:

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=<name> oder provider = "<name>" 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.<name>] 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:

[voice.elevenlabs]
de = "21m00Tcm4TlvDq8ikWAM"   # eigene Stimme je Sprache
en = "21m00Tcm4TlvDq8ikWAM"

[voice.edge]
de = "de-DE-KatjaNeural"

Rangfolge: --voice-Flag > voice.<provider>.<lang> > 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 -- <Befehl> [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)
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.
[wrap]
label = "Deployment"   # Ansage-Label statt Befehls-Basisname (TALK_WRAP_LABEL)

Entwicklung

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.

S
Description
No description provided
Readme MIT
816 KiB
0 Stars 1 Watchers 0 Forks
talk v0.1.1
Latest
2026-10-25 12:59:59 +01:00
Languages
Go 99.3%
Nix 0.5%
Shell 0.2%