Streamable HTTP nach MCP 2025-06-18-Semantik, handgewirkt ohne SDK: initialize (Version-Echo), notifications/initialized (202 ohne Body), ping, tools/list, tools/call. tools/call fährt denselben Aufnahme-Pfad wie POST /speak (admitSpeak, inkl. Mute/Gesetz); muted/discarded sind bewusst isError:false mit erklärendem Text, Validierungsfehler -32602. Zustandslos, GET /mcp → 405 (keine server-initiierten Streams). /status: mcp:true.
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 + 05)
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 |
GET /status |
{"version", "queue", "queue_ids", "current", "muted", "provider", "rate_limit": {"interval", "allow_high_burst", "next_slot"}, "quiet_hours": {"window", "active", "mode", "until"}} |
POST /cancel |
bricht die laufende Ansage ab + leert die Queue → 200 {"cancelled":n} |
POST /mute / POST /unmute |
persistenter Mute-State → 200 {"muted":bool} |
- 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).
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.