Neuer Abschnitt zur Befehlsummwicklung: Aufrufform, Schutzwirkung des ersten „--“, Label (wrap.label/TALK_WRAP_LABEL/Basisname) samt Sprach- erkennung, Exit-Code-Politik (Befehls-Code gewinnt immer, 127 für nicht startbare Befehle, 1 für Signaltode), geerbte say-Flags und der Ansageweg über Daemon/Degraded Mode; Implementiert-Liste um wrap ergänzt.
280 lines
13 KiB
Markdown
280 lines
13 KiB
Markdown
# 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 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` sowie `wrap`.
|
||
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` (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.
|
||
- **Degraded Mode:** Antwortet kein Daemon, spielt `talk` lokal und
|
||
sequenziell weiter — serialisiert über die Lock-Datei `talk.lock` im
|
||
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=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).
|
||
|
||
**Rangordnung:** manuelles Mute gewinnt immer > `now` > Ruhezeit >
|
||
Rate-Limit. 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)
|
||
|
||
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**:
|
||
|
||
```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.
|
||
- Ungelistete Origins bekommen saubere `204`/`202` **ohne** 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:
|
||
|
||
```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: <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:
|
||
|
||
```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=<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:
|
||
|
||
```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)
|
||
|
||
```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-Pfad (enqueue, non-blocking;
|
||
`--wait` respektiert), ohne Daemon Degraded Mode über die Lock-Datei.
|
||
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).
|