Der Interspersed-Loop von Task 03 nahm nach jedem Positionsargument das Flag-Parsen wieder auf und hob damit die Stdlib-Terminator-Semantik von „--“ auf: Argumente, die für einen NACHFOLGENDEN Befehl gemeint waren, wurden als talk-Flags fehlinterpretiert (talk wrap -- foo --bar wäre an --bar als unbekanntem Flag gescheitert; auch talk -- „Text --flag“ brach). Jetzt trennt parseInterspersed argv am ersten allein stehenden „--“: Nur der Teil davor wird interspersed geparst, der Rest ist bedingungslos Positionsargument. Die Abnahme-Form „Flag nach dem Text“ ohne „--“ (talk "Text" --provider=x) bleibt unverändert — Regressionstests für beide Formen in parse_test.go.
talk
Status: WIP — aktive Entwicklung (talk 2.0). Binde Spezifikation:
.scratch/talk-2-0/spec.md· Implementierungsplan:.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 den Daemon
talkd. Cross-platform (Windows/macOS/Linux, amd64/arm64), statische Binaries,
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 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 über
talkd, siehe unten), mute, unmute, status, cancel. Die übrigen
Befehle sind Stubs und melden not implemented yet (Exit-Code ≠ 0).
Daemon talkd (Task 04–06)
talkd 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).
| Endpunkt | Wirkung |
|---|---|
POST /speak |
{text, priority?, provider?, voice?, lang?, now?} → 202 {"id":"…"}; 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"}, "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 /mcp |
MCP (Streamable HTTP): JSON-RPC initialize, notifications/initialized, ping, tools/list, tools/call — ein Tool speak (siehe unten) |
GET /mcp |
405 — talkd bietet keine server-initiierten Streams an |
- CLI-Verhalten:
talk say(undtalk "Text") erkennt den Daemon viaGET /status. Läuft er: enqueue + sofortige Rückkehr (non-blocking, stdout erhält die Item-ID);--waitpollt bis zur Fertigstellung;--nowspringt vor die Warteschlange; bei Mute sagt die CLImuted, bei Gesetz-Verwurfdiscarded: <grund>.talk mute/unmute/status/cancelsind Daemon-Aufrufe mit plain Output. - Mute ist persistent (Datei
muteim Config-Dir, Inhalttrue/false) und überlebt Daemon-Restarts.talk muteohne laufenden Daemon schreibt die Datei direkt — der Daemon liest sie bei jeder Ansage neu, der Zustand greift also auch offline. - Degraded Mode: Antwortet kein Daemon, spielt
talklokal und sequenziell weiter — serialisiert über die Lock-Dateitalk.lockim Config-Dir (Kollision = warten; verwaisste Locks gelten nach 60 s als verfallen). 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=lowwird verworfen (schon beim Eintritt, die 202-Antwort nenntdiscarded: "rate_limit"); war das Fenster beim Eintritt noch frei, entscheidet der Worker später.normalparkt in der Queue und spielt zum freien Slot (in/statusalsrate_limit.next_slotsichtbar).priority=highdarf mitqueue.allow_high_burst = true(Default) bewusst verstoßen; ohne Burst-Recht wartet high wie normal.now:trueumgeht 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:truebricht auch die Ruhezeit (bewusster Ausbruch).
Rangordnung: manuelles Mute gewinnt immer > now > Ruhezeit >
Rate-Limit. 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)
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 … und ruhezeit: 18:00-08:00 (modus discard, aktiv bis 08:00).
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 →204mitAccess-Control-Allow-Origin(die Origin bzw.*),-Methods: POST, OPTIONS,-Headers: Content-Type,Max-Age: 600.- Echter
POST /speakmit gelisteterOriginträgt denselben ACAO-Header. - Ungelistete Origins bekommen saubere
204/202ohne CORS-Header — die Blockade macht der Browser.
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(oderespeak-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 nachspd-say --helpkeine Bytes in Dateien/Pipes liefern.talknutzt 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.
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.