Usability-Schicht für Nutzer, die kein Terminal-Handbuch lesen wollen: - `talk doctor`: eine Zeile je Prüfpunkt mit klarem Satz, was Sache ist und was zu tun wäre — Config (kaputt → ✘, die übrigen Prüfungen laufen mit Defaults weiter), Daemon (läuft / startet beim nächsten Sprechbefehl / fremder Dienst auf dem Port → ✘ mit Port-Hinweis), Audio (oto-Kontext wirklich initialisieren; null → ⚠), jede Provider-Stufe (Key-Env-Name, Endpunkt, Default-Stimme; OS-Stimme nennt den gefundenen Mechanismus), Mute (an → ⚠ mit unmute-Hinweis), Gesetze inkl. gerade aktiver Ruhezeit. --probe macht echte Synthese-Proben, --json liefert die Liste. Exit 0 ohne ✘. Windows-Konsolen bekommen [OK]/[WARN]/[FAIL]. - `talk test`: Testsatz direkt über den Sprech-Kern (nicht über den Daemon), nennt die Stufe, die wirklich gesprochen hat, Synthese- und Wiedergabezeit; Mute/Ruhezeit gelten bewusst nicht, Mute wird genannt. - `talk voices`: kuratierter Katalog mit Namen (ElevenLabs-Premade, OpenAI, Edge-Neural de/en-Locales) als Tabelle, „*“ = effektive Default-Stimme (Config vor Repo-Default), Config-IDs außerhalb des Katalogs als eigene Zeile, Filter --provider/--lang, --json. - `talk setup`: wenige Fragen statt Bubble Tea — Keys nur als Hinweis (ohne Key läuft die Edge-Stimme), Stimme je Sprache aus dem Katalog mit Probehören, Ruhezeit; schreibt die config.toml zusammengeführt mit bestehenden Werten (.bak), spricht einen Testsatz. q bricht ab, bevor etwas geschrieben wird; ohne Terminal keine Fragen, nur die wirksame Konfiguration. Unterbau: provider.Catalog/VoiceName/DefaultVoice, Chain.SpeakOutcome (Stufe, Bytes, Zeiten) durch speak.Engine bis in Daemon-Log und test, audio.Check (oto-Initialisierung ohne Ton), config.Path, OSBuiltin.Describe. README: Abschnitt „Erste Schritte“. Tests: voices-Zeilen (Default-Markierung, Config-Override, Filter, JSON), doctor (ohne Daemon, Mute/Key via JSON, fremder Dienst → Exit 1, kaputte Config → ✘ aber vollständiger Bericht), test (fake espeak), setup (stdin-Antworten → config.toml-Inhalt, Merge mit .bak, Abbruch schreibt nichts, Pipe stellt keine Fragen), Prozessgrenze: doctor/--probe/voices/ test gegen echten Daemon. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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 |
bekannte Stimmen je Provider/Sprache, Default markiert (--provider, --lang, --json) |
talk test |
Testsatz sprechen; zeigt Provider und Zeiten (--lang, --provider, --voice) |
talk doctor |
Selbstdiagnose: Config, Daemon, Audio, Provider, Mute, Gesetze (--probe, --json) |
talk setup |
Ersteinrichtung in wenigen Fragen: Stimmen probehören, Ruhezeit, config.toml |
Say-Flags (gelten auch für talk "Text"): --wait, --now, --priority,
--voice, --lang, --provider.
Erste Schritte
talk doctor # Was ist da, was fehlt? Eine Zeile pro Punkt.
talk test # Hörst du etwas? Welche Stimme war das?
talk setup # Stimme aussuchen, Ruhezeit, fertig.
talk "Hallo Welt" # Ab jetzt: Skripte und Agenten rufen genau das.
Ohne jeden Key funktioniert talk sofort: Es nutzt die kostenlose Edge-
Stimme von Microsoft (Internet nötig) und sonst die Stimme des
Betriebssystems. Wer eine Premium-Stimme will, setzt TALK_ELEVENLABS_KEY
oder TALK_OPENAI_KEY als Umgebungsvariable — Keys gehören nie in die
config.toml.
Diagnose und Einrichtung
talk doctorberichtet zeilenweise mit ✔ (ok), ⚠ (Hinweis), ✘ (Fehler): Config-Datei, Daemon (läuft / startet beim nächsten Sprechbefehl / fremder Dienst auf dem Port), Audio-Ausgabe (oto initialisierbar?null= kein Ton), jede Provider-Stufe (Key vorhanden, Endpunkt, Default-Stimme; OS-Stimme: welcher Mechanismus gefunden wurde), Mute, Gesetze inkl. aktiver Ruhezeit. Exit 0 ohne ✘, sonst 1; ein nicht laufender Daemon ist ⚠.--probemacht echte Synthese-Proben (Netz),--jsonliefert die Liste maschinenlesbar. Auf Windows-Konsolen stehen[OK]/[WARN]/[FAIL]statt der Symbole.talk testspricht einen festen Testsatz direkt in diesem Prozess (nicht über den Daemon; Mute und Ruhezeit gelten hier nicht, ein aktives Mute wird genannt) und zeigt, welche Stufe gesprochen hat und wie lange Synthese und Wiedergabe gedauert haben.talk voiceslistet den kuratierten Stimmen-Katalog (ElevenLabs- Premade, OpenAI, Edge-Neural je Locale) als Tabelle;*markiert die Stimme, die talk ohne--voicenimmt (configvoice.<provider>.<lang>oder Repo-Default). Config-Einträge außerhalb des Katalogs erscheinen als eigene Zeile. Jede Voice-ID des Providers funktioniert auch ohne Katalog-Eintrag.talk setupfragt im Terminal: Keys (nur Hinweis — ohne Key läuft die Edge-Stimme), Stimme für Deutsch und Englisch der aktiven Stufe (Nummer aus dem Katalog oder Voice-ID, probehören, übernehmen), Ruhezeit. Dann schreibt es die config.toml — eine bestehende Datei wird zusammengeführt (andere Einstellungen bleiben) und als.bakgesichert — und spricht einen Testsatz.qbricht jederzeit ab, geschrieben wird erst ganz am Ende. Ohne Terminal (Pipe, Agent) stellt setup keine Fragen und zeigt nur die wirksame Konfiguration.
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(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. - Direkt-Pfad: Ist kein Daemon erreichbar und keiner startbar
(Autostart aus, Port fremd belegt, Start gescheitert), spricht
talkdirekt 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=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).
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:truewartet nie lange und ist ausgenommen.
Rangordnung: manuelles Mute gewinnt immer > now > veraltet (max_age)
Ruhezeit > Rate-Limit;
max_textgreift 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 →204mitAccess-Control-Allow-Origin(die Origin bzw.*),-Methods: POST, OPTIONS,-Headers: Content-Type,Max-Age: 600.- Echter
POST /speakmit gelisteterOriginträ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 fremdemOrigin-Header mit403ab, die nicht POST /speak von einer gelisteten Origin ist — also ungelistete Origins auf /speak, jeden Browser-Zugriff auf/status,/mute,/unmute,/cancelund/mcp, auch von gelisteten Origins. Nur der PreflightOPTIONS /speakbleibt für ungelistete Origins ein204ohne CORS-Header (ohne Wirkung; der Browser verwirft den geplanten POST). CLI, curl und MCP-Clients senden keinenOrigin-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(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.
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 --barreicht--baran 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.labelin der config.toml (EnvTALK_WRAP_LABELschlä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;
--waitrespektiert; 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.