refactor: switch the whole project to English

talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.

Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.

Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 11:15:35 +02:00
1 parent 4d30bd95ad
commit 85fa64cfe6
95 files changed
+4707 -4854

No files matched your search

+10 -10
View File
@@ -1,5 +1,5 @@
# Release-Matrix: bei Tag v* → Tests → Artefakte für alle Plattformen →
# Gitea-Release mit Assets talk_<os>_<arch>[.exe] + checksums.txt.
# Release matrix: on tag v* -> tests -> artifacts for all platforms ->
# Gitea release with assets talk_<os>_<arch>[.exe] + checksums.txt.
name: release
on:
@@ -14,27 +14,27 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # git describe braucht die Tags
- name: Go einrichten
fetch-depth: 0 # git describe needs the tags
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Tests
run: go vet ./... && go test ./...
- name: Artefakte bauen
- name: Build artifacts
run: scripts/release.sh "${{ github.ref_name }}"
- name: Gitea-Release anlegen und Assets hochladen
- name: Create Gitea release and upload assets
uses: https://gitea.com/actions/gitea-release-action@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ github.ref_name }}
name: talk ${{ github.ref_name }}
body: |
Statische Binaries (CGO_ENABLED=0), Prüfsummen in `checksums.txt`.
Static binaries (CGO_ENABLED=0), checksums in `checksums.txt`.
Installation: passendes `talk_<os>_<arch>` laden, Prüfsumme vergleichen,
als `talk` in den PATH legen, `talk doctor`. Der Daemon startet beim ersten
Sprechbefehl von selbst.
Installation: download the matching `talk_<os>_<arch>`, verify the checksum,
place it in your PATH as `talk`, run `talk doctor`. The daemon starts itself
on the first speak command.
files: |-
dist/talk_*
dist/checksums.txt
+3 -3
View File
@@ -10,17 +10,17 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Go einrichten
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: go vet
run: go vet ./...
- name: gofmt-Check
- name: gofmt check
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "Folgende Dateien sind nicht gofmt-formatiert:"
echo "The following files are not gofmt-formatted:"
echo "$unformatted"
exit 1
fi
+4 -4
View File
@@ -1,13 +1,13 @@
# Go-Binaries
# Go binaries
/talk
/talk.exe
*.test
*.out
# Release-Artefakte (GoReleaser, Task 11)
# Release artifacts (GoReleaser, task 11)
/dist/
# SDD-Prozesszustand (Ledger, Briefs, Review-Diffs, Plan): liegt auf Branch sdd-state,
# nicht im Produkt-Code. Wiederherstellen: git checkout sdd-state -- .scratch .superpowers && git reset -q
# SDD process state (ledger, briefs, review diffs, plan): lives on branch sdd-state,
# not in the product code. Restore: git checkout sdd-state -- .scratch .superpowers && git reset -q
/.scratch/
/.superpowers/
+291 -282
View File
@@ -1,40 +1,41 @@
# talk
> **Status: WIP** — aktive Entwicklung (talk 2.0). Spezifikation mit
> Entscheidungs-Nachtrag: [`docs/spec.md`](docs/spec.md)
> **Status: WIP**, under active development. Specification with the decision
> addendum: [`docs/spec.md`](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.
**talk** gives scripts and AI agents a voice: one call, `talk "Build done"`,
speaks a short announcement through a chain of TTS providers
(ElevenLabs → OpenAI → Edge TTS → OS voice), serialized by a background
daemon that talk starts itself on first use. One static binary,
cross-platform (Windows, macOS, Linux; amd64 and arm64), no runtime
dependencies.
## Befehle
## Commands
| Befehl | Zweck |
| Command | Purpose |
| --- | --- |
| `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 |
| `talk "text"` | announce; shorthand for `talk say`, non-blocking |
| `talk say "text"` | announce (text as argument or from stdin) |
| `talk wrap -- <command>` | run a command, announce success/failure and duration |
| `talk mute` / `talk unmute` | mute all announcements persistently / turn them back on |
| `talk status` | daemon status (queue, mute, provider, laws, version) |
| `talk cancel` | cancel the playing and all queued announcements |
| `talk daemon run\|start\|stop` | run the daemon in the foreground / start it in the background / stop it (normally not needed, see below) |
| `talk voices` | known voices per provider and language, default marked (`--provider`, `--lang`, `--json`) |
| `talk test` | speak a test sentence; shows provider and timings (`--lang`, `--provider`, `--voice`) |
| `talk doctor` | self-diagnosis: config, daemon, audio, providers, notifications, mute, laws (`--probe`, `--json`) |
| `talk setup` | first-time setup in a few questions: language, voices with preview, quiet hours, config.toml |
Say-Flags (gelten auch für `talk "Text"`): `--wait`, `--now`, `--priority`,
Say flags (also for `talk "text"`): `--wait`, `--now`, `--priority`,
`--voice`, `--lang`, `--provider`.
## Installation
**Release-Binary** (Linux, macOS, Windows; amd64/arm64): von den
[Releases](https://code.m3ta.dev/m3tam3re/talk/releases) das passende
`talk_<os>_<arch>` laden, Prüfsumme gegen `checksums.txt` vergleichen, als
`talk` in den PATH legen. Keine weiteren Abhängigkeiten.
**Release binary** (Linux, macOS, Windows; amd64/arm64): download the
matching `talk_<os>_<arch>` from the
[releases](https://code.m3ta.dev/m3tam3re/talk/releases), verify the
checksum against `checksums.txt`, put it in your PATH as `talk`. No other
dependencies.
```console
curl -LO https://code.m3ta.dev/m3tam3re/talk/releases/latest/download/talk_linux_amd64
@@ -42,209 +43,215 @@ sha256sum -c --ignore-missing <(curl -sL https://code.m3ta.dev/m3tam3re/talk/rel
install -m 755 talk_linux_amd64 ~/.local/bin/talk
```
**Nix** (Flake): `nix run git+https://code.m3ta.dev/m3tam3re/talk -- doctor`
oder in der Konfiguration `inputs.talk.url = "git+https://code.m3ta.dev/m3tam3re/talk";`
und `inputs.talk.packages.${system}.talk` bzw. `inputs.talk.overlays.default`.
Das Paket bringt espeak-ng (OS-Stimme) und libnotify (Toast) mit.
**Nix** (flake): `nix run git+https://code.m3ta.dev/m3tam3re/talk -- doctor`,
or in your configuration `inputs.talk.url = "git+https://code.m3ta.dev/m3tam3re/talk";`
and `inputs.talk.packages.${system}.talk` or `inputs.talk.overlays.default`.
The package brings espeak-ng (OS voice) and libnotify (toast) along.
**Aus dem Quelltext:** `go install code.m3ta.dev/m3tam3re/talk/cmd/talk@latest`.
**From source:** `go install code.m3ta.dev/m3tam3re/talk/cmd/talk@latest`.
## Erste Schritte
## Getting started
```console
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.
talk doctor # What is there, what is missing? One line per item.
talk test # Do you hear something? Which voice was it?
talk setup # Pick a language and voices, set quiet hours, done.
talk "Hello world" # From now on scripts and agents call exactly this.
```
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.
talk works without any key: it uses Microsoft's free Edge voice (needs
internet) and otherwise your operating system's voice. For a premium voice,
set `TALK_ELEVENLABS_KEY` or `TALK_OPENAI_KEY` as an environment variable.
Keys never go into config.toml.
### Diagnose und Einrichtung
### Diagnosis and setup
- **`talk doctor`** berichtet 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 ⚠. `--probe` macht echte Synthese-Proben
(Netz), `--json` liefert die Liste maschinenlesbar. Auf Windows-Konsolen
stehen `[OK]`/`[WARN]`/`[FAIL]` statt der Symbole.
- **`talk test`** spricht 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 voices`** listet den kuratierten Stimmen-Katalog (ElevenLabs-
Premade, OpenAI, Edge-Neural je Locale) als Tabelle; `*` markiert die
Stimme, die talk ohne `--voice` nimmt (config `voice.<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 setup`** fragt 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 `.bak` gesichert
— und spricht einen Testsatz. `q` bricht jederzeit ab, geschrieben wird
erst ganz am Ende. Ohne Terminal (Pipe, Agent) stellt setup keine Fragen
und zeigt nur die wirksame Konfiguration.
- **`talk doctor`** reports one line per item with ✔ (ok), ⚠ (note), ✘
(error): config file, daemon (running / starts on the next speak command
/ foreign service on the port), audio output (can oto initialize? `null`
= no sound), every provider tier (key present, endpoint, default voice;
OS voice: which mechanism was found), notifications, mute, laws
including an active quiet window. Exit 0 without ✘, else 1; a daemon that
is not running is ⚠. `--probe` runs real synthesis probes (network),
`--json` returns the list for machines. Windows consoles get
`[OK]`/`[WARN]`/`[FAIL]` instead of the symbols.
- **`talk test`** speaks a fixed test sentence directly in this process (not
via the daemon; mute and quiet hours do not apply here, an active mute is
mentioned) and shows which tier spoke and how long synthesis and playback
took.
- **`talk voices`** lists the curated voice catalog (ElevenLabs premade,
OpenAI, Edge neural voices per locale) as a table; `*` marks the voice
talk uses without `--voice` (config `voice.<provider>.<lang>` or the
built-in default). Config entries outside the catalog appear as their own
row. Any voice ID the provider knows works without a catalog entry.
- **`talk setup`** asks in the terminal: keys (only a hint; without a key
the Edge voice speaks), the fallback language, a voice for each language
of the active tier (number from the catalog or a voice ID, preview,
accept), quiet hours. Then it writes config.toml, merging an existing
file (other settings stay) and backing it up as `.bak`, and speaks a
test sentence. `q` aborts at any point; the file is written only at the
very end. Without a terminal (pipe, agent) setup asks nothing and only
shows the effective configuration.
### Language
talk detects German and English per announcement (word lists plus umlauts;
no ML) and picks the voice accordingly. When it cannot tell (numbers,
single words, ties) it uses the configured fallback language, which is also
the language of talk's own announcements such as the `wrap` result:
```toml
lang = "en" # de | en; default en (env TALK_LANG)
```
## 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).
There is exactly one binary. The daemon is `talk daemon run`: the same code,
in the background. It owns the announcement queue and plays serially, exactly
one announcement at a time, in the order `now` > `high` > `normal` > `low`
(FIFO per level). The HTTP server listens on `127.0.0.1` only; port: default
`7800`, config `[server] port`, env `TALK_PORT`.
**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:
**Autostart:** when no daemon runs, `talk say` (and `talk wrap`) start it on
the first speak command, detached from the terminal, logging to `daemon.log`
with its PID in `daemon.pid` in the config directory; stderr says "daemon
started" once. The daemon then keeps running (idle costs next to nothing)
until `talk daemon stop`, logout or reboot. Parallel first calls are
harmless: whoever loses the port race uses the other one's daemon. If you
manage the daemon yourself (systemd, launchd, Task Scheduler with `talk
daemon run`), turn autostart off:
```toml
[daemon]
autostart = false # Env TALK_AUTOSTART=false
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.
Control commands (`mute`, `unmute`, `status`, `cancel`) deliberately do not
start the daemon: `talk status` then says "not running" (exit 1), `talk
mute` writes the mute file directly.
| Endpunkt | Wirkung |
| Endpoint | Effect |
| --- | --- |
| `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 |
| `POST /speak` | `{text, priority?, provider?, voice?, lang?, now?}` → `202 {"id":"…"}` (+ `"truncated":true` when cut to `max_text`); muted: `202 {"id":"","muted":true}`; law discard: `202 {"id":"","discarded":"rate_limit"\|"quiet_hours"}` (+ `"toast":true` in toast mode); documented behaviour, not an error |
| `OPTIONS /speak` | CORS preflight → `204` with allow headers when the `Origin` is listed in `server.allowed_origins` (see below); otherwise `204` without CORS headers |
| `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 |
| `POST /cancel` | aborts the playing announcement and empties the queue → `200 {"cancelled":n}` |
| `POST /mute` / `POST /unmute` | persistent mute state → `200 {"muted":bool}` |
| `POST /shutdown` | shuts the daemon down (`talk daemon stop`) → `200 {"ok":true}` |
| `POST /mcp` | MCP (Streamable HTTP): JSON-RPC `initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call`; one tool `speak` (see below) |
| `GET /mcp` | `405`; the daemon offers no server-initiated streams |
- **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.
- **CLI behaviour:** `talk say` (and `talk "text"`) detects the daemon via
`GET /status`. If it runs: enqueue and return at once (non-blocking,
stdout gets the item ID); `--wait` polls until spoken; `--now` jumps the
queue; when muted the CLI prints `muted`, on a law discard
`discarded: <reason>`. `talk mute`/`unmute`/`status`/`cancel` are daemon
calls with plain output.
- **Mute is persistent** (file `mute` in the config directory, content
`true`/`false`) and survives daemon restarts. `talk mute` without a
running daemon writes the file directly; the daemon re-reads it on every
announcement, so the state applies offline too.
- **Direct path:** if no daemon is reachable and none can be started
(autostart off, port taken by something else, start failed), `talk`
speaks directly in its own process: blocking, without a queue (parallel
calls may overlap), with a note and the reason on stderr. Mute is
honoured here too. Nothing fails silently.
### Rate-Limit + Ruhezeiten (Task 05)
### Rate limit, quiet hours, length and age
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:
The daemon enforces the anti-spam laws as a backstop under whatever a
prompt does. **Rate limit:** every *started* announcement books a time
window, default **1 announcement / 30 s**. Until the window elapses:
- `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).
- `priority=low` is **discarded** (already on admission; the 202 response
says `discarded: "rate_limit"`); if the window was open on admission, the
worker decides later.
- `normal` **parks** in the queue and plays at the next open slot (visible
in `/status` as `rate_limit.next_slot`).
- `priority=high` may **deliberately break** the limit with
`queue.allow_high_burst = true` (default); without burst rights high
waits like normal.
- `now:true` **bypasses the rate limit too**; the documented escape for
urgent matters (it still books a new window).
**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 hours:** `quiet_hours = "18:00-08:00"` (local time, may span
midnight; empty/`off` = disabled; deliberately no default window, quiet
hours are opt-in like mute). During quiet hours audio is dropped, also for
high (burst is a rate right, not a quiet right):
- `quiet_mode = "discard"` (Default): still verwerfen.
- `quiet_mode = "toast"`: Desktop-Benachrichtigung statt Audio — über die
Bordmittel des Systems (Linux `notify-send`, macOS Notification Center,
Windows Toast), best effort: fehlt das Werkzeug, bleibt es bei der
Protokoll-Zeile, `talk doctor` zeigt es unter „Toast".
- `now:true` **bricht auch die Ruhezeit** (bewusster Ausbruch).
- `quiet_mode = "discard"` (default): drop silently.
- `quiet_mode = "toast"`: a desktop notification instead of audio, through
the system's own tools (Linux `notify-send`, macOS Notification Center,
Windows toast), best effort: if the tool is missing, only the log line
remains and `talk doctor` shows it under "Toast".
- `now:true` **breaks out of quiet hours too** (a deliberate interruption).
**Länge und Alter:** Zwei weitere Gesetze gegen das, was Prompts gern tun
— die Langantwort vorlesen und Ansagen stapeln:
**Length and age:** two more laws against what prompts like to do, reading
out the long answer and piling up announcements:
- `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.
- `max_text` (default **400 characters**, two to three sentences): longer
texts are **cut** at a word boundary on admission and spoken like that;
the response reports `"truncated": true`, the CLI warns on stderr, the
MCP result says so in its text. The user hears the beginning instead of a
ten-minute lecture, and the API budget stays bounded.
- `max_age` (default **2 minutes**): whatever parks longer in the queue
(rate limit, backlog) is no longer news and is **discarded** when it
comes due (`discarded: "expired"` in the log; the 202 on admission stays a
promise, because nothing is stale on admission). `now:true` never waits
long and is exempt.
**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):
**Precedence:** manual mute always wins > `now` > stale (`max_age`) > quiet
hours > rate limit; `max_text` applies on admission to everything.
Configuration (config.toml; env beats the file):
```toml
[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)
rate_limit = "30s" # duration; "0s" = unlimited (TALK_RATE_LIMIT)
allow_high_burst = true # high may break the limit (TALK_ALLOW_HIGH_BURST)
max_text = 400 # characters; 0 = unlimited (TALK_MAX_TEXT)
max_age = "2m" # longest wait; "0s" = unlimited (TALK_MAX_AGE)
quiet_hours = "18:00-08:00" # leer = aus; Env TALK_QUIET_HOURS ("off" = aus)
quiet_hours = "18:00-08:00" # empty = off; env TALK_QUIET_HOURS ("off" = off)
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`.
`talk status` shows the law state: `rate-limit: 30s (high burst allowed),
next slot …`, `quiet-hours: 18:00-08:00 (mode discard, active until 08:00)`
and `limits: max 400 characters, max wait 2m0s`.
### CORS-Tür + MCP-Endpoint (Task 06)
### CORS door and MCP endpoint
**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**:
**CORS** applies to `POST /speak` only, the one door a later browser
integration (for example a web chat UI) needs; control endpoints stay out of
reach of browsers. The allow-list holds exact origins; `"*"` is allowed but
not the default; **an empty list (default) means no CORS headers at all**:
```toml
[server]
allowed_origins = ["https://chat.az-gruppe.com"] # Env TALK_ALLOWED_ORIGINS
allowed_origins = ["https://chat.example.com"] # env TALK_ALLOWED_ORIGINS
```
- `OPTIONS /speak` (Preflight) von gelisteter Origin → `204` mit
`Access-Control-Allow-Origin` (die Origin bzw. `*`), `-Methods: POST,
- `OPTIONS /speak` (preflight) from a listed origin → `204` with
`Access-Control-Allow-Origin` (the origin or `*`), `-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.
- A real `POST /speak` with a listed `Origin` carries the same header.
- **The door is shut server-side, not only browser-side.** CORS alone only
prevents *reading* the response; any web page can send a POST with a
`text/plain` body or a form POST without a preflight. Therefore the
daemon rejects every request with a foreign `Origin` header with `403`
unless it is POST /speak from a listed origin: unlisted origins on
/speak, and any browser access to `/status`, `/mute`, `/unmute`,
`/cancel` and `/mcp`, even from listed origins. Only the preflight
`OPTIONS /speak` stays a `204` without CORS headers for unlisted origins
(it has no effect; the browser drops the planned POST). CLI, curl and MCP
clients send no `Origin` header and are unaffected.
**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:
**MCP** (`POST /mcp`, Streamable HTTP): a lean own implementation of the
JSON-RPC subset, no SDK: `initialize` (the requested `protocolVersion` is
echoed; result `protocolVersion`, `capabilities: {tools:{}}`,
`serverInfo`), `notifications/initialized` (notification → `202` without
body), `ping`, `tools/list`, `tools/call`. The server is stateless (no
session IDs) and pushes nothing (hence `GET /mcp` → `405`). One tool:
```json
{"name": "speak",
@@ -257,13 +264,13 @@ nichts (daher `GET /mcp` → `405`). Ein Tool:
"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:
`tools/call` takes exactly the same admission path as `POST /speak`
(including mute, rate limit, quiet hours, truncation). The result is always
text content: `"queued: <id>"` on admission; `muted`/`discarded: <reason>`
with an explanation, deliberately `isError: false`, because a discard is
the correct documented result, not a protocol error. Validation errors
(missing text, invalid priority, unknown tool) are JSON-RPC errors
(`-32602`). Handshake as a curl sequence:
```sh
curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \
@@ -271,140 +278,142 @@ curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \
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"}}}'
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"speak","arguments":{"text":"Build done"}}}'
```
### Provider-Kette (Task 03)
### Provider chain
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.
Without a pin, the four-tier fallback chain runs
**ElevenLabs → OpenAI TTS → Edge TTS → OS built-in**: on a missing key, an
HTTP error, a timeout or a network problem the chain falls through to the
next tier automatically; every fallback is logged to stderr (never silent).
Pin with `--provider=<name>` or `provider = "<name>"` in config.toml.
| Stufe | Key (Env) | Default-Endpunkt | Audio |
| Tier | Key (env) | Default endpoint | 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 |
| `edge` | none (free) | `wss://speech.platform.bing.com/...` | mp3 |
| `osbuiltin` | none | local 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`).
Keys come exclusively from environment variables (never config.toml).
Endpoints are configurable: file `[providers.<name>] endpoint = "…"` or env
`TALK_ELEVENLABS_ENDPOINT` / `TALK_OPENAI_ENDPOINT` / `TALK_EDGE_ENDPOINT`
(env beats the file; this is how tests and CI point the chain at fake
servers). The OpenAI model is configurable (`[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:
Voices: the neutral built-in defaults per provider and language (ElevenLabs
"Rachel", OpenAI "alloy", Edge `de-DE-KatjaNeural`/`en-US-AriaNeural`) can
be overridden per language in config.toml:
```toml
[voice.elevenlabs]
de = "21m00Tcm4TlvDq8ikWAM" # eigene Stimme je Sprache
de = "21m00Tcm4TlvDq8ikWAM" # your own voice per language
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).
Precedence: `--voice` flag > `voice.<provider>.<lang>` > built-in default.
MP3 is decoded by the binary itself (pure Go decoder, no ffmpeg, CGO-free).
### OS-TTS auf Linux (Dev-Abhängigkeit espeak)
Edge TTS is an undocumented Microsoft endpoint with a DRM token scheme that
Microsoft changes now and then. When that happens, the edge tier fails with
a clear "protocol changed?" error and the chain falls through to the OS
voice; `talk doctor --probe` makes it visible.
Der OS-Provider `osbuiltin` nutzt auf Linux zwei Wege:
### OS TTS on Linux
- **Audio-Bytes** (Wiedergabe über den eingebauten oto-Sink): benötigt
`espeak` (oder `espeak-ng`). Auf NixOS als Dev-Abhängigkeit:
The `osbuiltin` provider uses two routes on Linux:
- **Audio bytes** (playback through the built-in oto sink): needs
`espeak-ng` (or `espeak`). On NixOS as a dev dependency:
`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.
- **Direct speech** without espeak: `spd-say` (speech-dispatcher) plays by
itself through its daemon but cannot deliver bytes to a file or pipe.
`talk` takes this route automatically when espeak is missing.
`TALK_AUDIO_SINK=null` (oder `audio.sink = "null"` in der config.toml)
schaltet jede Tongabe ab — für CI und Server.
`TALK_AUDIO_SINK=null` (or `audio.sink = "null"` in config.toml) turns all
sound off, for CI and servers.
## `talk wrap` — Befehle annoncieren (Task 07)
## `talk wrap`: announce commands
`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:
`talk wrap -- <command> [args …]` runs the command and makes the result
audible: stdin/stdout/stderr are passed through; stdout belongs to the
command alone, talk's information lines (for example the announcement's
item ID) go to stderr so pipes and redirects stay clean. Wall time is
measured and announced after the command ends:
- Erfolg: „sleep erfolgreich. Dauer: 2 Sekunden."
- Misserfolg: „sh fehlgeschlagen, Code 2." (ohne Dauer)
- success: "sleep succeeded. Duration: 2 seconds."
- failure: "sh failed, code 2." (without duration)
```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 (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.
- **`--` shields the command completely:** the first standalone `--` ends
talk's flag parsing; everything after it goes to the command unchanged.
`talk wrap -- foo --bar` passes `--bar` to foo instead of misreading it
as a talk flag. talk's flags for the announcement therefore go before it
(`talk wrap --wait -- …`); without `--` the usual rules apply (flags
anywhere, also after positional arguments).
- **Label** of the announcement: `wrap.label` in config.toml (env
`TALK_WRAP_LABEL` beats the file) or the command's base name
(`/usr/bin/make` → "make"). Language detection runs on the label (de/en,
falling back to the configured `lang`); `--lang` overrides.
- **Exit code policy:** wrap always exits with the command's code; if the
announcement itself fails, the command's code still stands (only a
warning on stderr; talk errors are not command errors). Commands that
cannot start (binary not found) count as 127 (shell convention), signal
deaths portably as 1. Usage errors of talk itself (no command, invalid
flags) fail before the command starts.
- **Announcement path:** the same as say: daemon (enqueue, non-blocking;
`--wait` honoured; autostart), without a daemon the direct path. wrap
inherits the say flags (`--wait`, `--now`, `--priority`, `--voice`,
`--lang`, `--provider`) and passes them on.
```toml
[wrap]
label = "Deployment" # Ansage-Label statt Befehls-Basisname (TALK_WRAP_LABEL)
label = "Deployment" # announcement label instead of the command's base name (TALK_WRAP_LABEL)
```
## Für Agenten: Skill und Standing-Order
## For agents: skill and standing order
[`skills/talk/SKILL.md`](skills/talk/SKILL.md) ist die Etikette als
Skill-Artefakt: **striktes Opt-in** (Sprechen nur auf Aufforderung in der
Session oder Standing-Order in der AGENTS.md, session-sticky bis Widerruf),
Inhalt = Status · nächster Schritt · wofür der Nutzer gebraucht wird, nie
die Langantwort, `muted`/`discarded` respektieren. Der Drei-Zeilen-Block
für Projekt-AGENTS.md steht in
[`skills/talk/AGENTS-snippet.md`](skills/talk/AGENTS-snippet.md). Der
Daemon erzwingt Rate-Limit, Längen- und Alters-Grenze, Mute und Ruhezeit
unabhängig davon, was im Prompt steht.
[`skills/talk/SKILL.md`](skills/talk/SKILL.md) is the etiquette as a skill
artifact: **strict opt-in** (speak only when asked in the session or by a
standing order in the project's AGENTS.md, session-sticky until revoked),
content = status · next step · what the user is needed for, never the long
answer, honour `muted`/`discarded`. The three-line block for project
AGENTS.md files is in
[`skills/talk/AGENTS-snippet.md`](skills/talk/AGENTS-snippet.md). The daemon
enforces rate limit, length and age limits, mute and quiet hours regardless
of what the prompt says.
## Entwicklung
## Development
```console
go build ./... # bauen
go test ./... # Tests
go vet ./... # statische Analyse
gofmt -l . # Formatierung prüfen (leerer Output = ok)
nix develop # devShell mit go, gopls, espeak-ng, libnotify
go build ./... # build
go test ./... # tests
go vet ./... # static analysis
gofmt -l . # formatting check (empty output = ok)
nix develop # devShell with go, gopls, espeak-ng, libnotify
```
CI: `.gitea/workflows/test.yml` (Gitea Actions) läuft vet, gofmt-Check, Tests
und Build bei jedem Push und Pull Request.
CI: `.gitea/workflows/test.yml` (Gitea Actions) runs vet, the gofmt check,
tests and build on every push and pull request.
**Release:** Tag `v*` pushen → `.gitea/workflows/release.yml` testet, baut
mit `scripts/release.sh` die sechs Targets (statisch, `CGO_ENABLED=0`,
Version per ldflags) und legt ein Gitea-Release mit `talk_<os>_<arch>[.exe]`
und `checksums.txt` an. Lokal: `scripts/release.sh 1.2.3` → `dist/`. Bewusst
ohne GoReleaser: ein Shell-Skript über `go build` ist dasselbe Artefakt,
lokal wie in CI, ohne zusätzliches Werkzeug. `nix build .#talk` baut das
Nix-Paket (nach Änderungen an go.mod den `vendorHash` in flake.nix
nachziehen — `nix build` nennt den erwarteten Wert).
**Release:** push a `v*` tag → `.gitea/workflows/release.yml` tests, builds
the six targets with `scripts/release.sh` (static, `CGO_ENABLED=0`, version
via ldflags) and creates a Gitea release with `talk_<os>_<arch>[.exe]` and
`checksums.txt`. Locally: `scripts/release.sh 1.2.3` → `dist/`. Deliberately
without GoReleaser: a shell script around `go build` yields the same
artifact locally and in CI without another tool. `nix build .#talk` builds
the Nix package (after changes to go.mod, update the `vendorHash` in
flake.nix; `nix build` prints the expected value).
## Lizenz
## License
MIT — siehe [LICENSE](LICENSE).
MIT, see [LICENSE](LICENSE).
+116 -141
View File
@@ -8,162 +8,153 @@ import (
"slices"
)
// Exit-Codes.
// Exit codes.
const (
exitOK = 0 // Erfolg (auch --help, --version)
exitFailure = 1 // Befehl fehlgeschlagen oder noch nicht implementiert
exitUsage = 2 // Falsche Benutzung (unbekannte Flags, kein Befehl)
exitOK = 0 // success (also --help, --version)
exitFailure = 1 // command failed
exitUsage = 2 // wrong usage (unknown flags, missing command)
)
// command ist ein Eintrag der Befehlstabelle. Die Tabelle ist bewusst
// datengetrieben: Spätere Tasks (z. B. die TUI-Befehle aus Task 09) ergänzen
// hier Einträge bzw. Register-Funktionen, ohne die Dispatch-Logik in run()
// anzufassen. register hängt die Flags des Befehls an den FlagSet und liefert
// die gebundenen say-Options (derzeit der einzige Befehl mit eigenen Flags;
// erhält ein weiterer Befehl eigene Flags, wird die Signatur verallgemeinert).
// command is an entry of the command table. The table is data-driven on
// purpose: new commands add an entry instead of touching the dispatch in
// run(). register attaches the command's flags to the FlagSet and returns the
// bound options; handler executes the command.
type command struct {
name string // Subcommand-Name (bindende CLI-Surface aus der Spec)
short string // Kurzbeschreibung für `talk --help`
args string // Positionsargumente-Hinweis für die Usage-Zeile
detail string // Beschreibung für `talk <Befehl> --help`
register func(fs *flag.FlagSet) *sayOptions // hängt die Flags des Befehls an den FlagSet
handler func(args []string, o *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int // Ausführung (fehlt sie: Stub)
name string // subcommand name (part of the CLI surface)
short string // one-liner for `talk --help`
args string // positional arguments hint for the usage line
detail string // description for `talk <command> --help`
register func(fs *flag.FlagSet) *sayOptions // attaches the command's flags to the FlagSet
handler func(args []string, o *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int // execution
}
// commands ist die vollständige Befehlstabelle. Die Namen sind Teil der
// bindenden CLI-Surface (Spec: „CLI-Surface (exakt)“) — nicht umbenennen.
// commands is the complete command table. The names are part of the CLI
// surface; do not rename.
var commands = []command{
{
name: "say",
short: "Text ansagen (Default-Befehl)",
args: `"Text"`,
detail: "Spricht den Text über die Provider-Kette aus. Ohne Text-Argument wird stdin gelesen. `talk \"Text\"` ist die Abkürzung für `talk say \"Text\"`.",
short: "speak a text (default command)",
args: `"text"`,
detail: "Speaks the text through the provider chain. Without a text argument, stdin is read. `talk \"text\"` is shorthand for `talk say \"text\"`.",
register: addSayFlags,
handler: runSay,
},
{
name: "wrap",
short: "Befehl ausführen und Ergebnis ansagen",
args: "-- <Befehl> [Argumente …]",
detail: "Führt den Befehl aus (stdin/stdout/stderr werden durchgereicht) und spricht danach Erfolg bzw. Misserfolg und Dauer. Das erste „--\" schirmt den Befehl komplett von den talk-Flags ab: `talk wrap -- foo --bar` reicht --bar an foo weiter. Der Exit-Code des Befehls wird durchgereicht; scheitert die Ansage selbst, bleibt er trotzdem. Label der Ansage: config `wrap.label` oder der Basisname des Befehls. wrap erbt die say-Flags (--wait, --now, --priority, --voice, --lang, --provider).",
short: "run a command and announce the result",
args: "-- <command> [args …]",
detail: "Runs the command (stdin/stdout/stderr are passed through) and then announces success or failure and the duration. The first \"--\" shields the command completely from talk's flags: `talk wrap -- foo --bar` passes --bar to foo. The command's exit code is passed through; if the announcement itself fails, the code still stands. Announcement label: config `wrap.label` or the command's base name. wrap inherits the say flags (--wait, --now, --priority, --voice, --lang, --provider).",
register: addSayFlags,
handler: runWrap,
},
{
name: "mute",
short: "Ansagen dauerhaft stummschalten",
detail: "Aktiviert den persistenten Mute-State (Datei `mute` im Config-Verzeichnis). Läuft der Daemon, wird er darüber informiert; andernfalls schreibt talk die Datei direkt — der Zustand überlebt Daemon-Restarts.",
short: "mute all announcements persistently",
detail: "Turns on the persistent mute state (file `mute` in the config directory). A running daemon is told; otherwise talk writes the file directly. The state survives daemon restarts.",
handler: runMute,
},
{
name: "unmute",
short: "Stummschaltung aufheben",
detail: "Hebt den persistenten Mute-State wieder auf (Daemon-Aufruf; ohne Daemon direkt über die Datei).",
short: "turn announcements back on",
detail: "Clears the persistent mute state (daemon call; without a daemon directly via the file).",
handler: runUnmute,
},
{
name: "status",
short: "Daemon-Status anzeigen",
detail: "Zeigt Queue, aktuelles Item, Mute-State, aktiven Provider und Daemon-Version (plain, eine Zeile pro Feld).",
short: "show the daemon status",
detail: "Shows queue, current item, mute state, active provider, laws and daemon version (plain, one line per field). Does not start the daemon.",
handler: runStatus,
},
{
name: "daemon",
short: "Hintergrundprozess steuern (run | start | stop)",
short: "control the background process (run | start | stop)",
args: "[run|start|stop]",
detail: "run (Default) betreibt den Daemon im Vordergrund — für systemd, launchd oder ein Terminal. start startet ihn im Hintergrund (Log: daemon.log im Config-Verzeichnis), stop fährt einen laufenden Daemon herunter. Normalerweise nicht nötig: talk startet den Daemon beim ersten Sprechbefehl selbst (daemon.autostart, Default an).",
detail: "run (default) runs the daemon in the foreground, for systemd, launchd or a terminal. start launches it in the background (log: daemon.log in the config directory), stop shuts a running daemon down. Normally not needed: talk starts the daemon itself on the first speak command (daemon.autostart, default on).",
handler: runDaemon,
},
{
name: "cancel",
short: "Laufende und geplante Ansagen abbrechen",
detail: "Bricht die laufende Ansage ab und leert die Queue des Daemons; Ausgabe ist die Zahl der abgebrochenen Ansagen.",
short: "cancel the playing and all queued announcements",
detail: "Aborts the playing announcement and empties the daemon's queue; prints the number of cancelled announcements.",
handler: runCancel,
},
{
name: "voices",
short: "Verfügbare Stimmen anzeigen",
detail: "Listet bekannte Stimmen je Provider und Sprache als Tabelle; „*“ markiert die Stimme, die talk ohne --voice nimmt (config voice.<provider>.<lang> oder Repo-Default). Filter: --provider, --lang. --json für Maschinen. Jede Voice-ID des Providers funktioniert auch ohne Katalog-Eintrag.",
short: "list known voices",
detail: "Lists known voices per provider and language as a table; \"*\" marks the voice talk uses without --voice (config voice.<provider>.<lang> or the built-in default). Filters: --provider, --lang. --json for machines. Any voice ID the provider knows works without a catalog entry.",
register: addVoicesFlags,
handler: runVoices,
},
{
name: "test",
short: "Test-Ansage ausgeben",
detail: "Spricht eine feste Testphrase direkt in diesem Prozess (ohne Daemon, auch bei Mute) und zeigt, welcher Provider gesprochen hat und wie lange Synthese und Wiedergabe gedauert haben. --lang de|en wählt die Phrase, --provider/--voice pinnen wie bei say.",
short: "speak a test sentence",
detail: "Speaks a fixed test sentence directly in this process (not via the daemon, even when muted) and shows which provider spoke and how long synthesis and playback took. --lang de|en picks the sentence, --provider/--voice pin like say.",
register: addTestFlags,
handler: runTest,
},
{
name: "doctor",
short: "Setup-Diagnose anzeigen",
detail: "Prüft Config, Daemon, Audio-Ausgabe, jede Provider-Stufe, Mute und Gesetze und berichtet zeilenweise mit ✔ (ok), ⚠ (Hinweis), ✘ (Fehler). Exit 0 ohne ✘, sonst 1; ein nicht laufender Daemon ist ⚠, kein Fehler. --probe macht echte Synthese-Proben bei allen verfügbaren Providern (Netz). --json für Maschinen.",
short: "diagnose the setup",
detail: "Checks config, daemon, audio output, every provider tier, notifications, mute and laws and reports one line each with ✔ (ok), ⚠ (note), ✘ (error). Exit 0 without ✘, else 1; a daemon that is not running is ⚠, not an error. --probe runs real synthesis probes against every available provider (network). --json for machines.",
register: addDoctorFlags,
handler: runDoctor,
},
{
name: "setup",
short: "Geführte Ersteinrichtung",
detail: "Fragt in wenigen Schritten nach dem, was talk zum Loslegen braucht: Keys (optional — ohne Key spricht die kostenlose Edge-Stimme), Stimme je Sprache mit Probehören, Ruhezeit — und schreibt die config.toml (bestehende Datei wird zusammengeführt, Sicherung .bak). Ohne Terminal: zeigt nur die wirksame Konfiguration.",
short: "guided first-time setup",
detail: "Asks a few questions for what talk needs to get going: keys (optional; without a key the free Edge voice speaks), a voice per language with preview, quiet hours, and writes config.toml (an existing file is merged, backup .bak). Without a terminal: only shows the effective configuration.",
handler: runSetup,
},
}
// addSayFlags registriert die Flags des say-Befehls und bindet sie an die
// zurückgegebene Options-Struct. Sie gelten auch für den Default-Pfad
// `talk "Text"` (gleiche Flag-Oberfläche, festgelegt in der Spec). Verhalten
// seit Task 04: läuft ein Daemon, wird enqueue (non-blocking); --wait pollt
// bis zur Fertigstellung, --now springt vor. Ohne Daemon: Degraded Mode.
//
// Rückgabe ist bewusst ein Pointer: flag.Var bindet an die Felder genau
// dieser Struct — eine Wertkopie hätte nach dem Parse weiterhin die
// Defaults.
// addSayFlags registers the say flags and binds them to the returned options
// struct. They also apply to the default form `talk "text"`. A pointer is
// returned on purpose: flag.Var binds to the fields of exactly this struct.
func addSayFlags(fs *flag.FlagSet) *sayOptions {
o := &sayOptions{}
fs.BoolVar(&o.wait, "wait", false, "warten, bis die Ansage fertig abgespielt ist")
fs.BoolVar(&o.now, "now", false, "Ansage sofort ausgeben (springt die Warteschlange)")
fs.StringVar(&o.priority, "priority", "normal", "Priorität: low | normal | high")
fs.StringVar(&o.voice, "voice", "", "Stimme als provider-spezifische ID")
fs.StringVar(&o.lang, "lang", "", "Sprache: de | en (Default: automatische Erkennung)")
fs.StringVar(&o.provider, "provider", "", "Provider pinnen: elevenlabs | openai | edge | osbuiltin")
fs.BoolVar(&o.wait, "wait", false, "wait until the announcement has been spoken")
fs.BoolVar(&o.now, "now", false, "speak immediately (jumps the queue; breaks out of rate limit and quiet hours)")
fs.StringVar(&o.priority, "priority", "normal", "priority: low | normal | high")
fs.StringVar(&o.voice, "voice", "", "voice as a provider-specific ID")
fs.StringVar(&o.lang, "lang", "", "language: de | en (default: detected)")
fs.StringVar(&o.provider, "provider", "", "pin a provider: elevenlabs | openai | edge | osbuiltin")
return o
}
// addVoicesFlags registriert die Flags von voices (Filter + JSON).
// addVoicesFlags registers the voices flags (filters + JSON).
func addVoicesFlags(fs *flag.FlagSet) *sayOptions {
o := &sayOptions{}
fs.StringVar(&o.provider, "provider", "", "nur dieser Provider: elevenlabs | openai | edge | osbuiltin")
fs.StringVar(&o.lang, "lang", "", "nur diese Sprache: de | en")
fs.BoolVar(&o.jsonOut, "json", false, "maschinenlesbare JSON-Ausgabe")
fs.StringVar(&o.provider, "provider", "", "only this provider: elevenlabs | openai | edge | osbuiltin")
fs.StringVar(&o.lang, "lang", "", "only this language: de | en")
fs.BoolVar(&o.jsonOut, "json", false, "machine-readable JSON output")
return o
}
// addTestFlags registriert die Flags von test (Phrase + Pins).
// addTestFlags registers the test flags (sentence + pins).
func addTestFlags(fs *flag.FlagSet) *sayOptions {
o := &sayOptions{}
fs.StringVar(&o.lang, "lang", "", "Sprache der Testphrase: de | en (Default de)")
fs.StringVar(&o.provider, "provider", "", "Provider pinnen: elevenlabs | openai | edge | osbuiltin")
fs.StringVar(&o.voice, "voice", "", "Stimme als provider-spezifische ID")
fs.StringVar(&o.lang, "lang", "", "language of the test sentence: de | en (default: configured lang)")
fs.StringVar(&o.provider, "provider", "", "pin a provider: elevenlabs | openai | edge | osbuiltin")
fs.StringVar(&o.voice, "voice", "", "voice as a provider-specific ID")
return o
}
// addDoctorFlags registriert die Flags von doctor.
// addDoctorFlags registers the doctor flags.
func addDoctorFlags(fs *flag.FlagSet) *sayOptions {
o := &sayOptions{}
fs.BoolVar(&o.jsonOut, "json", false, "maschinenlesbare JSON-Ausgabe")
fs.BoolVar(&o.probe, "probe", false, "echte Synthese-Probe bei jedem verfügbaren Provider (Netz-Aufrufe)")
fs.BoolVar(&o.jsonOut, "json", false, "machine-readable JSON output")
fs.BoolVar(&o.probe, "probe", false, "real synthesis probe for every available provider (network calls)")
return o
}
// run ist der Einstiegspunkt der CLI (Argumente ohne Programmnamen). Alle
// Ausgaben gehen auf die übergebenen Writer, stdin ist als Reader injiziert
// — damit ist run direkt und ohne Prozessstart testbar (auch der stdin-
// Pfad aus Task 02). Rückgabe ist der Exit-Code.
// run is the CLI entry point (arguments without the program name). All
// output goes to the given writers, stdin is injected, so run is testable
// without starting a process. The return value is the exit code.
func run(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
if len(argv) == 0 {
// Kein Befehl und kein Argument: Der Text könnte über stdin
// kommen (Pipe). An einem Terminal gibt es keinen Text — dann
// Benutzungsfehler, damit talk nie auf Eingaben wartet.
// No command and no argument: the text may come via stdin (pipe).
// At a terminal there is no text, so this is a usage error; talk
// must never sit waiting for input.
if isCharDevice(stdin) {
writeUsage(stderr)
return exitUsage
@@ -177,7 +168,7 @@ func run(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
writeCommandHelp(cmd, stdout)
return exitOK
}
fmt.Fprintf(stderr, "talk: unbekannter Befehl %q\n", argv[1])
fmt.Fprintf(stderr, "talk: unknown command %q\n", argv[1])
return exitUsage
}
writeUsage(stdout)
@@ -188,30 +179,25 @@ func run(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
return runCommand(cmd, argv[1:], stdin, stdout, stderr)
}
// Kein bekannter Befehl: Default-Pfad `talk "Text"` (mappt auf say)
// bzw. Root-Flags (--version, --help).
// No known command: default form `talk "text"` (maps to say) or root
// flags (--version, --help).
return runDefault(argv, stdin, stdout, stderr)
}
// parseInterspersed parst Flags, die überall in argv stehen dürfen. Die
// Go-stdlib-flag stoppt beim ersten Nicht-Flag-Argument; die CLI-Surface
// verlangt aber `talk "Text" --provider=x` (Abnahmekriterium Task 03)
// genauso wie `talk --provider=x "Text"`. Trick: Parse über den Rest
// wiederholen und jeweils das Positionsargument vor dem Weiterparsen
// entnehmen. Fehler (unbekannte Flags, -h) kommen wie gewohnt vom FlagSet.
// parseInterspersed parses flags that may appear anywhere in argv. The
// standard flag package stops at the first non-flag argument, but the CLI
// accepts `talk "text" --provider=x` as well as `talk --provider=x "text"`.
// Trick: repeat the parse over the rest, taking one positional argument
// before each round. Errors (unknown flags, -h) come from the FlagSet.
//
// Seit Task 07 schirmt das erste „--" (als eigenständiges Argument) alles
// dahinter vollständig ab — die Stdlib-Terminator-Semantik, die der
// Interspersed-Loop zuvor aushebelte: Er nahm nach jedem Positionsargument
// das Parsen wieder auf und fraß damit Flags, die für den NACHFOLGENDEN
// Befehl gemeint waren (`talk wrap -- foo --bar` wäre an --bar als
// vermeintlichem talk-Flag gescheitert). Jetzt wird argv am ersten „--"
// getrennt: Nur der Teil davor wird interspersed geparst, der Rest ist
// bedingungslos Positionsargument — für say (`talk -- "Text --flag"`)
// genauso wie für wrap (`talk wrap -- foo --bar`). Die Abnahme-Form
// `talk "Text" --provider=x` (Flag nach dem Text, ohne „--") ist davon
// unberührt. Ein „--" als Flag-Wert (unmittelbar nach --voice) trifft
// diese Trennung nicht, weil nur ein allein stehendes „--" trennt.
// The first standalone "--" shields everything after it completely (the
// standard terminator semantics the interspersed loop used to defeat: it
// resumed parsing after every positional and ate flags meant for the
// wrapped command; `talk wrap -- foo --bar` failed on --bar). argv is split
// at the first "--": only the part before it is parsed interspersed, the
// rest is positional unconditionally, for say (`talk -- "text --flag"`) as
// well as wrap (`talk wrap -- foo --bar`). A "--" as a flag VALUE (right
// after --voice) does not split, because only a standalone "--" does.
func parseInterspersed(fs *flag.FlagSet, argv []string) ([]string, error) {
if i := slices.Index(argv, "--"); i >= 0 {
positional, err := parseInterspersedLoop(fs, argv[:i])
@@ -223,8 +209,8 @@ func parseInterspersed(fs *flag.FlagSet, argv []string) ([]string, error) {
return parseInterspersedLoop(fs, argv)
}
// parseInterspersedLoop ist der Interspersed-Durchlauf — argv enthält kein
// allein stehendes „--" mehr (parseInterspersed hat davor getrennt).
// parseInterspersedLoop is the interspersed pass; argv holds no standalone
// "--" any more (parseInterspersed split before).
func parseInterspersedLoop(fs *flag.FlagSet, argv []string) ([]string, error) {
var positional []string
rest := argv
@@ -236,7 +222,7 @@ func parseInterspersedLoop(fs *flag.FlagSet, argv []string) ([]string, error) {
if len(args) == 0 {
return positional, nil
}
// args[0] ist das Argument, an dem Parse gestoppt hat.
// args[0] is the argument Parse stopped at.
positional = append(positional, args[0])
rest = args[1:]
}
@@ -251,10 +237,9 @@ func lookupCommand(name string) (command, bool) {
return command{}, false
}
// newCommandFlagSet baut den FlagSet eines Befehls: eigene Flags plus ein
// explizites --help und die gebundenen say-Options. Die Default-Usage wird
// stummgeschaltet, damit Hilfetext ausschließlich über writeCommandHelp
// (eine Quelle) ausgegeben wird.
// newCommandFlagSet builds a command's FlagSet: its own flags plus an
// explicit --help and the bound options. The default usage is silenced so
// help text comes from writeCommandHelp only (one source).
func newCommandFlagSet(cmd command, out io.Writer) (*flag.FlagSet, *bool, *sayOptions) {
fs := flag.NewFlagSet("talk "+cmd.name, flag.ContinueOnError)
fs.SetOutput(out)
@@ -263,24 +248,22 @@ func newCommandFlagSet(cmd command, out io.Writer) (*flag.FlagSet, *bool, *sayOp
if cmd.register != nil {
sayOpts = cmd.register(fs)
}
help := fs.Bool("help", false, "Hilfe zu diesem Befehl anzeigen")
help := fs.Bool("help", false, "show help for this command")
return fs, help, sayOpts
}
// runCommand führt einen benannten Subcommand aus. Implementiert: say
// (Task 02/04) sowie mute/unmute/status/cancel als Daemon-Aufrufe (Task
// 04); die restlichen sind Stubs bis zu ihren Tasks. Flags dürfen auch
// nach dem Text stehen (parseInterspersed).
// runCommand executes a named subcommand. Flags may follow the text
// (parseInterspersed).
func runCommand(cmd command, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fs, help, sayOpts := newCommandFlagSet(cmd, stderr)
positional, err := parseInterspersed(fs, args)
if err != nil {
if errors.Is(err, flag.ErrHelp) { // z. B. nacktes -h
if errors.Is(err, flag.ErrHelp) { // for example a bare -h
writeCommandHelp(cmd, stdout)
return exitOK
}
// Die Fehlermeldung selbst hat der FlagSet bereits ausgegeben.
fmt.Fprintf(stderr, "Hinweis: `talk %s --help` zeigt die Verwendung.\n", cmd.name)
// The FlagSet has already printed the error itself.
fmt.Fprintf(stderr, "hint: `talk %s --help` shows the usage.\n", cmd.name)
return exitUsage
}
if *help {
@@ -290,17 +273,18 @@ func runCommand(cmd command, args []string, stdin io.Reader, stdout, stderr io.W
if cmd.handler != nil {
return cmd.handler(positional, sayOpts, stdin, stdout, stderr)
}
return notImplemented(cmd.name, stderr)
fmt.Fprintf(stderr, "talk %s is not implemented yet\n", cmd.name)
return exitFailure
}
// runDefault behandelt Aufrufe ohne bekannten Subcommand: `talk "Text"`,
// `talk --version`, `talk --help` sowie say-Flags vor oder nach dem Text.
// runDefault handles calls without a known subcommand: `talk "text"`,
// `talk --version`, `talk --help`, and say flags before or after the text.
func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("talk", flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() {}
showVersion := fs.Bool("version", false, "Version anzeigen")
help := fs.Bool("help", false, "diese Hilfe anzeigen")
showVersion := fs.Bool("version", false, "show the version")
help := fs.Bool("help", false, "show this help")
sayOpts := addSayFlags(fs)
positional, err := parseInterspersed(fs, argv)
@@ -309,7 +293,7 @@ func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
writeUsage(stdout)
return exitOK
}
fmt.Fprintln(stderr, "Hinweis: `talk --help` zeigt die Verwendung.")
fmt.Fprintln(stderr, "hint: `talk --help` shows the usage.")
return exitUsage
}
if *showVersion {
@@ -323,50 +307,44 @@ func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
return runSay(positional, sayOpts, stdin, stdout, stderr)
}
// versionString rendert die Versionsausgabe: `talk version <version>` plus
// Commit-Hash in Klammern, falls per ldflags injiziert.
// versionString renders the version output: `talk version <version>` plus
// the commit hash in parentheses when injected.
func versionString() string {
return "talk version " + buildVersion()
}
// notImplemented meldet einen noch nicht implementierten Befehl auf stderr
// und liefert den dafür vorgesehenen Exit-Code (non-zero).
func notImplemented(name string, stderr io.Writer) int {
fmt.Fprintf(stderr, "talk %s is not implemented yet\n", name)
return exitFailure
}
// writeUsage schreibt die Gesamt-Hilfe; die Befehlsliste wird direkt aus der
// Befehlstabelle generiert, damit Hilfe und Dispatch nicht auseinanderlaufen.
// writeUsage writes the overall help; the command list is generated from the
// command table so help and dispatch never drift apart.
func writeUsage(w io.Writer) {
fmt.Fprint(w, `talk — Sprach-Ausgaben für Skripte und KI-Agenten
fmt.Fprint(w, `talk: voice notifications for scripts and AI agents
Usage:
talk [Flags] "Text" Text ansagen (Abkürzung für: talk say)
talk <Befehl> [Flags] Befehl ausführen
talk help [Befehl] Hilfe anzeigen
talk [flags] "text" speak a text (shorthand for: talk say)
talk <command> [flags] run a command
talk help [command] show help
Befehle:
Commands:
`)
for _, cmd := range commands {
fmt.Fprintf(w, " %-8s %s\n", cmd.name, cmd.short)
}
fmt.Fprint(w, `
Flags:
--version Version anzeigen
--help diese Hilfe anzeigen
--version show the version
--help show this help
Die Flags von say gelten auch für talk "Text"; Details: talk say --help
The say flags also apply to talk "text"; details: talk say --help
Getting started: talk doctor, then talk test, then talk setup.
`)
}
// writeCommandHelp schreibt die Hilfe eines einzelnen Befehls inkl. Flags.
// writeCommandHelp writes the help of a single command including its flags.
func writeCommandHelp(cmd command, w io.Writer) {
fmt.Fprintf(w, "Usage:\n talk %s", cmd.name)
if cmd.args != "" {
fmt.Fprintf(w, " %s", cmd.args)
}
fmt.Fprintln(w, " [Flags]")
fmt.Fprintln(w, " [flags]")
fmt.Fprintln(w)
if cmd.detail != "" {
fmt.Fprintf(w, "%s\n\n", cmd.detail)
@@ -374,7 +352,4 @@ func writeCommandHelp(cmd command, w io.Writer) {
fmt.Fprintln(w, "Flags:")
fs, _, _ := newCommandFlagSet(cmd, w)
fs.PrintDefaults()
if cmd.handler == nil {
fmt.Fprintln(w, "\nStatus: noch nicht implementiert (WIP).")
}
}
+28 -35
View File
@@ -7,24 +7,24 @@ import (
"testing"
)
// wantSubcommands ist die bindende CLI-Surface aus der Spec — bewusst als
// hartkodierte Liste getestet (nicht aus der Befehlstabelle abgeleitet),
// damit die Tests anschlagen, wenn Tabelle und Spec auseinanderlaufen.
// wantSubcommands is the CLI surface, deliberately tested as a hard-coded
// list (not derived from the command table) so the tests fire when table and
// documentation drift apart.
var wantSubcommands = []string{
"say", "wrap", "mute", "unmute", "status", "cancel", "voices", "test", "doctor", "setup",
"say", "wrap", "mute", "unmute", "status", "daemon", "cancel", "voices", "test", "doctor", "setup",
}
// runCLI ist der Test-Einstieg in run mit leerem stdin (kein Text über
// Pipe) — dem üblichen Aufruf ohne Eingabe.
// runCLI is the test entry into run with empty stdin (no text via pipe), the
// usual call without input.
func runCLI(argv []string, stdout, stderr io.Writer) int {
return run(argv, strings.NewReader(""), stdout, stderr)
}
func TestHelpListetAlleSubcommands(t *testing.T) {
func TestHelpListsAllSubcommands(t *testing.T) {
var out, errBuf bytes.Buffer
code := runCLI([]string{"--help"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("--help: Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
t.Fatalf("--help: exit code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
}
lines := strings.Split(out.String(), "\n")
for _, name := range wantSubcommands {
@@ -36,7 +36,7 @@ func TestHelpListetAlleSubcommands(t *testing.T) {
}
}
if !found {
t.Errorf("--help listet Befehl %q nicht (Output: %q)", name, out.String())
t.Errorf("--help does not list command %q (output: %q)", name, out.String())
}
}
}
@@ -45,14 +45,14 @@ func TestVersionDefault(t *testing.T) {
var out, errBuf bytes.Buffer
code := runCLI([]string{"--version"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("--version: Exit-Code = %d, want %d", code, exitOK)
t.Fatalf("--version: exit code = %d, want %d", code, exitOK)
}
if got, want := out.String(), "talk version dev\n"; got != want {
t.Errorf("--version Output = %q, want %q", got, want)
t.Errorf("--version output = %q, want %q", got, want)
}
}
func TestVersionStringMitCommit(t *testing.T) {
func TestVersionStringWithCommit(t *testing.T) {
oldCommit := commit
commit = "c0ffee1"
defer func() { commit = oldCommit }()
@@ -62,70 +62,63 @@ func TestVersionStringMitCommit(t *testing.T) {
}
}
func TestSayHelpZeigtFlags(t *testing.T) {
func TestSayHelpShowsFlags(t *testing.T) {
var out, errBuf bytes.Buffer
code := runCLI([]string{"say", "--help"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk say --help: Exit-Code = %d, want %d", code, exitOK)
t.Fatalf("talk say --help: exit code = %d, want %d", code, exitOK)
}
for _, f := range []string{"-wait", "-now", "-priority", "-voice", "-lang", "-provider"} {
if !strings.Contains(out.String(), f) {
t.Errorf("talk say --help zeigt Flag %s nicht (Output: %q)", f, out.String())
t.Errorf("talk say --help does not show flag %s (output: %q)", f, out.String())
}
}
if strings.Contains(out.String(), "noch nicht implementiert") {
t.Errorf("talk say --help soll say nicht mehr als Stub markieren: %q", out.String())
}
}
// TestWrapHelpZeigtSayFlags: wrap erbt die say-Flags (mindestens --wait)
// und ist kein Stub mehr.
func TestWrapHelpZeigtSayFlags(t *testing.T) {
// TestWrapHelpShowsSayFlags: wrap inherits the say flags (at least --wait).
func TestWrapHelpShowsSayFlags(t *testing.T) {
var out, errBuf bytes.Buffer
code := runCLI([]string{"wrap", "--help"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk wrap --help: Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
t.Fatalf("talk wrap --help: exit code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
}
for _, f := range []string{"-wait", "-provider"} {
if !strings.Contains(out.String(), f) {
t.Errorf("talk wrap --help zeigt Flag %s nicht (Output: %q)", f, out.String())
t.Errorf("talk wrap --help does not show flag %s (output: %q)", f, out.String())
}
}
if strings.Contains(out.String(), "noch nicht implementiert") {
t.Errorf("talk wrap --help soll wrap nicht als Stub markieren: %q", out.String())
}
}
func TestHelpSubcommand(t *testing.T) {
t.Run("ohne Argument", func(t *testing.T) {
t.Run("without argument", func(t *testing.T) {
var out, errBuf bytes.Buffer
if code := runCLI([]string{"help"}, &out, &errBuf); code != exitOK {
t.Fatalf("talk help: Exit-Code = %d, want %d", code, exitOK)
t.Fatalf("talk help: exit code = %d, want %d", code, exitOK)
}
for _, name := range wantSubcommands {
if !strings.Contains(out.String(), name) {
t.Errorf("talk help listet %q nicht", name)
t.Errorf("talk help does not list %q", name)
}
}
})
t.Run("mit Befehl", func(t *testing.T) {
t.Run("with command", func(t *testing.T) {
var out, errBuf bytes.Buffer
if code := runCLI([]string{"help", "mute"}, &out, &errBuf); code != exitOK {
t.Fatalf("talk help mute: Exit-Code = %d, want %d", code, exitOK)
t.Fatalf("talk help mute: exit code = %d, want %d", code, exitOK)
}
if !strings.Contains(out.String(), "talk mute") {
t.Errorf("talk help mute zeigt Usage von mute nicht: %q", out.String())
t.Errorf("talk help mute does not show mute's usage: %q", out.String())
}
})
}
func TestUnbekanntesFlag(t *testing.T) {
func TestUnknownFlag(t *testing.T) {
var out, errBuf bytes.Buffer
code := runCLI([]string{"--bogus"}, &out, &errBuf)
if code != exitUsage {
t.Fatalf("talk --bogus: Exit-Code = %d, want %d", code, exitUsage)
t.Fatalf("talk --bogus: exit code = %d, want %d", code, exitUsage)
}
if !strings.Contains(errBuf.String(), "bogus") {
t.Errorf("talk --bogus: Fehlermeldung fehlt auf stderr: %q", errBuf.String())
t.Errorf("talk --bogus: error message missing on stderr: %q", errBuf.String())
}
}
+51 -53
View File
@@ -12,26 +12,26 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/queue"
)
// Die Steuer-Befehle (Task 04) sind Daemon-Aufrufe mit plain Output:
// stdout führt eine maschinenlesbare Zeile (muted/unmuted, cancelled: N
// bzw. den Status-Block), stderr menschliche Hinweise.
// The control commands are daemon calls with plain output: stdout carries a
// machine-readable line (muted/unmuted, cancelled: N, or the status block),
// stderr human notes. They deliberately do NOT start the daemon (only
// speaking does): muting needs no running process, and status should show
// reality rather than change it.
// runMute implementiert `talk mute`: POST /mute an den Daemon. Ist kein
// Daemon erreichbar, wird die Mute-Datei direkt geschrieben — dieselbe
// Persistenz-Quelle, die der Daemon bei jeder Ansage neu liest; der
// Zustand greift also auch offline und überlebt Daemon-Restarts. Die
// Steuer-Befehle starten den Daemon bewusst NICHT (nur Sprechen tut das):
// Stummschalten braucht keinen laufenden Prozess.
// runMute implements `talk mute`: POST /mute to the daemon. When no daemon
// is reachable, the mute file is written directly; it is the same
// persistence the daemon re-reads on every announcement, so the state takes
// effect offline too and survives daemon restarts.
func runMute(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
return setMute(true, stdout, stderr)
}
// runUnmute implementiert `talk unmute` (analog zu runMute).
// runUnmute implements `talk unmute` (like runMute).
func runUnmute(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
return setMute(false, stdout, stderr)
}
// setMute ist der gemeinsame Kern von mute/unmute.
// setMute is the shared core of mute/unmute.
func setMute(muted bool, stdout, stderr io.Writer) int {
cfg, err := config.Load(config.Options{Getenv: getenv})
if err != nil {
@@ -46,24 +46,23 @@ func setMute(muted bool, stdout, stderr io.Writer) int {
return exitOK
}
// Daemon-Aufruf fehlgeschlagen (meist: nicht erreichbar): Datei direkt
// schreiben — der Daemon (und der Degraded Mode) liest sie bei jeder
// Ansage neu.
fmt.Fprintf(stderr, "talk: Daemon-Aufruf fehlgeschlagen (%v) — Mute-Datei wird direkt geschrieben\n", err)
// Daemon call failed (usually: not running): write the file directly;
// the daemon (and the direct path) re-read it on every announcement.
fmt.Fprintf(stderr, "talk: daemon call failed (%v); writing the mute file directly\n", err)
dir, err := config.Dir(getenv)
if err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
if err := (queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}).Set(muted); err != nil {
fmt.Fprintf(stderr, "talk: Mute-Datei schreiben: %v\n", err)
fmt.Fprintf(stderr, "talk: writing the mute file: %v\n", err)
return exitFailure
}
printMuteState(muted, stdout)
return exitOK
}
// printMuteState schreibt den Zustand als plain Token auf stdout.
// printMuteState writes the state as a plain token to stdout.
func printMuteState(muted bool, stdout io.Writer) {
if muted {
fmt.Fprintln(stdout, "muted")
@@ -72,11 +71,10 @@ func printMuteState(muted bool, stdout io.Writer) {
}
}
// runStatus implementiert `talk status`: plain Block aus GET /status —
// Daemon-Adresse + Version, Queue-Länge, aktuelles Item, Mute-State,
// aktiver Provider, Gesetz-State (Rate-Limit, Ruhezeit). Läuft kein Daemon,
// sagt status genau das (Exit 1) und startet ihn nicht — status soll die
// Wirklichkeit zeigen, nicht verändern.
// runStatus implements `talk status`: a plain block from GET /status (daemon
// address + version, queue length, current item, mute state, active
// provider, laws). When no daemon runs, status says exactly that (exit 1)
// without starting one.
func runStatus(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
cfg, err := config.Load(config.Options{Getenv: getenv})
if err != nil {
@@ -88,44 +86,44 @@ func runStatus(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr
defer cancel()
st, err := client.Status(ctx)
if err != nil {
fmt.Fprintf(stdout, "daemon: läuft nicht (%s)\n", client.BaseURL)
fmt.Fprintln(stderr, "talk: kein Daemon — er startet beim nächsten Sprechbefehl von selbst (oder: talk daemon start)")
fmt.Fprintf(stdout, "daemon: not running (%s)\n", client.BaseURL)
fmt.Fprintln(stderr, "talk: no daemon; it starts by itself on the next speak command (or: talk daemon start)")
return exitFailure
}
fmt.Fprintf(stdout, "daemon: %s (version %s)\n", client.BaseURL, st.Version)
fmt.Fprintf(stdout, "queue: %d\n", st.Queue)
fmt.Fprintf(stdout, "daemon: %s (version %s)\n", client.BaseURL, st.Version)
fmt.Fprintf(stdout, "queue: %d\n", st.Queue)
if st.Current != nil {
fmt.Fprintf(stdout, "current: %s %q (Priorität %s)\n", st.Current.ID, st.Current.Text, st.Current.Priority)
fmt.Fprintf(stdout, "current: %s %q (priority %s)\n", st.Current.ID, st.Current.Text, st.Current.Priority)
} else {
fmt.Fprintln(stdout, "current: –")
fmt.Fprintln(stdout, "current: -")
}
muteState := "aus"
muteState := "off"
if st.Muted {
muteState = "an"
muteState = "on"
}
fmt.Fprintf(stdout, "mute: %s\n", muteState)
fmt.Fprintf(stdout, "provider: %s\n", st.Provider)
fmt.Fprintf(stdout, "mute: %s\n", muteState)
fmt.Fprintf(stdout, "provider: %s\n", st.Provider)
printLawState(stdout, st)
return exitOK
}
// printLawState schreibt den Gesetz-State (Task 05) aus GET /status:
// Rate-Limit mit Burst-Recht und nächstem Slot sowie das Ruhezeit-Fenster.
// printLawState writes the law state from GET /status: rate limit with
// burst rights and next slot, quiet window, length and age limits.
func printLawState(stdout io.Writer, st *api.StatusResponse) {
if st.RateLimit != nil {
interval := st.RateLimit.Interval
if interval == "0s" {
interval = "aus (unbegrenzt)"
interval = "off (unlimited)"
}
burst := "verboten"
burst := "not allowed"
if st.RateLimit.AllowHighBurst {
burst = "erlaubt"
burst = "allowed"
}
line := fmt.Sprintf("rate-limit: %s (high-burst %s)", interval, burst)
line := fmt.Sprintf("rate-limit: %s (high burst %s)", interval, burst)
if st.RateLimit.NextSlot != nil {
if ts, err := time.Parse(time.RFC3339, *st.RateLimit.NextSlot); err == nil {
line += fmt.Sprintf(", nächster slot %s", ts.Format("15:04:05"))
line += fmt.Sprintf(", next slot %s", ts.Format("15:04:05"))
}
}
fmt.Fprintln(stdout, line)
@@ -133,35 +131,35 @@ func printLawState(stdout io.Writer, st *api.StatusResponse) {
if st.QuietHours != nil {
window := st.QuietHours.Window
if window == "" {
window = "aus"
window = "off"
}
state := "inaktiv"
state := "inactive"
if st.QuietHours.Active {
state = "aktiv"
state = "active"
if st.QuietHours.Until != nil {
if ts, err := time.Parse(time.RFC3339, *st.QuietHours.Until); err == nil {
state += " bis " + ts.Format("15:04")
state += " until " + ts.Format("15:04")
}
}
}
fmt.Fprintf(stdout, "ruhezeit: %s (modus %s, %s)\n", window, st.QuietHours.Mode, state)
fmt.Fprintf(stdout, "quiet-hours: %s (mode %s, %s)\n", window, st.QuietHours.Mode, state)
}
if st.Limits != nil {
text := fmt.Sprintf("max. %d Zeichen", st.Limits.MaxText)
text := fmt.Sprintf("max %d characters", st.Limits.MaxText)
if st.Limits.MaxText == 0 {
text = "Länge unbegrenzt"
text = "unlimited length"
}
age := "max. Wartezeit " + st.Limits.MaxAge
age := "max wait " + st.Limits.MaxAge
if st.Limits.MaxAge == "0s" {
age = "Wartezeit unbegrenzt"
age = "unlimited wait"
}
fmt.Fprintf(stdout, "limits: %s, %s\n", text, age)
fmt.Fprintf(stdout, "limits: %s, %s\n", text, age)
}
}
// runCancel implementiert `talk cancel`: POST /cancel — bricht die laufende
// Ansage ab und leert die Queue; stdout meldet die Zahl der abgebrochenen
// Ansagen. Ohne Daemon gibt es nichts, was talk abbrechen könnte.
// runCancel implements `talk cancel`: POST /cancel aborts the playing
// announcement and empties the queue; stdout reports the number of cancelled
// announcements. Without a daemon there is nothing to cancel.
func runCancel(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
cfg, err := config.Load(config.Options{Getenv: getenv})
if err != nil {
@@ -173,7 +171,7 @@ func runCancel(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr
defer cancel()
n, err := client.Cancel(ctx)
if err != nil {
fmt.Fprintf(stderr, "talk: Daemon nicht erreichbar unter %s — nichts zu abbrechen\n", client.BaseURL)
fmt.Fprintf(stderr, "talk: no daemon at %s; nothing to cancel\n", client.BaseURL)
return exitFailure
}
fmt.Fprintf(stdout, "cancelled: %d\n", n)
+44 -45
View File
@@ -10,111 +10,110 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/queue"
)
// Diese Tests decken die Steuer-Befehle ohne Daemon ab (in-process über
// die run-Seam; TALK_PORT=1 aus der setGetenv-Grundausstattung → Daemon-
// Erkennung schlägt sofort fehl). Das Verhalten MIT Daemon testen die
// Prozessgrenzen-Tests in /test.
// These tests cover the control commands without a daemon (in-process via
// the run seam; TALK_PORT=1 from the setGetenv base makes daemon detection
// fail immediately). Behaviour WITH a daemon is covered by the process
// boundary tests in /test.
// TestStatusOhneDaemon: `talk status` ohne Daemon ist ein klarer Fehler
// (Exit 1) mit Hinweis auf stderr — und stdout nennt den Zustand trotzdem
// als Status-Zeile, damit der Block nie leer bleibt. Status startet den
// Daemon nicht.
func TestStatusOhneDaemon(t *testing.T) {
// TestStatusWithoutDaemon: `talk status` without a daemon is a clear error
// (exit 1) with a note on stderr, and stdout still names the state as a
// status line so the block is never empty. status does not start the
// daemon.
func TestStatusWithoutDaemon(t *testing.T) {
setGetenv(t, nil)
var out, errBuf bytes.Buffer
code := runCLI([]string{"status"}, &out, &errBuf)
if code != exitFailure {
t.Fatalf("talk status ohne Daemon: Exit-Code = %d, want %d", code, exitFailure)
t.Fatalf("talk status without daemon: exit code = %d, want %d", code, exitFailure)
}
if !strings.Contains(errBuf.String(), "kein Daemon") {
t.Errorf("stderr = %q, will Hinweis auf fehlenden Daemon", errBuf.String())
if !strings.Contains(errBuf.String(), "no daemon") {
t.Errorf("stderr = %q, want a note about the missing daemon", errBuf.String())
}
if !strings.HasPrefix(out.String(), "daemon: läuft nicht") {
t.Errorf("stdout = %q, will Status-Zeile „läuft nicht“", out.String())
if !strings.HasPrefix(out.String(), "daemon: not running") {
t.Errorf("stdout = %q, want the status line \"not running\"", out.String())
}
}
// TestCancelOhneDaemon: ohne Daemon gibt es nichts zu abbrechen — klarer
// Fehler.
func TestCancelOhneDaemon(t *testing.T) {
// TestCancelWithoutDaemon: without a daemon there is nothing to cancel; a
// clear error.
func TestCancelWithoutDaemon(t *testing.T) {
setGetenv(t, nil)
var out, errBuf bytes.Buffer
code := runCLI([]string{"cancel"}, &out, &errBuf)
if code != exitFailure {
t.Fatalf("talk cancel ohne Daemon: Exit-Code = %d, want %d", code, exitFailure)
t.Fatalf("talk cancel without daemon: exit code = %d, want %d", code, exitFailure)
}
if !strings.Contains(errBuf.String(), "nichts zu abbrechen") {
t.Errorf("stderr = %q, will Hinweis", errBuf.String())
if !strings.Contains(errBuf.String(), "nothing to cancel") {
t.Errorf("stderr = %q, want a note", errBuf.String())
}
}
// TestMuteOhneDaemonSchreibtDatei: `talk mute` ohne Daemon schreibt die
// Mute-Datei direkt — und genau diese Datei respektiert der Degraded Mode
// von say anschließend.
func TestMuteOhneDaemonSchreibtDatei(t *testing.T) {
// TestMuteWithoutDaemonWritesFile: `talk mute` without a daemon writes the
// mute file directly, and the direct path of say honours exactly that file.
func TestMuteWithoutDaemonWritesFile(t *testing.T) {
root := t.TempDir()
setGetenv(t, map[string]string{"XDG_CONFIG_HOME": root})
mutePath := filepath.Join(root, "talk", queue.MuteFileName)
var out, errBuf bytes.Buffer
if code := runCLI([]string{"mute"}, &out, &errBuf); code != exitOK {
t.Fatalf("talk mute ohne Daemon: Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
t.Fatalf("talk mute without daemon: exit code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
}
if got := out.String(); got != "muted\n" {
t.Errorf("talk mute stdout = %q, want %q", got, "muted\n")
}
if !strings.Contains(errBuf.String(), "direkt") {
t.Errorf("stderr = %q, will Hinweis auf direkten Datei-Schreib", errBuf.String())
if !strings.Contains(errBuf.String(), "directly") {
t.Errorf("stderr = %q, want a note about writing the file directly", errBuf.String())
}
b, err := os.ReadFile(mutePath)
if err != nil {
t.Fatalf("Mute-Datei fehlt: %v", err)
t.Fatalf("mute file missing: %v", err)
}
if strings.TrimSpace(string(b)) != "true" {
t.Errorf("Mute-Datei = %q, want true", b)
t.Errorf("mute file = %q, want true", b)
}
// say im Degraded Mode respektiert dieselbe Datei (tonlos verworfen,
// ohne irgendeinen Provider zu brauchen).
// say on the direct path honours the same file (discarded silently,
// without needing any provider).
out.Reset()
errBuf.Reset()
if code := runCLI([]string{"say", "stille bitte"}, &out, &errBuf); code != exitOK {
t.Fatalf("talk say (mute an): Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
if code := runCLI([]string{"say", "quiet please"}, &out, &errBuf); code != exitOK {
t.Fatalf("talk say (muted): exit code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
}
if got := out.String(); got != "muted\n" {
t.Errorf("talk say (mute an) stdout = %q, want %q", got, "muted\n")
t.Errorf("talk say (muted) stdout = %q, want %q", got, "muted\n")
}
// unmute hebt es auf (Datei false).
// unmute lifts it (file false).
out.Reset()
errBuf.Reset()
if code := runCLI([]string{"unmute"}, &out, &errBuf); code != exitOK {
t.Fatalf("talk unmute: Exit-Code = %d (stderr: %q)", code, errBuf.String())
t.Fatalf("talk unmute: exit code = %d (stderr: %q)", code, errBuf.String())
}
if got := out.String(); got != "unmuted\n" {
t.Errorf("talk unmute stdout = %q, want %q", got, "unmuted\n")
}
b, err = os.ReadFile(mutePath)
if err != nil || strings.TrimSpace(string(b)) != "false" {
t.Errorf("Mute-Datei nach unmute = %q (%v), want false", b, err)
t.Errorf("mute file after unmute = %q (%v), want false", b, err)
}
}
// TestSayOhneConfigDir: Degraded Mode ohne Config-Dir (kein HOME) spielt
// weiter statt zu scheitern — Best-effort ohne Lock und Mute.
func TestSayOhneConfigDir(t *testing.T) {
// TestSayWithoutConfigDir: the direct path without a config directory (no
// HOME) keeps speaking instead of failing; best effort without mute.
func TestSayWithoutConfigDir(t *testing.T) {
seen := withFakeEspeak(t)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
code := runCLI([]string{"Hallo"}, &out, &errBuf)
code := runCLI([]string{"Hello"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk ohne HOME: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
t.Fatalf("talk without HOME: exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
}
if _, err := os.Stat(seen); err != nil {
t.Errorf("fake espeak nicht gerufen: %v", err)
t.Errorf("fake espeak not called: %v", err)
}
if !strings.Contains(errBuf.String(), "spreche direkt") {
t.Errorf("stderr = %q, will Degraded-Hinweis", errBuf.String())
if !strings.Contains(errBuf.String(), "speaking directly") {
t.Errorf("stderr = %q, want the direct-path note", errBuf.String())
}
}
+57 -59
View File
@@ -15,21 +15,21 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/queue"
)
// Zeit-Budgets der Daemon-Kommunikation. Die Daemon-Erkennung (GET /status)
// hält kurz: Auf einem freien Port wird die Verbindung sofort abgelehnt;
// das Budget deckt nur unglückliche Fälle (gefilterte Ports), damit talk
// ohne Daemon nicht spürbar trödelt.
// Time budgets for talking to the daemon. Detection (GET /status) is short:
// on a free port the connection is refused immediately; the budget only
// covers unlucky cases (filtered ports) so talk never dawdles without a
// daemon.
const (
daemonProbeTimeout = time.Second // Daemon-Erkennung (GET /status)
daemonCallTimeout = 5 * time.Second // einzelne Daemon-Aufrufe
daemonStartTimeout = 5 * time.Second // Autostart: so lange auf /status warten
daemonStartPoll = 50 * time.Millisecond // Autostart-Poll-Frequenz
waitPollInterval = 100 * time.Millisecond // --wait-Poll-Frequenz
daemonProbeTimeout = time.Second // daemon detection (GET /status)
daemonCallTimeout = 5 * time.Second // individual daemon calls
daemonStartTimeout = 5 * time.Second // autostart: how long to wait for /status
daemonStartPoll = 50 * time.Millisecond // autostart poll interval
waitPollInterval = 100 * time.Millisecond // --wait poll interval
)
// daemonAlive meldet, ob unter dem Client ein Daemon antwortet (GET /status
// mit 200). Jeder Fehler — Verbindung abgelehnt, Timeout, fremder Dienst
// mit anderem Status — zählt als „kein Daemon".
// daemonAlive reports whether a daemon answers under the client (GET
// /status with 200). Every error (connection refused, timeout, a foreign
// service with another status) counts as "no daemon".
func daemonAlive(client *api.Client, timeout time.Duration) bool {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
@@ -37,11 +37,11 @@ func daemonAlive(client *api.Client, timeout time.Duration) bool {
return err == nil
}
// ensureDaemon liefert einen Client zu einem laufenden Daemon und startet
// ihn bei Bedarf selbst (daemon.autostart, Default an). ok=false heißt:
// kein Daemon erreichbar und keiner startbar (Autostart aus, Start
// gescheitert) — der Aufrufer spricht dann direkt. Gründe stehen auf
// stderr; der Daemon selbst protokolliert in daemon.log im Config-Dir.
// ensureDaemon returns a client to a running daemon, starting one if needed
// (daemon.autostart, default on). ok=false means: no daemon reachable and
// none startable (autostart off, start failed); the caller then speaks
// directly. Reasons go to stderr; the daemon itself logs to daemon.log in
// the config directory.
func ensureDaemon(cfg config.Config, stderr io.Writer) (client *api.Client, ok bool) {
client = api.NewClient(cfg.Server.Port)
if daemonAlive(client, daemonProbeTimeout) {
@@ -53,14 +53,14 @@ func ensureDaemon(cfg config.Config, stderr io.Writer) (client *api.Client, ok b
return client, startDaemon(client, stderr)
}
// startDaemon startet den Daemon im Hintergrund (dasselbe Binary mit
// `daemon run`) und wartet, bis er auf /status antwortet. Endet das Kind
// vorher (typisch: Port belegt, weil ein paralleler talk-Aufruf schneller
// war), wird noch einmal geprüft — der Daemon des anderen zählt genauso.
// startDaemon starts the daemon in the background (the same binary with
// `daemon run`) and waits until it answers on /status. If the child exits
// first (typically: port taken because a parallel talk call was faster), we
// probe once more: the other call's daemon counts just as well.
func startDaemon(client *api.Client, stderr io.Writer) bool {
exe, err := os.Executable()
if err != nil {
fmt.Fprintf(stderr, "talk: eigenes Binary nicht bestimmbar — Daemon-Start ausgesetzt (%v)\n", err)
fmt.Fprintf(stderr, "talk: cannot determine own binary; daemon start skipped (%v)\n", err)
return false
}
logPath := ""
@@ -77,50 +77,49 @@ func startDaemon(client *api.Client, stderr io.Writer) bool {
}
logHint := ""
if logPath != "" {
logHint = " (Log: " + logPath + ")"
logHint = " (log: " + logPath + ")"
}
deadline := time.Now().Add(daemonStartTimeout)
for {
if daemonAlive(client, daemonStartPoll*4) {
fmt.Fprintf(stderr, "talk: Daemon gestartet unter %s%s\n", client.BaseURL, logHint)
fmt.Fprintf(stderr, "talk: daemon started at %s%s\n", client.BaseURL, logHint)
return true
}
select {
case err := <-exited:
if daemonAlive(client, daemonProbeTimeout) {
return true // ein anderer Aufruf hat den Daemon gestartet
return true // another call started the daemon
}
fmt.Fprintf(stderr, "talk: Daemon-Start fehlgeschlagen (%v)%s\n", exitReason(err), logHint)
fmt.Fprintf(stderr, "talk: daemon start failed (%s)%s\n", exitReason(err), logHint)
return false
case <-time.After(daemonStartPoll):
}
if time.Now().After(deadline) {
fmt.Fprintf(stderr, "talk: Daemon antwortet nicht binnen %s%s\n", daemonStartTimeout, logHint)
fmt.Fprintf(stderr, "talk: daemon not answering within %s%s\n", daemonStartTimeout, logHint)
return false
}
}
}
// exitReason macht das Wait-Ergebnis eines beendeten Kindes lesbar.
// exitReason makes the Wait result of an exited child readable.
func exitReason(err error) string {
if err == nil {
return "Prozess hat sich sofort beendet"
return "process exited immediately"
}
return err.Error()
}
// sayViaDaemon reicht eine Ansage beim Daemon ein (POST /speak). Default:
// sofort zurückkehren (non-blocking), info erhält die Item-ID; --wait pollt
// /status, bis die Ansage weder läuft noch wartet. Bei aktivem Mute
// verwirft der Daemon — info sagt „muted"; Gesetz-Verwürfe nennen den
// Grund. info ist der Writer für diese Informations-Token: say reicht
// stdout, wrap stderr durch (stdout eines gewrappten Befehls darf nicht um
// talk-Zeilen verunreinigt werden).
// sayViaDaemon submits an announcement to the daemon (POST /speak). Default:
// return at once (non-blocking), info receives the item ID; --wait polls
// /status until the announcement is neither playing nor waiting. When muted
// the daemon discards and info says "muted"; law discards name the reason.
// info is the writer for these information tokens: say passes stdout, wrap
// stderr (a wrapped command's stdout must not be polluted by talk lines).
//
// handled=false heißt: Netz-Fehler (Daemon zwischen Erkennung und Aufruf
// gestorben) → der Aufrufer spricht direkt. Eine Daemon-Antwort mit
// Fehlerstatus (z. B. 400) wird dagegen angezeigt — die Ansage war beim
// Daemon angekommen und wurde bewusst abgelehnt.
// handled=false means: network error (daemon died between detection and
// call), so the caller speaks directly. A daemon response with an error
// status (for example 400) is reported instead: the announcement reached
// the daemon and was rejected deliberately.
func sayViaDaemon(client *api.Client, text string, opts *sayOptions, info, stderr io.Writer) (code int, handled bool) {
ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
defer cancel()
@@ -135,20 +134,20 @@ func sayViaDaemon(client *api.Client, text string, opts *sayOptions, info, stder
if err != nil {
var httpErr *api.HTTPError
if errors.As(err, &httpErr) {
fmt.Fprintf(stderr, "talk: Daemon hat die Ansage abgelehnt: %v\n", err)
fmt.Fprintf(stderr, "talk: the daemon rejected the announcement: %v\n", err)
return exitFailure, true
}
return 0, false
}
if resp.Muted {
fmt.Fprintln(info, "muted")
fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen")
fmt.Fprintln(stderr, "talk: muted; announcement discarded")
return exitOK, true
}
if resp.Discarded != "" {
// Gesetz-Verwurf (Rate-Limit-Budget / Ruhezeit): dokumentiertes
// Verhalten wie bei Mute — plain Token statt Fehler; --wait hat
// nichts zu warten, es gibt keine Item-ID.
// Law discard (rate limit budget / quiet hours): documented behaviour
// like mute, a plain token instead of an error; --wait has nothing to
// wait for, there is no item ID.
line := "discarded: " + resp.Discarded
if resp.Toast {
line += " (toast)"
@@ -159,7 +158,7 @@ func sayViaDaemon(client *api.Client, text string, opts *sayOptions, info, stder
}
fmt.Fprintln(info, resp.ID)
if resp.Truncated {
fmt.Fprintln(stderr, "talk: Text war zu lang und wurde gekürzt (queue.max_text) — Ansagen sind 1–2 Sätze")
fmt.Fprintln(stderr, "talk: text was too long and has been truncated (queue.max_text); announcements are 1 to 2 sentences")
}
if !opts.wait {
return exitOK, true
@@ -167,36 +166,35 @@ func sayViaDaemon(client *api.Client, text string, opts *sayOptions, info, stder
return waitForItem(client, resp.ID, stderr), true
}
// discardHint übersetzt den Verwurfs-Grund der Daemon-Antwort in einen
// menschlichen Hinweis.
// discardHint translates the daemon's discard reason into a human note.
func discardHint(reason string, toast bool) string {
switch reason {
case queue.DiscardQuietHours:
if toast {
return "Ruhezeit aktiv — Ansage durch Toast ersetzt (kein Audio)"
return "quiet hours active; announcement replaced by a toast (no audio)"
}
return "Ruhezeit aktiv — Ansage verworfen (quiet_mode = discard; now bricht bewusst aus)"
return "quiet hours active; announcement discarded (quiet_mode = discard; --now deliberately breaks out)"
case queue.DiscardRateLimit:
return "Rate-Limit — Ansage verworfen (Priorität low über dem Fenster-Budget)"
return "rate limit; announcement discarded (priority low over the window budget)"
case queue.DiscardExpired:
return "Ansage verworfen — sie hat länger gewartet als queue.max_age erlaubt (veraltet)"
return "announcement discarded; it waited longer than queue.max_age allows (stale)"
default:
return "Ansage verworfen (" + reason + ")"
return "announcement discarded (" + reason + ")"
}
}
// waitForItem pollt /status, bis die Ansage id weder läuft noch wartet —
// sie ist dann fertig (oder abgebrochen; in beiden Fällen hat der Aufrufer
// nichts mehr zu warten). Stirbt der Daemon während des Wartens, ist das
// ein Fehler: Der Zustand der Ansage ist unbekannt, und still warten wäre
// das Gegenteil von --wait.
// waitForItem polls /status until the announcement id is neither playing
// nor waiting; it is then done (or cancelled; either way there is nothing
// left to wait for). If the daemon dies while waiting, that is an error:
// the announcement's state is unknown, and waiting silently would be the
// opposite of --wait.
func waitForItem(client *api.Client, id string, stderr io.Writer) int {
for {
ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
st, err := client.Status(ctx)
cancel()
if err != nil {
fmt.Fprintf(stderr, "talk: Daemon beim Warten nicht mehr erreichbar (%v)\n", err)
fmt.Fprintf(stderr, "talk: daemon no longer reachable while waiting (%v)\n", err)
return exitFailure
}
if !st.PendingOrCurrent(id) {
+20 -21
View File
@@ -14,27 +14,26 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/daemon"
)
// daemonStopTimeout ist die Frist, in der ein per /shutdown angestoßener
// Daemon verschwunden sein muss.
// daemonStopTimeout is how long a daemon told to shut down via /shutdown may
// take to disappear.
const daemonStopTimeout = 5 * time.Second
// runDaemon implementiert `talk daemon [run|start|stop]`:
// runDaemon implements `talk daemon [run|start|stop]`:
//
// - run (Default): Daemon im Vordergrund bis SIGINT/SIGTERM — der Modus
// für systemd/launchd/Task Scheduler oder ein Terminal. Protokoll auf
// stderr.
// - start: Daemon im Hintergrund starten (dasselbe wie der Autostart von
// say, nur explizit und unabhängig von daemon.autostart) und melden,
// wo er läuft. Läuft schon einer: kein Fehler.
// - stop: laufenden Daemon per POST /shutdown herunterfahren und warten,
// bis er weg ist. Läuft keiner: Hinweis, Exit 0 (idempotent).
// - run (default): the daemon in the foreground until SIGINT/SIGTERM; the
// mode for systemd/launchd/Task Scheduler or a terminal. Log on stderr.
// - start: start the daemon in the background (the same as say's
// autostart, only explicit and independent of daemon.autostart) and
// report where it runs. Already running: no error.
// - stop: shut a running daemon down via POST /shutdown and wait until it
// is gone. None running: a note, exit 0 (idempotent).
func runDaemon(args []string, _ *sayOptions, _ io.Reader, stdout, stderr io.Writer) int {
sub := "run"
if len(args) > 0 {
sub = args[0]
}
if len(args) > 1 {
fmt.Fprintln(stderr, "talk daemon: zu viele Argumente — Verwendung: talk daemon [run|start|stop]")
fmt.Fprintln(stderr, "talk daemon: too many arguments; usage: talk daemon [run|start|stop]")
return exitUsage
}
cfg, err := config.Load(config.Options{Getenv: getenv})
@@ -50,7 +49,7 @@ func runDaemon(args []string, _ *sayOptions, _ io.Reader, stdout, stderr io.Writ
case "stop":
return daemonStop(cfg, stdout, stderr)
default:
fmt.Fprintf(stderr, "talk daemon: unbekanntes Unterkommando %q (erlaubt: run, start, stop)\n", sub)
fmt.Fprintf(stderr, "talk daemon: unknown subcommand %q (allowed: run, start, stop)\n", sub)
return exitUsage
}
}
@@ -74,20 +73,20 @@ func daemonRun(cfg config.Config, stderr io.Writer) int {
func daemonStart(cfg config.Config, stdout, stderr io.Writer) int {
client := api.NewClient(cfg.Server.Port)
if daemonAlive(client, daemonProbeTimeout) {
fmt.Fprintf(stdout, "daemon: läuft bereits unter %s\n", client.BaseURL)
fmt.Fprintf(stdout, "daemon: already running at %s\n", client.BaseURL)
return exitOK
}
if !startDaemon(client, stderr) {
return exitFailure
}
fmt.Fprintf(stdout, "daemon: gestartet unter %s\n", client.BaseURL)
fmt.Fprintf(stdout, "daemon: started at %s\n", client.BaseURL)
return exitOK
}
func daemonStop(cfg config.Config, stdout, stderr io.Writer) int {
client := api.NewClient(cfg.Server.Port)
if !daemonAlive(client, daemonProbeTimeout) {
fmt.Fprintf(stdout, "daemon: läuft nicht (%s)\n", client.BaseURL)
fmt.Fprintf(stdout, "daemon: not running (%s)\n", client.BaseURL)
return exitOK
}
ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
@@ -96,9 +95,9 @@ func daemonStop(cfg config.Config, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "talk daemon stop: %v\n", err)
return exitFailure
}
// Warten, bis der Daemon wirklich weg ist: erst verstummt HTTP, dann
// bricht er die laufende Ansage ab und löscht als Letztes die PID-Datei.
// Ohne Config-Dir (keine PID-Datei) genügt das HTTP-Ende.
// Wait until the daemon is really gone: first HTTP falls silent, then it
// aborts the playing announcement and deletes the PID file as its last
// act. Without a config directory (no PID file) the HTTP end suffices.
pidDir := ""
if dir, err := config.Dir(getenv); err == nil {
pidDir = dir
@@ -106,12 +105,12 @@ func daemonStop(cfg config.Config, stdout, stderr io.Writer) int {
deadline := time.Now().Add(daemonStopTimeout)
for daemonAlive(client, daemonStartPoll*4) || (pidDir != "" && daemon.ReadPID(pidDir) != 0) {
if time.Now().After(deadline) {
fmt.Fprintf(stderr, "talk daemon stop: Daemon ist nach %s noch nicht beendet — PID siehe %s im Config-Verzeichnis\n",
fmt.Fprintf(stderr, "talk daemon stop: daemon has not exited after %s; see %s in the config directory for its PID\n",
daemonStopTimeout, daemon.PIDFileName)
return exitFailure
}
time.Sleep(daemonStartPoll)
}
fmt.Fprintln(stdout, "daemon: gestoppt")
fmt.Fprintln(stdout, "daemon: stopped")
return exitOK
}
+68 -82
View File
@@ -22,31 +22,31 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/speak"
)
// Status einer doctor-Prüfung.
// Status of a doctor check.
const (
statusOK = "ok" // ✔ in Ordnung
statusWarn = "warn" // ⚠ Hinweis — funktioniert, aber eingeschränkt oder beachtenswert
statusFail = "fail" // ✘ Fehler — so wird nichts zu hören sein (Exit 1)
statusOK = "ok" // ✔ fine
statusWarn = "warn" // ⚠ note: works, but limited or worth knowing
statusFail = "fail" // ✘ error: nothing will be heard like this (exit 1)
)
// check ist eine Zeile des doctor-Berichts (auch die JSON-Form).
// check is one line of the doctor report (also the JSON form).
type check struct {
Check string `json:"check"`
Status string `json:"status"`
Detail string `json:"detail"`
}
// probeTimeout ist das Budget je Synthese-Probe (--probe).
// probeTimeout is the budget per synthesis probe (--probe).
const probeTimeout = 20 * time.Second
// runDoctor implementiert `talk doctor`: eine Zeile je Prüfpunkt — Config,
// Daemon, Audio, jede Provider-Stufe, Mute, Gesetze — mit klarem Satz, was
// Sache ist und was zu tun wäre. Exit 0 ohne ✘, sonst 1; ein nicht
// laufender Daemon ist ⚠ (er startet beim nächsten Sprechbefehl). --probe
// macht echte Synthese-Proben (Netz); --json liefert die Liste als JSON.
// runDoctor implements `talk doctor`: one line per check (config, daemon,
// audio, every provider tier, notifications, mute, laws) with a plain
// sentence saying what is up and what to do. Exit 0 without ✘, else 1; a
// daemon that is not running is ⚠ (it starts on the next speak command).
// --probe runs real synthesis probes (network); --json returns the list.
func runDoctor(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Writer) int {
if len(args) > 0 {
fmt.Fprintln(stderr, "talk doctor: keine Argumente erwartet (Flags: --probe, --json)")
fmt.Fprintln(stderr, "talk doctor: no arguments expected (flags: --probe, --json)")
return exitUsage
}
checks := doctorChecks(opts.probe)
@@ -65,9 +65,9 @@ func runDoctor(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.W
fmt.Fprintf(stdout, "%s %-11s %s\n", symbol(c.Status), c.Check, c.Detail)
}
if failed {
fmt.Fprintln(stdout, "\nMindestens ein Punkt verhindert Ton (✘). Behebe ihn und prüfe mit `talk test`.")
fmt.Fprintln(stdout, "\nAt least one item prevents sound (✘). Fix it, then check with `talk test`.")
} else {
fmt.Fprintln(stdout, "\nAlles bereit. Probiere: talk test")
fmt.Fprintln(stdout, "\nAll set. Try: talk test")
}
}
if failed {
@@ -76,8 +76,8 @@ func runDoctor(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.W
return exitOK
}
// symbol rendert den Status: Unicode-Zeichen, auf Windows-Konsolen (oft
// ohne UTF-8) kurze Wörter.
// symbol renders the status: Unicode marks, short words on Windows consoles
// (often without UTF-8).
func symbol(status string) string {
win := runtime.GOOS == "windows"
switch status {
@@ -99,34 +99,19 @@ func symbol(status string) string {
}
}
// doctorChecks führt alle Prüfungen aus. Jede Prüfung ist unabhängig: Ein
// Fehler in einer (z. B. kaputte Config) verhindert die anderen nicht —
// dann laufen sie mit Defaults, damit der Bericht vollständig bleibt.
// doctorChecks runs all checks. Each one is independent: a failure in one
// (for example a broken config) does not prevent the others; they run with
// defaults so the report stays complete.
func doctorChecks(probe bool) []check {
var out []check
// 1. Config
cfg, cfgCheck := checkConfig()
out = append(out, cfgCheck)
// 2. Daemon
out = append(out, checkDaemon(cfg))
// 3. Audio
out = append(out, checkAudio(cfg))
// 4. Provider-Stufen (+ 5. Gesamt-Verfügbarkeit)
out = append(out, checkProviders(cfg, probe)...)
// 6. Benachrichtigungen (Ruhezeit-Toast)
out = append(out, checkNotify(cfg))
// 7. Mute
out = append(out, checkMute())
// 8. Gesetze
out = append(out, checkLaws(cfg))
return out
}
@@ -135,16 +120,16 @@ func checkConfig() (config.Config, check) {
cfg, err := config.Load(config.Options{Getenv: getenv})
switch {
case err != nil:
// Mit Defaults weiterprüfen, aber den Fehler klar nennen.
// Keep checking with defaults, but name the error clearly.
cfg, _ = config.Load(config.Options{Getenv: func(string) string { return "" }})
return cfg, check{"Config", statusFail, fmt.Sprintf("%v — bitte beheben, talk läuft sonst nicht", err)}
return cfg, check{"Config", statusFail, fmt.Sprintf("%v; fix it, talk does not run otherwise", err)}
case perr != nil:
return cfg, check{"Config", statusWarn, "kein Config-Verzeichnis (HOME bzw. APPDATA fehlt) — Defaults + Env; Mute und Daemon-Dateien deaktiviert"}
return cfg, check{"Config", statusWarn, "no config directory (HOME or APPDATA not set); defaults + env, mute and daemon files disabled"}
}
if _, statErr := os.Stat(path); statErr != nil {
return cfg, check{"Config", statusOK, fmt.Sprintf("keine Datei unter %s — Defaults gelten (talk setup legt eine an)", path)}
return cfg, check{"Config", statusOK, fmt.Sprintf("no file at %s; defaults apply (talk setup creates one)", path)}
}
return cfg, check{"Config", statusOK, path + " geladen"}
return cfg, check{"Config", statusOK, path + " loaded"}
}
func checkDaemon(cfg config.Config) check {
@@ -153,32 +138,32 @@ func checkDaemon(cfg config.Config) check {
defer cancel()
st, err := client.Status(ctx)
if err == nil {
detail := fmt.Sprintf("läuft unter %s (version %s, %d in der Queue)", client.BaseURL, st.Version, st.Queue)
detail := fmt.Sprintf("running at %s (version %s, %d queued)", client.BaseURL, st.Version, st.Queue)
if dir, derr := config.Dir(getenv); derr == nil {
detail += ", Log: " + filepath.Join(dir, daemon.LogFileName)
detail += ", log: " + filepath.Join(dir, daemon.LogFileName)
}
return check{"Daemon", statusOK, detail}
}
var httpErr *api.HTTPError
if errors.As(err, &httpErr) {
return check{"Daemon", statusFail, fmt.Sprintf("unter %s antwortet ein fremder Dienst (HTTP %d) — anderen Port wählen: [server] port bzw. TALK_PORT", client.BaseURL, httpErr.Status)}
return check{"Daemon", statusFail, fmt.Sprintf("a foreign service answers at %s (HTTP %d); choose another port: [server] port or TALK_PORT", client.BaseURL, httpErr.Status)}
}
if cfg.Daemon.Autostart {
return check{"Daemon", statusWarn, fmt.Sprintf("läuft nicht (%s) — startet beim nächsten Sprechbefehl von selbst", client.BaseURL)}
return check{"Daemon", statusWarn, fmt.Sprintf("not running (%s); starts by itself on the next speak command", client.BaseURL)}
}
return check{"Daemon", statusWarn, fmt.Sprintf("läuft nicht (%s), Autostart ist aus — `talk daemon start` oder Dienst einrichten; bis dahin spricht talk direkt", client.BaseURL)}
return check{"Daemon", statusWarn, fmt.Sprintf("not running (%s), autostart is off; `talk daemon start` or set up a service; until then talk speaks directly", client.BaseURL)}
}
func checkAudio(cfg config.Config) check {
if cfg.Audio.Sink == config.SinkNull {
return check{"Audio", statusWarn, "audio.sink = null — kein Ton (für CI/Server gedacht; TALK_AUDIO_SINK=oto für Wiedergabe)"}
return check{"Audio", statusWarn, "audio.sink = null; no sound (meant for CI/servers; TALK_AUDIO_SINK=oto for playback)"}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := audio.Check(ctx, cfg.Audio.Sink); err != nil {
return check{"Audio", statusFail, fmt.Sprintf("%v — kein Audiogerät oder Audio-Bibliothek fehlt (Linux: PulseAudio/ALSA)", err)}
return check{"Audio", statusFail, fmt.Sprintf("%v; no audio device, or the audio library is missing (Linux: PulseAudio/ALSA)", err)}
}
return check{"Audio", statusOK, "Audiogerät bereit (oto)"}
return check{"Audio", statusOK, "audio device ready (oto)"}
}
func checkProviders(cfg config.Config, probe bool) []check {
@@ -199,9 +184,9 @@ func checkProviders(cfg config.Config, probe bool) []check {
out = append(out, c)
}
if available == 0 {
out = append(out, check{"Provider", statusFail, "keine Stufe verfügbar — ohne Key und ohne OS-Stimme kann talk nicht sprechen (espeak-ng installieren oder TALK_ELEVENLABS_KEY/TALK_OPENAI_KEY setzen)"})
out = append(out, check{"Provider", statusFail, "no tier available; without a key and without an OS voice talk cannot speak (install espeak-ng or set TALK_ELEVENLABS_KEY/TALK_OPENAI_KEY)"})
} else if cfg.Provider != "" {
out = append(out, check{"Provider", statusOK, fmt.Sprintf("Kette auf %q gepinnt (config provider) — kein Fallback", cfg.Provider)})
out = append(out, check{"Provider", statusOK, fmt.Sprintf("chain pinned to %q (config provider); no fallback", cfg.Provider)})
}
return out
}
@@ -215,42 +200,42 @@ func checkTier(p provider.Provider, cfg config.Config) check {
envKey = provider.EnvOpenAIKey
}
if !p.Available(context.Background()) {
return check{name, statusWarn, fmt.Sprintf("kein Key (%s) — Stufe wird übersprungen", envKey)}
return check{name, statusWarn, fmt.Sprintf("no key (%s); tier is skipped", envKey)}
}
detail := fmt.Sprintf("Key gesetzt (%s)", envKey)
detail := fmt.Sprintf("key set (%s)", envKey)
if ep := cfg.Providers[name].Endpoint; ep != "" {
detail += ", Endpunkt " + ep
detail += ", endpoint " + ep
}
if v := provider.DefaultVoice(name, "de", cfg.Voice); v != "" {
detail += ", Stimme de: " + voiceLabel(name, v)
if v := provider.DefaultVoice(name, cfg.Lang, cfg.Voice); v != "" {
detail += fmt.Sprintf(", voice %s: %s", cfg.Lang, voiceLabel(name, v))
}
return check{name, statusOK, detail}
case "edge":
if !p.Available(context.Background()) {
return check{name, statusWarn, "kein Endpunkt konfiguriert — Stufe wird übersprungen"}
return check{name, statusWarn, "no endpoint configured; tier is skipped"}
}
return check{name, statusOK, "kostenlos, ohne Key (braucht Internet), Stimme de: " + voiceLabel(name, provider.DefaultVoice(name, "de", cfg.Voice))}
return check{name, statusOK, fmt.Sprintf("free, no key (needs internet), voice %s: %s", cfg.Lang, voiceLabel(name, provider.DefaultVoice(name, cfg.Lang, cfg.Voice)))}
case "osbuiltin":
desc := "OS-Stimme"
desc := "OS voice"
if d, ok := p.(interface{ Describe() string }); ok {
desc = d.Describe()
}
if !p.Available(context.Background()) {
return check{name, statusWarn, desc + " — letzte Stufe fehlt; ohne Netz bleibt talk stumm"}
return check{name, statusWarn, desc + "; the last tier is missing, talk stays silent without network"}
}
if strings.Contains(desc, "nur Direktansage") {
if strings.Contains(desc, "direct speech only") {
return check{name, statusWarn, desc}
}
return check{name, statusOK, desc}
default:
if p.Available(context.Background()) {
return check{name, statusOK, "verfügbar"}
return check{name, statusOK, "available"}
}
return check{name, statusWarn, "nicht verfügbar"}
return check{name, statusWarn, "not available"}
}
}
// voiceLabel rendert eine Voice-ID mit Katalog-Namen, falls bekannt.
// voiceLabel renders a voice ID with its catalog name when known.
func voiceLabel(prov, id string) string {
if n := provider.VoiceName(prov, id); n != "" {
return fmt.Sprintf("%s (%s)", n, id)
@@ -258,68 +243,69 @@ func voiceLabel(prov, id string) string {
return id
}
// probeTier macht eine echte Synthese-Probe (kein Abspielen) und meldet
// Bytes und Dauer bzw. den Fehler.
// probeTier runs a real synthesis probe (no playback) and reports bytes and
// duration, or the error.
func probeTier(p provider.Provider) string {
ctx, cancel := context.WithTimeout(context.Background(), probeTimeout)
defer cancel()
start := time.Now()
data, err := p.Synthesize(ctx, "Test", "de")
data, err := p.Synthesize(ctx, "Test", "en")
switch {
case errors.Is(err, provider.ErrNoByteOutput):
return "Probe: liefert keine Bytes (spricht nur direkt) — ok"
return "probe: yields no bytes (speaks directly only); ok"
case err != nil:
return "Probe FEHLGESCHLAGEN: " + err.Error()
return "probe FAILED: " + err.Error()
default:
return fmt.Sprintf("Probe ok: %d Bytes in %s", len(data), time.Since(start).Round(time.Millisecond))
return fmt.Sprintf("probe ok: %d bytes in %s", len(data), time.Since(start).Round(time.Millisecond))
}
}
func checkNotify(cfg config.Config) check {
if notify.Available() {
return check{"Toast", statusOK, notify.Describe() + " — für quiet_mode = toast"}
return check{"Toast", statusOK, notify.Describe() + "; used for quiet_mode = toast"}
}
if cfg.QuietMode == queue.QuietModeToast {
return check{"Toast", statusWarn, notify.Describe() + " — quiet_mode = toast wird nur protokolliert"}
return check{"Toast", statusWarn, notify.Describe() + "; quiet_mode = toast is only logged"}
}
return check{"Toast", statusOK, notify.Describe() + " (nur für quiet_mode = toast relevant)"}
return check{"Toast", statusOK, notify.Describe() + " (only relevant for quiet_mode = toast)"}
}
func checkMute() check {
dir, err := config.Dir(getenv)
if err != nil {
return check{"Mute", statusOK, "aus (kein Config-Dir, Mute nicht verfügbar)"}
return check{"Mute", statusOK, "off (no config directory, mute unavailable)"}
}
if (queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}).Muted() {
return check{"Mute", statusWarn, "AN — alle Ansagen werden verworfen, bis `talk unmute`"}
return check{"Mute", statusWarn, "ON; every announcement is discarded until `talk unmute`"}
}
return check{"Mute", statusOK, "aus"}
return check{"Mute", statusOK, "off"}
}
func checkLaws(cfg config.Config) check {
parts := []string{
fmt.Sprintf("rate-limit %s", durationOrOff(cfg.Queue.RateLimit)),
fmt.Sprintf("max. %d Zeichen", cfg.Queue.MaxText),
fmt.Sprintf("max. Wartezeit %s", durationOrOff(cfg.Queue.MaxAge)),
fmt.Sprintf("rate limit %s", durationOrOff(cfg.Queue.RateLimit)),
fmt.Sprintf("max %d characters", cfg.Queue.MaxText),
fmt.Sprintf("max wait %s", durationOrOff(cfg.Queue.MaxAge)),
fmt.Sprintf("fallback language %s", cfg.Lang),
}
status := statusOK
if cfg.QuietHours == "" {
parts = append(parts, "Ruhezeit aus")
parts = append(parts, "quiet hours off")
} else {
q, err := queue.ParseQuietHours(cfg.QuietHours)
if err == nil && q.Active(time.Now()) {
status = statusWarn
parts = append(parts, fmt.Sprintf("Ruhezeit %s AKTIV bis %s (modus %s) — Ansagen werden gerade verworfen", cfg.QuietHours, q.Until(time.Now()).Format("15:04"), cfg.QuietMode))
parts = append(parts, fmt.Sprintf("quiet hours %s ACTIVE until %s (mode %s); announcements are being discarded right now", cfg.QuietHours, q.Until(time.Now()).Format("15:04"), cfg.QuietMode))
} else {
parts = append(parts, fmt.Sprintf("Ruhezeit %s (modus %s)", cfg.QuietHours, cfg.QuietMode))
parts = append(parts, fmt.Sprintf("quiet hours %s (mode %s)", cfg.QuietHours, cfg.QuietMode))
}
}
return check{"Gesetze", status, strings.Join(parts, ", ")}
return check{"Laws", status, strings.Join(parts, ", ")}
}
func durationOrOff(d time.Duration) string {
if d <= 0 {
return "aus"
return "off"
}
return d.String()
}
+27 -27
View File
@@ -18,30 +18,30 @@ func checkByName(t *testing.T, checks []check, name string) check {
return c
}
}
t.Fatalf("Prüfung %q fehlt in %+v", name, checks)
t.Fatalf("check %q missing in %+v", name, checks)
return check{}
}
// TestDoctorOhneDaemon: toter Daemon ist ⚠ (kein Fehler), sink null ist ⚠,
// Cloud-Stufen ohne Key ⚠, edge ✔ — Exit 0, Abschlusszeile „Alles bereit".
func TestDoctorOhneDaemon(t *testing.T) {
// TestDoctorWithoutDaemon: a dead daemon is ⚠ (no error), sink null is ⚠,
// cloud tiers without keys ⚠, edge ✔; exit 0, closing line "All set".
func TestDoctorWithoutDaemon(t *testing.T) {
root := t.TempDir()
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null", "XDG_CONFIG_HOME": root})
var out, errBuf bytes.Buffer
code := runCLI([]string{"doctor"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk doctor: Exit %d, stdout %q, stderr %q", code, out.String(), errBuf.String())
t.Fatalf("talk doctor: exit %d, stdout %q, stderr %q", code, out.String(), errBuf.String())
}
for _, want := range []string{"⚠ Daemon", "läuft nicht", "⚠ Audio", "⚠ elevenlabs", "TALK_ELEVENLABS_KEY", "✔ edge", "✔ Mute", "✔ Gesetze", "Alles bereit"} {
for _, want := range []string{"⚠ Daemon", "not running", "⚠ Audio", "⚠ elevenlabs", "TALK_ELEVENLABS_KEY", "✔ edge", "✔ Mute", "✔ Laws", "All set"} {
if !strings.Contains(out.String(), want) {
t.Errorf("stdout ohne %q:\n%s", want, out.String())
t.Errorf("stdout without %q:\n%s", want, out.String())
}
}
}
// TestDoctorJSONUndMute: --json liefert die Prüfliste; eine Mute-Datei
// macht Mute zu warn; ein Key macht die Stufe ok.
func TestDoctorJSONUndMute(t *testing.T) {
// TestDoctorJSONAndMute: --json yields the check list; a mute file makes
// Mute warn; a key makes the tier ok.
func TestDoctorJSONAndMute(t *testing.T) {
root := t.TempDir()
setGetenv(t, map[string]string{
"TALK_AUDIO_SINK": "null",
@@ -56,45 +56,45 @@ func TestDoctorJSONUndMute(t *testing.T) {
}
var out, errBuf bytes.Buffer
if code := runCLI([]string{"doctor", "--json"}, &out, &errBuf); code != exitOK {
t.Fatalf("talk doctor --json: Exit %d, stderr %q", code, errBuf.String())
t.Fatalf("talk doctor --json: exit %d, stderr %q", code, errBuf.String())
}
var checks []check
if err := json.Unmarshal(out.Bytes(), &checks); err != nil {
t.Fatalf("JSON = %q (%v)", out.String(), err)
}
if c := checkByName(t, checks, "Mute"); c.Status != statusWarn || !strings.Contains(c.Detail, "unmute") {
t.Errorf("Mute = %+v, want warn mit unmute-Hinweis", c)
t.Errorf("Mute = %+v, want warn with an unmute hint", c)
}
if c := checkByName(t, checks, "elevenlabs"); c.Status != statusOK || !strings.Contains(c.Detail, "Rachel") {
t.Errorf("elevenlabs = %+v, want ok mit Default-Stimme", c)
t.Errorf("elevenlabs = %+v, want ok with the default voice", c)
}
if c := checkByName(t, checks, "Daemon"); c.Status != statusWarn {
t.Errorf("Daemon = %+v, want warn", c)
}
}
// TestDoctorFremderDienstAufPort: Antwortet unter dem Port etwas anderes als
// talk, ist das ✘ mit Port-Hinweis — Exit 1.
func TestDoctorFremderDienstAufPort(t *testing.T) {
fremd := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TestDoctorForeignServiceOnPort: if something other than talk answers on
// the port, that is ✘ with a port hint; exit 1.
func TestDoctorForeignServiceOnPort(t *testing.T) {
foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(fremd.Close)
port := fremd.URL[strings.LastIndex(fremd.URL, ":")+1:]
t.Cleanup(foreign.Close)
port := foreign.URL[strings.LastIndex(foreign.URL, ":")+1:]
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null", "TALK_PORT": port})
var out, errBuf bytes.Buffer
code := runCLI([]string{"doctor"}, &out, &errBuf)
if code != exitFailure {
t.Fatalf("talk doctor (fremder Dienst): Exit %d, want 1\n%s", code, out.String())
t.Fatalf("talk doctor (foreign service): exit %d, want 1\n%s", code, out.String())
}
if !strings.Contains(out.String(), "✘ Daemon") || !strings.Contains(out.String(), "fremder Dienst") || !strings.Contains(out.String(), "TALK_PORT") {
if !strings.Contains(out.String(), "✘ Daemon") || !strings.Contains(out.String(), "foreign service") || !strings.Contains(out.String(), "TALK_PORT") {
t.Errorf("stdout = %q", out.String())
}
}
// TestDoctorKaputteConfig: Eine unlesbare config.toml ist ✘, die übrigen
// Prüfungen laufen trotzdem (mit Defaults) — der Bericht bleibt vollständig.
func TestDoctorKaputteConfig(t *testing.T) {
// TestDoctorBrokenConfig: an unreadable config.toml is ✘, the other checks
// still run (with defaults); the report stays complete.
func TestDoctorBrokenConfig(t *testing.T) {
root := t.TempDir()
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null", "XDG_CONFIG_HOME": root})
if err := os.MkdirAll(filepath.Join(root, "talk"), 0o755); err != nil {
@@ -106,11 +106,11 @@ func TestDoctorKaputteConfig(t *testing.T) {
var out, errBuf bytes.Buffer
code := runCLI([]string{"doctor"}, &out, &errBuf)
if code != exitFailure || !strings.Contains(out.String(), "✘ Config") {
t.Errorf("Exit %d, stdout %q — want ✘ Config und Exit 1", code, out.String())
t.Errorf("exit %d, stdout %q; want ✘ Config and exit 1", code, out.String())
}
for _, want := range []string{"Daemon", "edge", "Mute", "Gesetze"} {
for _, want := range []string{"Daemon", "edge", "Mute", "Laws"} {
if !strings.Contains(out.String(), want) {
t.Errorf("Prüfung %q fehlt trotz Config-Fehler:\n%s", want, out.String())
t.Errorf("check %q missing despite the config error:\n%s", want, out.String())
}
}
}
+14 -14
View File
@@ -1,14 +1,14 @@
// talk ist das eine Binary von talk 2.0: CLI und Daemon.
// talk is the single binary of talk: CLI and daemon.
//
// say (und der Default-Aufruf `talk "Text"`) reicht Ansagen in den Daemon
// ein — non-blocking, --wait pollt bis zur Fertigstellung, --now springt
// vor. Läuft kein Daemon, startet talk ihn selbst im Hintergrund
// (daemon.autostart); gelingt auch das nicht, spricht talk direkt (ohne
// Warteschlange, mit Hinweis) — nichts bricht still. `talk daemon run`
// betreibt den Daemon im Vordergrund, start/stop steuern ihn explizit.
// mute/unmute/status/cancel sind Daemon-Aufrufe mit plain Output;
// `talk wrap -- <Befehl>` führt Befehle aus und sagt das Ergebnis an. Die
// übrigen Befehle sind Stubs.
// say (and the default form `talk "Text"`) hands announcements to the
// daemon: non-blocking, --wait polls until spoken, --now jumps the queue.
// When no daemon runs, talk starts one itself in the background
// (daemon.autostart); if that fails too, talk speaks directly (no queue,
// with a note). Nothing fails silently. `talk daemon run` runs the daemon in
// the foreground, start/stop control it explicitly. mute/unmute/status/
// cancel are daemon calls with plain output; `talk wrap -- <command>` runs
// commands and announces the result; doctor/test/voices/setup cover
// diagnostics and onboarding.
package main
import (
@@ -16,18 +16,18 @@ import (
"os"
)
// Build-Informationen. Werden beim Release per -ldflags injiziert, z. B.
// Build information, injected at release time via -ldflags, for example
//
// go build -ldflags "-X main.version=1.0.0 -X main.commit=abc1234" ./cmd/talk
//
// Defaults: version "dev", commit leer (siehe auch .goreleaser.yaml, Task 11).
// Defaults: version "dev", commit empty (see scripts/release.sh).
var (
version = "dev"
commit = ""
)
// buildVersion rendert Version plus Commit-Hash in Klammern, falls injiziert
// — der Wert, den der Daemon in /status und MCP serverInfo meldet.
// buildVersion renders the version plus the commit hash in parentheses when
// injected; the value the daemon reports in /status and MCP serverInfo.
func buildVersion() string {
if commit == "" {
return version
+23 -24
View File
@@ -7,12 +7,11 @@ import (
"testing"
)
// TestParseInterspersedDoubleDash (Regression, Task 07): Das erste allein
// stehende „--" schirmt alles Folgenden von der Flag-Auswertung ab — der
// Kern der wrap-Aufrufform `talk wrap -- foo --bar`. Vorher nahm der
// Interspersed-Loop nach jedem Positionsargument das Parsen wieder auf und
// fraß damit Flags des nachfolgenden Befehls. Gleichzeitig bleibt die
// Abnahme-Form „Flag nach dem Text" (ohne „--") intakt.
// TestParseInterspersedDoubleDash (regression): the first standalone "--"
// shields everything after it from flag parsing, the core of the wrap form
// `talk wrap -- foo --bar`. Previously the interspersed loop resumed parsing
// after every positional and ate the wrapped command's flags. At the same
// time the "flag after the text" form (without "--") stays intact.
func TestParseInterspersedDoubleDash(t *testing.T) {
newWrapFS := func() (*flag.FlagSet, *sayOptions) {
fs := flag.NewFlagSet("talk wrap", flag.ContinueOnError)
@@ -21,78 +20,78 @@ func TestParseInterspersedDoubleDash(t *testing.T) {
return fs, addSayFlags(fs)
}
t.Run("Doppelminus schirmt Befehls-Flags", func(t *testing.T) {
t.Run("double dash shields command flags", func(t *testing.T) {
fs, opts := newWrapFS()
positional, err := parseInterspersed(fs, []string{"--", "foo", "--bar"})
if err != nil {
t.Fatalf("parse: %v", err)
}
if strings.Join(positional, " ") != "foo --bar" {
t.Errorf("Positionsargumente = %q, want %q", positional, []string{"foo", "--bar"})
t.Errorf("positional = %q, want %q", positional, []string{"foo", "--bar"})
}
if opts.wait {
t.Error("--bar darf kein talk-Flag geworden sein (Shielding kaputt)")
t.Error("--bar must not have become a talk flag (shielding broken)")
}
})
t.Run("talk-Flags vor dem Doppelminus gelten", func(t *testing.T) {
t.Run("talk flags before the double dash apply", func(t *testing.T) {
fs, opts := newWrapFS()
positional, err := parseInterspersed(fs, []string{"--wait", "--", "sleep", "1"})
if err != nil {
t.Fatalf("parse: %v", err)
}
if !opts.wait {
t.Error("--wait vor dem -- muss als talk-Flag gelten")
t.Error("--wait before the -- must count as a talk flag")
}
if strings.Join(positional, " ") != "sleep 1" {
t.Errorf("Positionsargumente = %q, want %q", positional, []string{"sleep", "1"})
t.Errorf("positional = %q, want %q", positional, []string{"sleep", "1"})
}
})
t.Run("Positional vor dem Doppelminus wird mitgeschleppt", func(t *testing.T) {
t.Run("positional before the double dash is carried along", func(t *testing.T) {
fs, _ := newWrapFS()
positional, err := parseInterspersed(fs, []string{"foo", "--", "--bar"})
if err != nil {
t.Fatalf("parse: %v", err)
}
if strings.Join(positional, " ") != "foo --bar" {
t.Errorf("Positionsargumente = %q, want %q", positional, []string{"foo", "--bar"})
t.Errorf("positional = %q, want %q", positional, []string{"foo", "--bar"})
}
})
t.Run("abgeschirmtes talk-Flag setzt den Flag nicht", func(t *testing.T) {
t.Run("shielded talk flag does not set the flag", func(t *testing.T) {
fs, opts := newWrapFS()
positional, err := parseInterspersed(fs, []string{"--", "--wait", "true"})
if err != nil {
t.Fatalf("parse: %v", err)
}
if opts.wait {
t.Error("--wait NACH dem -- gehört dem Befehl, nicht talk")
t.Error("--wait AFTER the -- belongs to the command, not talk")
}
if strings.Join(positional, " ") != "--wait true" {
t.Errorf("Positionsargumente = %q, want %q", positional, []string{"--wait", "true"})
t.Errorf("positional = %q, want %q", positional, []string{"--wait", "true"})
}
})
t.Run("Flag nach dem Text ohne Doppelminus bleibt erlaubt", func(t *testing.T) {
t.Run("flag after the text without double dash stays allowed", func(t *testing.T) {
fs, opts := newWrapFS()
positional, err := parseInterspersed(fs, []string{"Text", "--lang=en"})
if err != nil {
t.Fatalf("parse: %v (Abnahme-Form Flag-nach-Text kaputt)", err)
t.Fatalf("parse: %v (flag-after-text form broken)", err)
}
if strings.Join(positional, " ") != "Text" || opts.lang != "en" {
t.Errorf("Positionsargumente = %q, lang = %q — Flag-nach-Text muss weitergehen", positional, opts.lang)
t.Errorf("positional = %q, lang = %q; flag-after-text must keep working", positional, opts.lang)
}
})
t.Run("Text nach dem Doppelminus kommt vollständig durch (say)", func(t *testing.T) {
t.Run("text after the double dash comes through completely (say)", func(t *testing.T) {
fs, _ := newWrapFS()
positional, err := parseInterspersed(fs, []string{"--", "Bericht", "mit --flag", "fertig"})
positional, err := parseInterspersed(fs, []string{"--", "Report", "with --flag", "done"})
if err != nil {
t.Fatalf("parse: %v", err)
}
if strings.Join(positional, " ") != "Bericht mit --flag fertig" {
t.Errorf("Positionsargumente = %q, want Wörter unverändert", positional)
if strings.Join(positional, " ") != "Report with --flag done" {
t.Errorf("positional = %q, want the words unchanged", positional)
}
})
}
+34 -35
View File
@@ -13,13 +13,13 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/speak"
)
// getenv ist die Env-Seam von run (Tests injizieren hier TALK_AUDIO_SINK,
// TALK_PORT etc., ohne den Prozess-Environment anzufassen). Default:
// getenv is the CLI's environment seam (tests inject TALK_AUDIO_SINK,
// TALK_PORT etc. without touching the process environment). Default:
// os.Getenv.
var getenv = os.Getenv
// sayOptions bündelt die Flags von say (identisch im Default-Pfad
// `talk "Text"`).
// sayOptions bundles the say flags (identical for the default form
// `talk "text"`).
type sayOptions struct {
wait bool
now bool
@@ -28,40 +28,39 @@ type sayOptions struct {
lang string
provider string
// Diagnose-Flags (doctor, voices): maschinenlesbare Ausgabe und echte
// Netz-Proben. Liegen in derselben Struct, damit die Befehlstabelle
// eine Flag-Signatur behält; say ignoriert sie.
// Diagnostic flags (doctor, voices): machine-readable output and real
// network probes. They live in the same struct so the command table
// keeps one flag signature; say ignores them.
jsonOut bool
probe bool
}
// runSay ist der say-Eingangspfad: Text aus Argumenten oder stdin sammeln
// und validieren, dann über den Daemon sprechen — non-blocking als
// Default, --wait pollt bis zur Fertigstellung, --now springt vor; Läuft
// kein Daemon, startet talk ihn selbst (daemon.autostart). Geht auch das
// nicht, spricht talk direkt im eigenen Prozess (sayDirect) — ohne
// Warteschlange, aber nie still.
// runSay is the say entry point: collect the text from arguments or stdin,
// validate, then speak through the daemon (non-blocking by default, --wait
// polls until spoken, --now jumps the queue). When no daemon runs, talk
// starts one itself (daemon.autostart). If that fails too, talk speaks
// directly in this process (sayDirect): no queue, but never silent.
func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
text := strings.Join(args, " ")
if text == "" {
if isCharDevice(stdin) {
fmt.Fprintln(stderr, "talk: kein Text — bitte als Argument übergeben oder über stdin pipen")
fmt.Fprintln(stderr, "talk: no text given; pass it as an argument or pipe it via stdin")
return exitUsage
}
b, err := io.ReadAll(stdin)
if err != nil {
fmt.Fprintf(stderr, "talk: stdin lesen: %v\n", err)
fmt.Fprintf(stderr, "talk: reading stdin: %v\n", err)
return exitFailure
}
text = strings.TrimSpace(string(b))
if text == "" {
fmt.Fprintln(stderr, "talk: kein Text (Argument und stdin sind leer)")
fmt.Fprintln(stderr, "talk: no text given (argument and stdin are empty)")
return exitFailure
}
}
// Validierung vor jedem Daemon-Kontakt (auch wrap erbt die say-Flags):
// Benutzungsfehler sollen schnell und ohne Netzwerk-Umwege auffallen.
// Validate before any daemon contact (wrap inherits the say flags):
// usage errors should fail fast and without network round trips.
if code, bad := validateSayOptions(opts, stderr); bad {
return code
}
@@ -74,30 +73,30 @@ func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.
return announce(cfg, text, opts, stdout, stderr)
}
// announce ist der gemeinsame Ansage-Weg von say und wrap: Daemon sicher-
// stellen (ggf. starten) und einreihen; ohne Daemon direkt sprechen. info
// ist der Writer für Informations-Token (Item-ID, muted, discarded).
// announce is the shared announcement path of say and wrap: ensure a daemon
// (start one if needed) and enqueue; without a daemon speak directly. info
// is the writer for the information tokens (item ID, muted, discarded).
func announce(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) int {
if client, ok := ensureDaemon(cfg, stderr); ok {
if code, handled := sayViaDaemon(client, text, opts, info, stderr); handled {
return code
}
}
fmt.Fprintln(stderr, "talk: kein Daemon erreichbar — spreche direkt (ohne Warteschlange)")
fmt.Fprintln(stderr, "talk: no daemon reachable; speaking directly (no queue)")
return sayDirect(cfg, text, opts, info, stderr)
}
// sayDirect ist der Notfallpfad ohne Daemon: Mute respektieren (dieselbe
// Datei, die der Daemon liest), dann über den Sprech-Kern sprechen —
// blockierend im CLI-Prozess, unserialisiert (zwei gleichzeitige Aufrufe
// können überlappen; der Daemon ist der Normalfall). Ohne Config-Dir läuft
// es ohne Mute weiter, statt zu scheitern.
// sayDirect is the emergency path without a daemon: honour mute (the same
// file the daemon reads), then speak via the engine, blocking in the CLI
// process and unserialized (two simultaneous calls may overlap; the daemon
// is the normal case). Without a config directory it runs without mute
// rather than failing.
func sayDirect(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) int {
if dir, err := config.Dir(getenv); err == nil {
mute := queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}
if mute.Muted() {
fmt.Fprintln(info, "muted")
fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen")
fmt.Fprintln(stderr, "talk: muted; announcement discarded")
return exitOK
}
}
@@ -119,23 +118,23 @@ func sayDirect(cfg config.Config, text string, opts *sayOptions, info, stderr io
return exitOK
}
// validateSayOptions prüft die say-Flags (wrap erbt sie): --lang auf de |
// en, --priority auf low | normal | high. Rückgabe (code, true) heißt:
// Benutzungsfehler, bereits auf stderr gemeldet.
// validateSayOptions checks the say flags (wrap inherits them): --lang must
// be de | en, --priority low | normal | high. (code, true) means: usage
// error, already reported on stderr.
func validateSayOptions(opts *sayOptions, stderr io.Writer) (int, bool) {
if opts.lang != "" && opts.lang != "de" && opts.lang != "en" {
fmt.Fprintf(stderr, "talk: --lang: ungültiger Wert %q (erlaubt: de, en)\n", opts.lang)
fmt.Fprintf(stderr, "talk: --lang: invalid value %q (allowed: de, en)\n", opts.lang)
return exitFailure, true
}
if opts.priority != "" && !queue.Priority(opts.priority).Valid() {
fmt.Fprintf(stderr, "talk: --priority: ungültiger Wert %q (erlaubt: low, normal, high)\n", opts.priority)
fmt.Fprintf(stderr, "talk: --priority: invalid value %q (allowed: low, normal, high)\n", opts.priority)
return exitFailure, true
}
return 0, false
}
// isCharDevice meldet, ob r ein Terminal ist (dann darf talk nicht auf
// stdin-Input warten, sondern meldet einen Benutzungsfehler).
// isCharDevice reports whether r is a terminal (then talk must not wait for
// stdin input but report a usage error).
func isCharDevice(r io.Reader) bool {
f, ok := r.(*os.File)
if !ok {
+149 -146
View File
@@ -10,22 +10,21 @@ import (
"testing"
)
// setGetenv überschreibt die Env-Seam der CLI für einen Test. Grundaus-
// stattung jedes Tests: TALK_EDGE_ENDPOINT zeigt auf einen garantiert
// toten Loopback-Port (Verbindung wird sofort abgelehnt) — die Default-
// Kette enthält edge, und kein Test darf je den echten Microsoft-Endpunkt
// anfassen. Dasselbe gilt seit Task 04 für TALK_PORT: Die Daemon-Erkennung
// von say zeigt auf einen toten Port, damit die Tests hier Deterministisch
// den Direkt-Pfad laufen (und nie einen zufällig laufenden echten Daemon
// treffen); TALK_AUTOSTART=false verhindert, dass ein Test das Test-Binary
// selbst als Daemon spawnt. Wer einen dieser Ports bewusst anders braucht, überschreibt
// den Key.
// setGetenv overrides the CLI's environment seam for one test. Base kit of
// every test: TALK_EDGE_ENDPOINT points at a guaranteed dead loopback port
// (connection refused at once); the default chain contains edge, and no
// test may ever touch the real Microsoft endpoint. The same goes for
// TALK_PORT: daemon detection points at a dead port so the tests here run
// the direct path deterministically (and never hit a real daemon that
// happens to run); TALK_AUTOSTART=false prevents a test from spawning the
// test binary as a daemon. Whoever needs one of these different overrides
// the key.
func setGetenv(t *testing.T, kv map[string]string) {
t.Helper()
base := map[string]string{
"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/",
"TALK_PORT": "1",
"TALK_AUTOSTART": "false", // In-Prozess-Tests dürfen nie das Test-Binary als Daemon starten
"TALK_AUTOSTART": "false", // in-process tests must never start the test binary as a daemon
}
for k, v := range kv {
base[k] = v
@@ -40,9 +39,9 @@ func setGetenv(t *testing.T, kv map[string]string) {
t.Cleanup(func() { getenv = old })
}
// fakeTTSBin schreibt ein ausführbares Shell-Skript namens name in dir
// (gleicher Mechanismus wie im provider-Paket: echter Prozess, PATH-
// Manipulation, kein Mock-Framework).
// fakeTTSBin writes an executable shell script named name into dir (same
// mechanism as in the provider package: real process, PATH manipulation, no
// mock framework).
func fakeTTSBin(t *testing.T, dir, name, body string) {
t.Helper()
script := "#!/bin/sh\n" + body + "\n"
@@ -52,9 +51,8 @@ func fakeTTSBin(t *testing.T, dir, name, body string) {
}
}
// withFakeEspeak stellt ein fake espeak bereit, das WAV-Bytes auf stdout
// schreibt und den Aufruf in seen protokolliert. Zurück kommt der Pfad der
// Protokolldatei.
// withFakeEspeak provides a fake espeak that writes WAV bytes to stdout and
// records the call in seen. Returns the path of the record file.
func withFakeEspeak(t *testing.T) string {
t.Helper()
dir := t.TempDir()
@@ -69,79 +67,86 @@ cat "`+wav+`"`)
return seen
}
// TestSayArgumentMitNullSink: `talk "Hallo Welt"` läuft mit
// TALK_AUDIO_SINK=null tonlos durch (Abnahmekriterium) — Synthese läuft
// (fake espeak wurde gerufen), und die Wiedergabe wird mit sichtbarer
// Verwurf-Zeile auf stderr verworfen (extern beobachtbares Verhalten des
// null-Sinks auf dem CLI-Pfad).
func TestSayArgumentMitNullSink(t *testing.T) {
// TestSayArgumentWithNullSink: `talk "Hello world"` runs silently with
// TALK_AUDIO_SINK=null: synthesis happens (fake espeak was called) and
// playback is discarded with a visible line on stderr (observable behaviour
// of the null sink on the CLI path).
func TestSayArgumentWithNullSink(t *testing.T) {
seen := withFakeEspeak(t)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
code := runCLI([]string{"Hallo Welt"}, &out, &errBuf)
code := runCLI([]string{"Hello world"}, &out, &errBuf)
if code != exitOK {
t.Fatalf(`talk "Hallo Welt" (sink=null): Exit-Code = %d, want %d; stderr = %q`, code, exitOK, errBuf.String())
t.Fatalf(`talk "Hello world" (sink=null): exit code = %d, want %d; stderr = %q`, code, exitOK, errBuf.String())
}
if _, err := os.Stat(seen); err != nil {
t.Errorf("Synthese lief nicht (fake espeak nicht gerufen): %v", err)
t.Errorf("synthesis did not run (fake espeak not called): %v", err)
}
if !strings.Contains(errBuf.String(), "verworfen (null sink)") {
t.Errorf("stderr = %q, will Verwurf-Zeile des null-Sinks (Synthese war erfolgreich)", errBuf.String())
if !strings.Contains(errBuf.String(), "discarded (null sink)") {
t.Errorf("stderr = %q, want the null sink's discard line (synthesis succeeded)", errBuf.String())
}
}
// TestSaySubcommandWieDefault: `talk say "Text"` verhält sich wie der
// Default-Pfad.
func TestSaySubcommandWieDefault(t *testing.T) {
// TestSaySubcommandLikeDefault: `talk say "text"` behaves like the default
// path.
func TestSaySubcommandLikeDefault(t *testing.T) {
seen := withFakeEspeak(t)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
if code := runCLI([]string{"say", "Hallo"}, &out, &errBuf); code != exitOK {
t.Fatalf("talk say: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
if code := runCLI([]string{"say", "Hello"}, &out, &errBuf); code != exitOK {
t.Fatalf("talk say: exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
}
if _, err := os.Stat(seen); err != nil {
t.Errorf("fake espeak nicht gerufen: %v", err)
t.Errorf("fake espeak not called: %v", err)
}
}
// TestSayViaStdin: ohne Text-Argument wird stdin gelesen.
// TestSayViaStdin: without a text argument stdin is read.
func TestSayViaStdin(t *testing.T) {
withFakeEspeak(t)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
code := run([]string{}, strings.NewReader("Hallo Welt\n"), &out, &errBuf)
code := run([]string{}, strings.NewReader("Hello world\n"), &out, &errBuf)
if code != exitOK {
t.Fatalf("talk < stdin: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
t.Fatalf("talk < stdin: exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
}
}
// TestSaySprachwahl: englisches Argument übergibt -v en an den Provider,
// deutsches -v de; --lang überschreibt die Erkennung.
func TestSaySprachwahl(t *testing.T) {
// TestSayLanguageChoice: an English argument passes -v en to the provider, a
// German one -v de; undecidable text takes the configured fallback; --lang
// overrides detection.
func TestSayLanguageChoice(t *testing.T) {
cases := []struct {
name string
env map[string]string
argv []string
want string
}{
{"erkannt de", []string{"Der Build ist fertig"}, "-v de --stdout Der Build ist fertig"},
{"erkannt en", []string{"The build is done"}, "-v en --stdout The build is done"},
{"flag pinnt en", []string{"--lang=en", "Guten Morgen"}, "-v en --stdout Guten Morgen"},
{"detected de", nil, []string{"Der Build ist fertig"}, "-v de --stdout Der Build ist fertig"},
{"detected en", nil, []string{"The build is done"}, "-v en --stdout The build is done"},
{"undecidable → default en", nil, []string{"Build 42"}, "-v en --stdout Build 42"},
{"undecidable → configured de", map[string]string{"TALK_LANG": "de"}, []string{"Build 42"}, "-v de --stdout Build 42"},
{"flag pins en", nil, []string{"--lang=en", "Guten Morgen"}, "-v en --stdout Guten Morgen"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
seen := withFakeEspeak(t)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
kv := map[string]string{"TALK_AUDIO_SINK": "null"}
for k, v := range tc.env {
kv[k] = v
}
setGetenv(t, kv)
var out, errBuf bytes.Buffer
if code := runCLI(tc.argv, &out, &errBuf); code != exitOK {
t.Fatalf("Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("fake espeak nicht gerufen: %v", err)
t.Fatalf("fake espeak not called: %v", err)
}
if got := strings.TrimSpace(string(args)); got != tc.want {
t.Errorf("espeak ARGS = %q, want %q", got, tc.want)
@@ -150,135 +155,135 @@ func TestSaySprachwahl(t *testing.T) {
}
}
// TestSayOhneText: leeres stdin und kein Argument → klarer Fehler.
func TestSayOhneText(t *testing.T) {
// TestSayWithoutText: empty stdin and no argument is a clear error.
func TestSayWithoutText(t *testing.T) {
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
code := runCLI(nil, &out, &errBuf)
if code != exitFailure {
t.Fatalf("talk ohne Text: Exit-Code = %d, want %d", code, exitFailure)
t.Fatalf("talk without text: exit code = %d, want %d", code, exitFailure)
}
if !strings.Contains(errBuf.String(), "kein Text") {
t.Errorf("stderr = %q, will klaren Hinweis auf fehlenden Text", errBuf.String())
if !strings.Contains(errBuf.String(), "no text") {
t.Errorf("stderr = %q, want a clear note about the missing text", errBuf.String())
}
}
// TestSayUngueltigeSprache: --lang mit Unsinn ist ein klarer Fehler.
func TestSayUngueltigeSprache(t *testing.T) {
// TestSayInvalidLanguage: --lang with garbage is a clear error.
func TestSayInvalidLanguage(t *testing.T) {
withFakeEspeak(t)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
code := runCLI([]string{"--lang=fr", "Bonjour"}, &out, &errBuf)
if code != exitFailure {
t.Fatalf("talk --lang=fr: Exit-Code = %d, want %d", code, exitFailure)
t.Fatalf("talk --lang=fr: exit code = %d, want %d", code, exitFailure)
}
if !strings.Contains(errBuf.String(), "--lang") {
t.Errorf("stderr = %q, will --lang-Fehler", errBuf.String())
t.Errorf("stderr = %q, want a --lang error", errBuf.String())
}
}
// TestSayUnbekannterProvider: --provider mit unbekanntem Namen ist ein
// klarer Fehler, der die verfügbaren Provider nennt (Task 03: Kette).
func TestSayUnbekannterProvider(t *testing.T) {
// TestSayUnknownProvider: --provider with an unknown name is a clear error
// naming the available providers.
func TestSayUnknownProvider(t *testing.T) {
withFakeEspeak(t)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
code := runCLI([]string{"--provider=gibtsnicht", "Test"}, &out, &errBuf)
code := runCLI([]string{"--provider=nosuch", "Test"}, &out, &errBuf)
if code != exitFailure {
t.Fatalf("talk --provider=gibtsnicht: Exit-Code = %d, want %d", code, exitFailure)
t.Fatalf("talk --provider=nosuch: exit code = %d, want %d", code, exitFailure)
}
if !strings.Contains(errBuf.String(), "gibtsnicht") || !strings.Contains(errBuf.String(), "osbuiltin") || !strings.Contains(errBuf.String(), "edge") {
t.Errorf("stderr = %q, will Provider-Fehler mit verfügbaren Namen", errBuf.String())
if !strings.Contains(errBuf.String(), "nosuch") || !strings.Contains(errBuf.String(), "osbuiltin") || !strings.Contains(errBuf.String(), "edge") {
t.Errorf("stderr = %q, want a provider error with the available names", errBuf.String())
}
}
// TestSayUngueltigerSink: TALK_AUDIO_SINK=Müll ist ein klarer Fehler aus
// der Config-Validierung.
func TestSayUngueltigerSink(t *testing.T) {
// TestSayInvalidSink: TALK_AUDIO_SINK=garbage is a clear error from config
// validation.
func TestSayInvalidSink(t *testing.T) {
withFakeEspeak(t)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "mpv"})
var out, errBuf bytes.Buffer
code := runCLI([]string{"Test"}, &out, &errBuf)
if code != exitFailure {
t.Fatalf("talk mit TALK_AUDIO_SINK=mpv: Exit-Code = %d, want %d", code, exitFailure)
t.Fatalf("talk with TALK_AUDIO_SINK=mpv: exit code = %d, want %d", code, exitFailure)
}
if !strings.Contains(errBuf.String(), "audio.sink") {
t.Errorf("stderr = %q, will audio.sink-Validierungsfehler", errBuf.String())
t.Errorf("stderr = %q, want an audio.sink validation error", errBuf.String())
}
}
// TestSayOhneTTS: ohne jedes TTS-Binary ist Available false → klarer
// Fehler, kein Panik.
func TestSayOhneTTS(t *testing.T) {
// TestSayWithoutTTS: without any TTS binary Available is false; a clear
// error, no panic.
func TestSayWithoutTTS(t *testing.T) {
t.Setenv("PATH", t.TempDir())
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
code := runCLI([]string{"Hallo"}, &out, &errBuf)
code := runCLI([]string{"Hello"}, &out, &errBuf)
if code != exitFailure {
t.Fatalf("talk ohne TTS: Exit-Code = %d, want %d", code, exitFailure)
t.Fatalf("talk without TTS: exit code = %d, want %d", code, exitFailure)
}
if !strings.Contains(errBuf.String(), "nicht verfügbar") {
t.Errorf("stderr = %q, will Verfügbarkeits-Fehler", errBuf.String())
if !strings.Contains(errBuf.String(), "not available") {
t.Errorf("stderr = %q, want an availability error", errBuf.String())
}
}
// TestSayDirektesFallbeckenOhneEspeak: Linux ohne espeak (nur fake spd-say)
// und sink=null verwirft tonlos mit Exit 0 — das spd-say wird NICHT gerufen
// (null-Sink darf nie Ton erzeugen).
func TestSayDirektesFallbeckenOhneEspeak(t *testing.T) {
// TestSayDirectFallbackWithoutEspeak: Linux without espeak (only fake
// spd-say) and sink=null discards silently with exit 0; spd-say is NOT
// called (the null sink must never produce sound).
func TestSayDirectFallbackWithoutEspeak(t *testing.T) {
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeTTSBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
// PATH NUR auf das fake-spd-say-Verzeichnis: kein echtes espeak, kein
// echtes spd-say dazwischen.
// PATH ONLY the fake spd-say directory: no real espeak, no real spd-say.
t.Setenv("PATH", dir)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
code := runCLI([]string{"Hallo Welt"}, &out, &errBuf)
code := runCLI([]string{"Hello world"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk (Linux ohne espeak, sink=null): Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
t.Fatalf("talk (Linux without espeak, sink=null): exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
}
if _, err := os.Stat(seen); err == nil {
t.Error("spd-say wurde gerufen — null-Sink hätte die Ansage verworfen müssen")
t.Error("spd-say was called; the null sink should have discarded the announcement")
}
if !strings.Contains(errBuf.String(), "verworfen") {
t.Errorf("stderr = %q, will Verwurf-Hinweis", errBuf.String())
if !strings.Contains(errBuf.String(), "discarded") {
t.Errorf("stderr = %q, want a discard note", errBuf.String())
}
}
// TestSayDirektesFallbeckenMitOtoSink: ohne espeak, mit (fake) spd-say und
// dem Default-Sink oto muss die Direktansage über spd-say laufen.
func TestSayDirektesFallbeckenMitOtoSink(t *testing.T) {
// TestSayDirectFallbackWithOtoSink: without espeak, with (fake) spd-say and
// the default sink oto, direct speech must go through spd-say.
func TestSayDirectFallbackWithOtoSink(t *testing.T) {
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeTTSBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
t.Setenv("PATH", dir)
setGetenv(t, nil) // Default-Sink oto, keine Env-Overrides
setGetenv(t, nil) // default sink oto, no env overrides
var out, errBuf bytes.Buffer
code := runCLI([]string{"Hallo Welt"}, &out, &errBuf)
code := runCLI([]string{"Der Build ist fertig"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk (Direktansage): Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
t.Fatalf("talk (direct speech): exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("fake spd-say nicht gerufen: %v", err)
t.Fatalf("fake spd-say not called: %v", err)
}
if got := strings.TrimSpace(string(args)); got != "-w -l de Hallo Welt" {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt")
if got := strings.TrimSpace(string(args)); got != "-w -l de Der Build ist fertig" {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Der Build ist fertig")
}
}
// fakeCloudServer startet einen httptest-HTTP-Fake, der status mit body
// antwortet und den letzten Request aufzeichnet (Struktur wie im
// provider-Paket; die CLI-Tests beobachten nur extern: Exit-Code, stderr,
// ob der Fake gerufen wurde und mit welchem Pfad).
// fakeCloudReq records what the fake cloud endpoint saw.
type fakeCloudReq struct {
path string
apiKey string
}
// fakeCloudServer starts an httptest fake that answers status with body and
// records each request (structure as in the provider package; the CLI tests
// only observe externally: exit code, stderr, whether the fake was called
// and with which path).
func fakeCloudServer(t *testing.T, status int, body []byte) (url string, reqs chan fakeCloudReq) {
t.Helper()
reqs = make(chan fakeCloudReq, 8)
@@ -291,11 +296,11 @@ func fakeCloudServer(t *testing.T, status int, body []byte) (url string, reqs ch
return ts.URL, reqs
}
// TestSayCloudProviderMitFake: `talk "Text" --provider=elevenlabs` mit Key
// + Fake-Endpunkt und sink=null läuft mit Exit 0 durch — Synthese lief
// (Fake gerufen, korrekter Pfad inkl. neutraler Default-Stimme, Key im
// Header), Wiedergabe verworfen.
func TestSayCloudProviderMitFake(t *testing.T) {
// TestSayCloudProviderWithFake: `talk "text" --provider=elevenlabs` with a
// key, a fake endpoint and sink=null exits 0: synthesis ran (fake called,
// correct path with the neutral default voice, key in the header), playback
// discarded.
func TestSayCloudProviderWithFake(t *testing.T) {
url, reqs := fakeCloudServer(t, 200, []byte("\xff\xfb\x90\x00mp3"))
setGetenv(t, map[string]string{
"TALK_AUDIO_SINK": "null",
@@ -306,26 +311,26 @@ func TestSayCloudProviderMitFake(t *testing.T) {
var out, errBuf bytes.Buffer
code := runCLI([]string{"--provider=elevenlabs", "Test"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk --provider=elevenlabs (Fake): Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
t.Fatalf("talk --provider=elevenlabs (fake): exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
}
select {
case req := <-reqs:
if want := "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM"; req.path != want {
t.Errorf("Pfad = %q, want %q (neutrales Default)", req.path, want)
t.Errorf("path = %q, want %q (neutral default)", req.path, want)
}
if req.apiKey != "cli-testkey" {
t.Errorf("xi-api-key = %q", req.apiKey)
}
default:
t.Error("Fake-Endpunkt wurde nicht gerufen")
t.Error("fake endpoint was not called")
}
if !strings.Contains(errBuf.String(), "verworfen (null sink)") {
t.Errorf("stderr = %q, will Verwurf-Zeile", errBuf.String())
if !strings.Contains(errBuf.String(), "discarded (null sink)") {
t.Errorf("stderr = %q, want the discard line", errBuf.String())
}
}
// TestSayVoiceFlagAmCloudProvider: --voice landet im Request-Pfad.
func TestSayVoiceFlagAmCloudProvider(t *testing.T) {
// TestSayVoiceFlagAtCloudProvider: --voice lands in the request path.
func TestSayVoiceFlagAtCloudProvider(t *testing.T) {
url, reqs := fakeCloudServer(t, 200, []byte("\xff\xfb\x90\x00mp3"))
setGetenv(t, map[string]string{
"TALK_AUDIO_SINK": "null",
@@ -334,43 +339,42 @@ func TestSayVoiceFlagAmCloudProvider(t *testing.T) {
})
var out, errBuf bytes.Buffer
if code := runCLI([]string{"--provider=elevenlabs", "--voice=MeineStimme", "Test"}, &out, &errBuf); code != exitOK {
t.Fatalf("Exit-Code = %d, stderr = %q", code, errBuf.String())
if code := runCLI([]string{"--provider=elevenlabs", "--voice=MyVoice", "Test"}, &out, &errBuf); code != exitOK {
t.Fatalf("exit code = %d, stderr = %q", code, errBuf.String())
}
select {
case req := <-reqs:
if want := "/v1/text-to-speech/MeineStimme"; req.path != want {
t.Errorf("Pfad = %q, want %q", req.path, want)
if want := "/v1/text-to-speech/MyVoice"; req.path != want {
t.Errorf("path = %q, want %q", req.path, want)
}
default:
t.Error("Fake-Endpunkt wurde nicht gerufen")
t.Error("fake endpoint was not called")
}
}
// TestSayCloudOhneKey: gepinnter Cloud-Provider ohne Key → klarer Fehler
// (Stufe nicht verfügbar, Exit 1).
func TestSayCloudOhneKey(t *testing.T) {
// TestSayCloudWithoutKey: a pinned cloud provider without a key is a clear
// error (tier not available, exit 1).
func TestSayCloudWithoutKey(t *testing.T) {
url, _ := fakeCloudServer(t, 200, []byte("x"))
setGetenv(t, map[string]string{
"TALK_AUDIO_SINK": "null",
"TALK_ELEVENLABS_ENDPOINT": url, // Endpunkt, aber kein Key
"TALK_ELEVENLABS_ENDPOINT": url, // endpoint, but no key
})
var out, errBuf bytes.Buffer
code := runCLI([]string{"--provider=elevenlabs", "Test"}, &out, &errBuf)
if code != exitFailure {
t.Fatalf("Exit-Code = %d, want %d", code, exitFailure)
t.Fatalf("exit code = %d, want %d", code, exitFailure)
}
if !strings.Contains(errBuf.String(), "nicht verfügbar") {
t.Errorf("stderr = %q, will Verfügbarkeits-Hinweis", errBuf.String())
if !strings.Contains(errBuf.String(), "not available") {
t.Errorf("stderr = %q, want an availability note", errBuf.String())
}
}
// TestSayCloud500FaelltAufOsbuiltin: gepinnt wäre kurzfristig — hier ohne
// Pin: elevenlabs (Fake, 500) → openai (kein Key) → edge (tot) → osbuiltin
// (fake espeak) liefert die Bytes. Der CLI-Ketten-Fallback ist end-to-end
// über die Env-Seam beobachtbar.
func TestSayCloud500FaelltAufOsbuiltin(t *testing.T) {
// TestSayCloud500FallsBackToOsbuiltin: without a pin: elevenlabs (fake, 500)
// → openai (no key) → edge (dead) → osbuiltin (fake espeak) yields the
// bytes. The CLI chain fallback is observable end to end via the env seam.
func TestSayCloud500FallsBackToOsbuiltin(t *testing.T) {
url, _ := fakeCloudServer(t, 500, []byte("boom"))
seen := withFakeEspeak(t)
setGetenv(t, map[string]string{
@@ -380,22 +384,21 @@ func TestSayCloud500FaelltAufOsbuiltin(t *testing.T) {
})
var out, errBuf bytes.Buffer
code := runCLI([]string{"Ketten Test"}, &out, &errBuf)
code := runCLI([]string{"Chain test"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk mit 500-Cloud-Stufe: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
t.Fatalf("talk with a 500 cloud tier: exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
}
if _, err := os.Stat(seen); err != nil {
t.Errorf("fake espeak nicht gerufen (Kette nicht bis osbuiltin gefallen): %v", err)
t.Errorf("fake espeak not called (chain did not fall through to osbuiltin): %v", err)
}
if !strings.Contains(errBuf.String(), "500") || !strings.Contains(errBuf.String(), "Fallback") {
t.Errorf("stderr = %q, will sichtbaren 500-Fallback", errBuf.String())
if !strings.Contains(errBuf.String(), "500") || !strings.Contains(errBuf.String(), "falling back") {
t.Errorf("stderr = %q, want a visible 500 fallback", errBuf.String())
}
}
// TestSayFlagNachDemText: `talk "Text" --provider=…` (Flag NACH dem Text,
// Abnahme-Aufrufform) pinnt den Provider — nur die gepinnte Stufe wird
// angefragt.
func TestSayFlagNachDemText(t *testing.T) {
// TestSayFlagAfterText: `talk "text" --provider=…` (flag AFTER the text)
// pins the provider: only the pinned tier is asked.
func TestSayFlagAfterText(t *testing.T) {
url, reqs := fakeCloudServer(t, 200, []byte("\xff\xfb\x90\x00mp3"))
setGetenv(t, map[string]string{
"TALK_AUDIO_SINK": "null",
@@ -404,30 +407,30 @@ func TestSayFlagNachDemText(t *testing.T) {
})
var out, errBuf bytes.Buffer
code := runCLI([]string{"Test ansage", "--provider=elevenlabs"}, &out, &errBuf)
code := runCLI([]string{"Test announcement", "--provider=elevenlabs"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk \"Text\" --provider=elevenlabs: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
t.Fatalf("talk \"text\" --provider=elevenlabs: exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
}
select {
case req := <-reqs:
if !strings.Contains(req.path, "/v1/text-to-speech/") {
t.Errorf("Pfad = %q, want TTS-Pfad", req.path)
t.Errorf("path = %q, want a TTS path", req.path)
}
default:
t.Error("Fake-Endpunkt wurde nicht gerufen")
t.Error("fake endpoint was not called")
}
// Zwei Argumente mit verteilten Flags: Text bleibt vollständig.
// Two arguments with interspersed flags: the text stays complete.
seen := withFakeEspeak(t)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
code = runCLI([]string{"Erster", "--lang=en", "zweiter Teil"}, &out, &errBuf)
code = runCLI([]string{"First", "--lang=en", "second part"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk Erster --lang=en zweiter Teil: Exit-Code = %d, stderr = %q", code, errBuf.String())
t.Fatalf("talk First --lang=en second part: exit code = %d, stderr = %q", code, errBuf.String())
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("fake espeak nicht gerufen: %v", err)
t.Fatalf("fake espeak not called: %v", err)
}
if got := strings.TrimSpace(string(args)); got != "-v en --stdout Erster zweiter Teil" {
t.Errorf("espeak ARGS = %q, want %q", got, "-v en --stdout Erster zweiter Teil")
if got := strings.TrimSpace(string(args)); got != "-v en --stdout First second part" {
t.Errorf("espeak ARGS = %q, want %q", got, "-v en --stdout First second part")
}
}
+147 -106
View File
@@ -21,64 +21,65 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/speak"
)
// setupInteractive entscheidet, ob der Wizard Fragen stellen darf (stdin
// ist ein Terminal). Tests überschreiben die Seam und füttern Antworten
// über stdin.
// setupInteractive decides whether the wizard may ask questions (stdin is a
// terminal). Tests override the seam and feed answers via stdin.
var setupInteractive = isCharDevice
// setupSample ist der Probehör-Satz je Sprache.
// setupSample is the preview sentence per language.
var setupSample = map[string]string{
"de": "Hallo, so klinge ich. Build fertig.",
"en": "Hello, this is how I sound. Build finished.",
}
// runSetup implementiert `talk setup`: wenige Fragen, dann config.toml.
// runSetup implements `talk setup`: a few questions, then config.toml.
//
// 1. Daemon/Version, Audio.
// 2. Keys: gefunden → Premium; keiner → Erklärung, dass die kostenlose
// Edge-Stimme (Internet) bzw. die OS-Stimme ohnehin funktionieren.
// 3. Stimme je Sprache (de, en) für die aktive Stufe: Nummer aus dem
// Katalog wählen, probehören, übernehmen.
// 4. Ruhezeit (optional).
// 5. config.toml schreiben (bestehende zusammenführen, .bak), Test-Satz.
// 1. Daemon, audio.
// 2. Keys: found → premium; none → explanation that the free Edge voice
// (internet) or the OS voice work regardless.
// 3. Language: which language talk assumes when it cannot tell (and speaks
// its own announcements in).
// 4. Voice per language (de, en) for the active tier: pick a catalog
// number, preview, accept.
// 5. Quiet hours (optional).
// 6. Write config.toml (merge an existing one, .bak), test sentence.
//
// Ohne Terminal (Pipe, Agent) stellt setup keine Fragen: Es zeigt die
// wirksame Konfiguration und endet mit Exit 0 — nichts bricht, nichts
// wartet. Abbruch mit Ctrl-C oder leerer Eingabe bei „q": nichts
// halbfertig geschrieben (geschrieben wird erst ganz am Ende).
// Without a terminal (pipe, agent) setup asks nothing: it shows the
// effective configuration and exits 0; nothing breaks, nothing waits.
// Abort with Ctrl-C or "q": nothing half-written (the file is written only
// at the very end).
func runSetup(args []string, _ *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
if len(args) > 0 {
fmt.Fprintln(stderr, "talk setup: keine Argumente erwartet")
fmt.Fprintln(stderr, "talk setup: no arguments expected")
return exitUsage
}
cfg, err := config.Load(config.Options{Getenv: getenv})
if err != nil {
fmt.Fprintf(stderr, "talk setup: %v\nBitte zuerst die Config-Datei reparieren oder umbenennen.\n", err)
fmt.Fprintf(stderr, "talk setup: %v\nPlease fix or rename the config file first.\n", err)
return exitFailure
}
path, perr := config.Path(getenv)
if perr != nil {
fmt.Fprintf(stderr, "talk setup: %v — ohne Config-Verzeichnis kann setup nichts speichern\n", perr)
fmt.Fprintf(stderr, "talk setup: %v; without a config directory setup cannot save anything\n", perr)
return exitFailure
}
if !setupInteractive(stdin) {
fmt.Fprintln(stdout, "talk setup braucht ein Terminal, um Fragen zu stellen. Wirksame Konfiguration:")
fmt.Fprintln(stdout, "talk setup needs a terminal to ask questions. Effective configuration:")
printEffective(stdout, cfg, path)
return exitOK
}
w := &wizard{in: bufio.NewReader(stdin), out: stdout, err: stderr, cfg: cfg}
fmt.Fprintln(stdout, "talk einrichten — ein paar Fragen, Enter übernimmt den Vorschlag in [Klammern], q bricht ab.")
fmt.Fprintln(stdout, "Setting up talk: a few questions. Enter takes the suggestion in [brackets], q aborts.")
fmt.Fprintln(stdout)
// 1. Umgebung
fmt.Fprintf(stdout, "Audio-Ausgabe: %s\n", audioSummary(cfg))
// 1. Environment
fmt.Fprintf(stdout, "Audio output: %s\n", audioSummary(cfg))
client := api.NewClient(cfg.Server.Port)
if daemonAlive(client, daemonProbeTimeout) {
fmt.Fprintf(stdout, "Daemon: läuft unter %s\n", client.BaseURL)
fmt.Fprintf(stdout, "Daemon: running at %s\n", client.BaseURL)
} else {
fmt.Fprintln(stdout, "Daemon: startet beim ersten Sprechbefehl von selbst")
fmt.Fprintln(stdout, "Daemon: starts by itself on the first speak command")
}
// 2. Keys
@@ -91,83 +92,98 @@ func runSetup(args []string, _ *sayOptions, stdin io.Reader, stdout, stderr io.W
fmt.Fprintln(stdout)
switch {
case getenv(provider.EnvElevenLabsKey) != "":
fmt.Fprintln(stdout, "Stimmen: ElevenLabs-Key gefunden (TALK_ELEVENLABS_KEY) — Premium-Stimme.")
fmt.Fprintln(stdout, "Voices: ElevenLabs key found (TALK_ELEVENLABS_KEY); premium voice.")
case getenv(provider.EnvOpenAIKey) != "":
fmt.Fprintln(stdout, "Stimmen: OpenAI-Key gefunden (TALK_OPENAI_KEY).")
fmt.Fprintln(stdout, "Voices: OpenAI key found (TALK_OPENAI_KEY).")
default:
fmt.Fprintln(stdout, "Stimmen: kein Key gefunden — das ist ok. talk nutzt die kostenlose Edge-Stimme von")
fmt.Fprintln(stdout, " Microsoft (braucht Internet) und sonst die Stimme deines Betriebssystems.")
fmt.Fprintln(stdout, " Für Premium-Stimmen später TALK_ELEVENLABS_KEY oder TALK_OPENAI_KEY als")
fmt.Fprintln(stdout, " Umgebungsvariable setzen (nie in die config.toml).")
fmt.Fprintln(stdout, "Voices: no key found; that is fine. talk uses Microsoft's free Edge voice")
fmt.Fprintln(stdout, " (needs internet) and otherwise your operating system's voice.")
fmt.Fprintln(stdout, " For premium voices, set TALK_ELEVENLABS_KEY or TALK_OPENAI_KEY as an")
fmt.Fprintln(stdout, " environment variable later (never in config.toml).")
}
if active == speak.NoProvider {
fmt.Fprintln(stderr, "talk setup: derzeit ist keine Stimme verfügbar — `talk doctor` zeigt, was fehlt. Einstellungen werden trotzdem gespeichert.")
fmt.Fprintln(stderr, "talk setup: no voice is available right now; `talk doctor` shows what is missing. Settings are saved anyway.")
} else {
fmt.Fprintf(stdout, "Aktive Stufe: %s\n", active)
fmt.Fprintf(stdout, "Active tier: %s\n", active)
}
// 3. Stimmen
voices := map[string]string{}
if active != speak.NoProvider && active != "osbuiltin" {
for _, lang := range []string{"de", "en"} {
id, ok := w.chooseVoice(engine, active, lang)
if !ok {
fmt.Fprintln(stdout, "Abgebrochen — nichts geschrieben.")
return exitOK
}
if id != "" {
voices[lang] = id
}
}
} else if active == "osbuiltin" {
fmt.Fprintln(stdout, "\nDie OS-Stimme wählt dein Betriebssystem; es gibt hier nichts einzustellen.")
}
// 4. Ruhezeit
// 3. Language
fmt.Fprintln(stdout)
quiet, ok := w.askQuietHours(cfg.QuietHours)
lang, ok := w.askLang(cfg.Lang)
if !ok {
fmt.Fprintln(stdout, "Abgebrochen — nichts geschrieben.")
fmt.Fprintln(stdout, "Aborted; nothing written.")
return exitOK
}
// 5. Schreiben
changes := map[string]any{}
// 4. Voices
voices := map[string]string{}
if active != speak.NoProvider && active != "osbuiltin" {
for _, l := range []string{lang, otherLang(lang)} {
id, ok := w.chooseVoice(engine, active, l)
if !ok {
fmt.Fprintln(stdout, "Aborted; nothing written.")
return exitOK
}
if id != "" {
voices[l] = id
}
}
} else if active == "osbuiltin" {
fmt.Fprintln(stdout, "\nThe OS voice is chosen by your operating system; nothing to set here.")
}
// 5. Quiet hours
fmt.Fprintln(stdout)
quiet, ok := w.askQuietHours(cfg.QuietHours)
if !ok {
fmt.Fprintln(stdout, "Aborted; nothing written.")
return exitOK
}
// 6. Write
changes := map[string]any{"lang": lang, "quiet_hours": quiet}
if len(voices) > 0 {
changes["voice"] = map[string]any{active: toAny(voices)}
}
changes["quiet_hours"] = quiet
backup, err := writeConfig(path, changes)
if err != nil {
fmt.Fprintf(stderr, "talk setup: config schreiben: %v\n", err)
fmt.Fprintf(stderr, "talk setup: writing config: %v\n", err)
return exitFailure
}
fmt.Fprintln(stdout)
fmt.Fprintf(stdout, "Gespeichert: %s\n", path)
fmt.Fprintf(stdout, "Saved: %s\n", path)
if backup != "" {
fmt.Fprintf(stdout, "Sicherung: %s\n", backup)
fmt.Fprintf(stdout, "Backup: %s\n", backup)
}
// Abschluss: Testsatz über die neue Config.
// Finish: a test sentence through the new config.
if active != speak.NoProvider {
fmt.Fprintln(stdout, "\nTestsatz …")
fmt.Fprintln(stdout, "\nTest sentence …")
if cfg2, err := config.Load(config.Options{Getenv: getenv}); err == nil {
if eng2, err := speak.New(cfg2, getenv, io.Discard); err == nil {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if out, err := eng2.Speak(ctx, speak.Request{Text: testPhrase["de"], Lang: "de"}); err != nil {
fmt.Fprintf(stderr, "talk setup: Testsatz fehlgeschlagen: %v (siehe talk doctor)\n", err)
if out, err := eng2.Speak(ctx, speak.Request{Text: testPhrase[lang], Lang: lang}); err != nil {
fmt.Fprintf(stderr, "talk setup: test sentence failed: %v (see talk doctor)\n", err)
} else {
fmt.Fprintf(stdout, "Gesprochen über %s.\n", out.Provider)
fmt.Fprintf(stdout, "Spoken via %s.\n", out.Provider)
}
}
}
}
fmt.Fprintln(stdout, "\nFertig. Probiere: talk \"Hallo Welt\" — Status: talk status — Stumm: talk mute")
fmt.Fprintln(stdout, "\nDone. Try: talk \"Hello world\" / status: talk status / silence: talk mute")
return exitOK
}
// wizard hält Ein-/Ausgabe des Setups.
// otherLang returns the other supported language.
func otherLang(lang string) string {
if lang == "de" {
return "en"
}
return "de"
}
// wizard holds the setup's input and output.
type wizard struct {
in *bufio.Reader
out io.Writer
@@ -175,8 +191,8 @@ type wizard struct {
cfg config.Config
}
// ask stellt eine Frage mit Vorschlag; Enter liefert den Vorschlag, „q"
// bricht ab (ok=false). EOF zählt als Abbruch.
// ask asks a question with a suggestion; Enter returns the suggestion, "q"
// aborts (ok=false). EOF counts as abort.
func (w *wizard) ask(prompt, def string) (string, bool) {
if def != "" {
fmt.Fprintf(w.out, "%s [%s]: ", prompt, def)
@@ -197,12 +213,27 @@ func (w *wizard) ask(prompt, def string) (string, bool) {
return line, true
}
// chooseVoice lässt eine Stimme für provider/lang wählen: nummerierte
// Katalog-Liste, aktueller Default markiert; Probehören; übernehmen oder
// neu wählen. Rückgabe: gewählte ID ("" = Default behalten) und ok=false
// bei Abbruch.
// askLang asks for the fallback language (de | en).
func (w *wizard) askLang(current string) (string, bool) {
fmt.Fprintln(w.out, "Language: talk detects German and English automatically. Which one should it assume when it cannot tell?")
for {
answer, ok := w.ask("Language (de/en)", current)
if !ok {
return "", false
}
answer = strings.ToLower(answer)
if answer == "de" || answer == "en" {
return answer, true
}
fmt.Fprintln(w.out, "Please answer de or en.")
}
}
// chooseVoice lets the user pick a voice for provider/lang: numbered catalog
// list with the current default marked; preview; accept or choose again.
// Returns the chosen ID ("" = keep the default) and ok=false on abort.
func (w *wizard) chooseVoice(engine *speak.Engine, prov, lang string) (string, bool) {
langName := map[string]string{"de": "Deutsch", "en": "Englisch"}[lang]
langName := map[string]string{"de": "German", "en": "English"}[lang]
current := provider.DefaultVoice(prov, lang, w.cfg.Voice)
var list []provider.Voice
for _, v := range provider.Catalog() {
@@ -210,7 +241,7 @@ func (w *wizard) chooseVoice(engine *speak.Engine, prov, lang string) (string, b
list = append(list, v)
}
}
fmt.Fprintf(w.out, "\nStimme für %s (%s):\n", langName, prov)
fmt.Fprintf(w.out, "\nVoice for %s (%s):\n", langName, prov)
defIdx := ""
for i, v := range list {
mark := " "
@@ -221,17 +252,17 @@ func (w *wizard) chooseVoice(engine *speak.Engine, prov, lang string) (string, b
fmt.Fprintf(w.out, " %s %d) %s\n", mark, i+1, v.Name)
}
if defIdx == "" && current != "" {
fmt.Fprintf(w.out, " * aktuell: %s (aus config.toml)\n", current)
fmt.Fprintf(w.out, " * current: %s (from config.toml)\n", current)
}
for {
answer, ok := w.ask("Nummer wählen, oder eine Voice-ID eintippen", defIdx)
answer, ok := w.ask("Pick a number, or type a voice ID", defIdx)
if !ok {
return "", false
}
id := answer
if n, err := strconv.Atoi(answer); err == nil {
if n < 1 || n > len(list) {
fmt.Fprintf(w.out, "Bitte 1–%d.\n", len(list))
fmt.Fprintf(w.out, "Please pick 1 to %d.\n", len(list))
continue
}
id = list[n-1].ID
@@ -239,63 +270,72 @@ func (w *wizard) chooseVoice(engine *speak.Engine, prov, lang string) (string, b
if id == "" {
return "", true
}
hear, ok := w.ask("Probehören?", "J")
hear, ok := w.ask("Preview?", "Y")
if !ok {
return "", false
}
if strings.EqualFold(hear, "j") || strings.EqualFold(hear, "ja") || strings.EqualFold(hear, "y") {
if isYes(hear) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
_, err := engine.Speak(ctx, speak.Request{Text: setupSample[lang], Lang: lang, Provider: prov, Voice: id})
cancel()
if err != nil {
fmt.Fprintf(w.err, "Probehören fehlgeschlagen: %v\n", err)
fmt.Fprintf(w.err, "preview failed: %v\n", err)
}
}
take, ok := w.ask("Übernehmen?", "J")
take, ok := w.ask("Use this voice?", "Y")
if !ok {
return "", false
}
if strings.EqualFold(take, "j") || strings.EqualFold(take, "ja") || strings.EqualFold(take, "y") {
if isYes(take) {
if id == current {
return "", true // Default behalten — nichts in die Datei schreiben
return "", true // keep the default: nothing to write
}
return id, true
}
}
}
// askQuietHours fragt das Ruhezeit-Fenster ab (leer = keine).
// isYes accepts y/yes/j/ja in any case.
func isYes(s string) bool {
switch strings.ToLower(s) {
case "y", "yes", "j", "ja":
return true
}
return false
}
// askQuietHours asks for the quiet window (empty = none).
func (w *wizard) askQuietHours(current string) (string, bool) {
fmt.Fprintln(w.out, "Ruhezeit: In diesem Fenster bleibt talk still (z. B. 22:00-07:00). Leer lassen = keine Ruhezeit.")
fmt.Fprintln(w.out, "Quiet hours: talk stays silent in this window (for example 22:00-07:00). Leave empty for none.")
def := current
if def == "" {
def = "keine"
def = "none"
}
for {
answer, ok := w.ask("Ruhezeit", def)
answer, ok := w.ask("Quiet hours", def)
if !ok {
return "", false
}
if answer == "keine" || answer == "-" || answer == "aus" {
if answer == "none" || answer == "-" || answer == "off" {
return "", true
}
if _, err := queue.ParseQuietHours(answer); err != nil {
fmt.Fprintf(w.out, "Format HH:MM-HH:MM, z. B. 22:00-07:00 (%v).\n", err)
fmt.Fprintf(w.out, "Format HH:MM-HH:MM, for example 22:00-07:00 (%v).\n", err)
continue
}
return answer, true
}
}
// writeConfig führt changes in die bestehende config.toml ein (fehlende
// Datei: neu) und schreibt sie atomar; eine vorhandene Datei wird vorher
// als .bak gesichert. Verschachtelte Tabellen (voice.<provider>) werden
// zusammengeführt, nicht ersetzt. Rückgabe: Pfad der Sicherung oder "".
// writeConfig merges changes into the existing config.toml (missing file:
// new) and writes it atomically; an existing file is backed up as .bak
// first. Nested tables (voice.<provider>) are merged, not replaced. Returns
// the backup path or "".
func writeConfig(path string, changes map[string]any) (backup string, err error) {
existing := map[string]any{}
if raw, rerr := os.ReadFile(path); rerr == nil {
if _, derr := toml.Decode(string(raw), &existing); derr != nil {
return "", fmt.Errorf("bestehende %s unlesbar: %w", path, derr)
return "", fmt.Errorf("existing %s unreadable: %w", path, derr)
}
backup = path + ".bak"
if werr := os.WriteFile(backup, raw, 0o644); werr != nil {
@@ -305,12 +345,12 @@ func writeConfig(path string, changes map[string]any) (backup string, err error)
return "", rerr
}
mergeInto(existing, changes)
// Leere Ruhezeit heißt: Schlüssel raus, statt "" zu schreiben.
// Empty quiet hours means: drop the key rather than writing "".
if v, ok := existing["quiet_hours"]; ok && v == "" {
delete(existing, "quiet_hours")
}
var buf bytes.Buffer
buf.WriteString("# talk — geschrieben von `talk setup`. Keys gehören NICHT hierher (TALK_*-Umgebungsvariablen).\n\n")
buf.WriteString("# talk: written by `talk setup`. Keys do NOT belong here (TALK_* environment variables).\n\n")
if err := toml.NewEncoder(&buf).Encode(existing); err != nil {
return "", err
}
@@ -328,7 +368,7 @@ func writeConfig(path string, changes map[string]any) (backup string, err error)
return backup, nil
}
// mergeInto schreibt src rekursiv in dst (Tabellen zusammenführen).
// mergeInto writes src recursively into dst (merging tables).
func mergeInto(dst, src map[string]any) {
for k, v := range src {
if sv, ok := v.(map[string]any); ok {
@@ -349,27 +389,28 @@ func toAny(m map[string]string) map[string]any {
return out
}
// printEffective zeigt die wirksame Konfiguration (Nicht-Terminal-Pfad).
// printEffective shows the effective configuration (non-terminal path).
func printEffective(w io.Writer, cfg config.Config, path string) {
fmt.Fprintf(w, " config: %s\n", path)
fmt.Fprintf(w, " audio: %s\n", audioSummary(cfg))
fmt.Fprintf(w, " provider: %s\n", orDefault(cfg.Provider, "Kette "+strings.Join(provider.DefaultOrder, " → ")))
fmt.Fprintf(w, " config: %s\n", path)
fmt.Fprintf(w, " audio: %s\n", audioSummary(cfg))
fmt.Fprintf(w, " language: %s (fallback)\n", cfg.Lang)
fmt.Fprintf(w, " provider: %s\n", orDefault(cfg.Provider, "chain "+strings.Join(provider.DefaultOrder, " → ")))
for _, p := range provider.DefaultOrder {
for _, lang := range []string{"de", "en"} {
if id := cfg.Voice[p][lang]; id != "" {
fmt.Fprintf(w, " voice: %s/%s = %s\n", p, lang, id)
fmt.Fprintf(w, " voice: %s/%s = %s\n", p, lang, id)
}
}
}
fmt.Fprintf(w, " ruhezeit: %s\n", orDefault(cfg.QuietHours, "keine"))
fmt.Fprintf(w, " autostart: %v\n", cfg.Daemon.Autostart)
fmt.Fprintf(w, " quiet hours: %s\n", orDefault(cfg.QuietHours, "none"))
fmt.Fprintf(w, " autostart: %v\n", cfg.Daemon.Autostart)
}
func audioSummary(cfg config.Config) string {
if cfg.Audio.Sink == config.SinkNull {
return "null (kein Ton — CI/Server)"
return "null (no sound; CI/servers)"
}
return "eingebaute Wiedergabe (oto)"
return "built-in playback (oto)"
}
func orDefault(v, def string) string {
+47 -43
View File
@@ -16,9 +16,9 @@ import (
func readFile(path string) ([]byte, error) { return os.ReadFile(path) }
// setupEnv stellt den Wizard auf „interaktiv“ und pinnt die Kette auf einen
// Fake-ElevenLabs-Endpunkt (sink null) — Probehören und Testsatz laufen
// gegen den Fake. Rückgabe: Zähler der Synthese-Aufrufe.
// setupEnv makes the wizard "interactive" and pins the chain to a fake
// ElevenLabs endpoint (sink null): preview and test sentence hit the fake.
// Returns the counter of synthesis calls.
func setupEnv(t *testing.T, root string, extra map[string]string) *atomic.Int32 {
t.Helper()
var calls atomic.Int32
@@ -44,52 +44,56 @@ func setupEnv(t *testing.T, root string, extra map[string]string) *atomic.Int32
return &calls
}
// TestSetupSchreibtConfig: Antworten über stdin — Stimme 2 für Deutsch
// (probehören, übernehmen), Englisch Default behalten, Ruhezeit setzen —
// ergeben eine config.toml mit genau diesen Werten; Probehören und
// Testsatz treffen den Fake.
func TestSetupSchreibtConfig(t *testing.T) {
// TestSetupWritesConfig: answers via stdin (language en; voice 2 for
// English, preview, accept; German keeps the default; quiet hours) yield a
// config.toml with exactly those values; preview and test sentence hit the
// fake.
func TestSetupWritesConfig(t *testing.T) {
root := t.TempDir()
calls := setupEnv(t, root, nil)
stdin := strings.NewReader("2\nj\nj\n\nn\nj\n22:00-07:00\n")
stdin := strings.NewReader("en\n2\ny\ny\n\nn\ny\n22:00-07:00\n")
var out, errBuf bytes.Buffer
code := run([]string{"setup"}, stdin, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk setup: Exit %d\nstdout: %s\nstderr: %s", code, out.String(), errBuf.String())
t.Fatalf("talk setup: exit %d\nstdout: %s\nstderr: %s", code, out.String(), errBuf.String())
}
path := filepath.Join(root, "talk", "config.toml")
var got struct {
Lang string `toml:"lang"`
QuietHours string `toml:"quiet_hours"`
Voice map[string]map[string]string `toml:"voice"`
}
if _, err := toml.DecodeFile(path, &got); err != nil {
t.Fatalf("config lesen: %v", err)
t.Fatalf("reading config: %v", err)
}
if got.Lang != "en" {
t.Errorf("lang = %q, want en", got.Lang)
}
if got.QuietHours != "22:00-07:00" {
t.Errorf("quiet_hours = %q", got.QuietHours)
}
if got.Voice["elevenlabs"]["de"] != "pNInz6obpgDQGcFmaJgB" { // Nr. 2 = Adam
t.Errorf("voice.elevenlabs.de = %q, want Adam", got.Voice["elevenlabs"]["de"])
if got.Voice["elevenlabs"]["en"] != "pNInz6obpgDQGcFmaJgB" { // no. 2 = Adam
t.Errorf("voice.elevenlabs.en = %q, want Adam", got.Voice["elevenlabs"]["en"])
}
if _, ok := got.Voice["elevenlabs"]["en"]; ok {
t.Errorf("voice.elevenlabs.en gesetzt, obwohl Default behalten wurde: %v", got.Voice)
if _, ok := got.Voice["elevenlabs"]["de"]; ok {
t.Errorf("voice.elevenlabs.de set although the default was kept: %v", got.Voice)
}
if n := calls.Load(); n != 2 { // 1× Probehören de + 1× Testsatz
t.Errorf("Fake-Aufrufe = %d, want 2 (Probehören + Testsatz)", n)
if n := calls.Load(); n != 2 { // 1× preview en + 1× test sentence
t.Errorf("fake calls = %d, want 2 (preview + test sentence)", n)
}
if _, err := os.Stat(path + ".bak"); !os.IsNotExist(err) {
t.Errorf("Sicherung ohne Vorgänger-Datei angelegt")
t.Errorf("backup created without a previous file")
}
if !strings.Contains(out.String(), "Gespeichert") || !strings.Contains(out.String(), "Gesprochen über elevenlabs") {
if !strings.Contains(out.String(), "Saved") || !strings.Contains(out.String(), "Spoken via elevenlabs") {
t.Errorf("stdout = %q", out.String())
}
}
// TestSetupMergtBestehendeConfig: Vorhandene Werte (anderer Port, andere
// Provider-Stimme) bleiben erhalten, es gibt eine .bak, und „keine“ löscht
// eine vorhandene Ruhezeit.
func TestSetupMergtBestehendeConfig(t *testing.T) {
// TestSetupMergesExistingConfig: existing values (other port, another
// provider's voice) survive, there is a .bak, and "none" removes an existing
// quiet window.
func TestSetupMergesExistingConfig(t *testing.T) {
root := t.TempDir()
setupEnv(t, root, nil)
dir := filepath.Join(root, "talk")
@@ -101,63 +105,63 @@ func TestSetupMergtBestehendeConfig(t *testing.T) {
if err := os.WriteFile(path, []byte(orig), 0o644); err != nil {
t.Fatal(err)
}
// Das Setup selbst muss auf den Default-Port 7800 zeigen, nicht 7900 aus
// der Datei — sonst würde die Daemon-Erkennung einen fremden Port
// anfassen. TALK_PORT=1 aus setGetenv schlägt die Datei.
stdin := strings.NewReader("1\nn\nj\n1\nn\nj\nkeine\n")
// The setup itself must not touch port 7900 from the file: TALK_PORT=1
// from setGetenv beats the file.
stdin := strings.NewReader("\n1\nn\ny\n1\nn\ny\nnone\n")
var out, errBuf bytes.Buffer
if code := run([]string{"setup"}, stdin, &out, &errBuf); code != exitOK {
t.Fatalf("talk setup: Exit %d\n%s\n%s", code, out.String(), errBuf.String())
t.Fatalf("talk setup: exit %d\n%s\n%s", code, out.String(), errBuf.String())
}
bak, err := os.ReadFile(path + ".bak")
if err != nil || string(bak) != orig {
t.Errorf(".bak = %q (%v), want Original", bak, err)
t.Errorf(".bak = %q (%v), want the original", bak, err)
}
var got map[string]any
if _, err := toml.DecodeFile(path, &got); err != nil {
t.Fatal(err)
}
if _, ok := got["quiet_hours"]; ok {
t.Errorf("quiet_hours nicht entfernt: %v", got)
t.Errorf("quiet_hours not removed: %v", got)
}
if srv, _ := got["server"].(map[string]any); srv["port"] != int64(7900) {
t.Errorf("server.port verloren: %v", got["server"])
t.Errorf("server.port lost: %v", got["server"])
}
voice, _ := got["voice"].(map[string]any)
if edge, _ := voice["edge"].(map[string]any); edge["de"] != "de-DE-ConradNeural" {
t.Errorf("voice.edge.de verloren: %v", voice)
t.Errorf("voice.edge.de lost: %v", voice)
}
// Default behalten (Nr. 1 = Rachel = Repo-Default) → kein elevenlabs-Eintrag.
// Default kept (no. 1 = Rachel = built-in default) → no elevenlabs entry.
if _, ok := voice["elevenlabs"]; ok {
t.Errorf("voice.elevenlabs geschrieben, obwohl Default behalten: %v", voice)
t.Errorf("voice.elevenlabs written although the default was kept: %v", voice)
}
}
// TestSetupAbbruchSchreibtNichts: „q“ bricht ab, keine Datei entsteht.
func TestSetupAbbruchSchreibtNichts(t *testing.T) {
// TestSetupAbortWritesNothing: "q" aborts, no file is created.
func TestSetupAbortWritesNothing(t *testing.T) {
root := t.TempDir()
setupEnv(t, root, nil)
var out, errBuf bytes.Buffer
if code := run([]string{"setup"}, strings.NewReader("q\n"), &out, &errBuf); code != exitOK {
t.Fatalf("Exit %d, stderr %q", code, errBuf.String())
t.Fatalf("exit %d, stderr %q", code, errBuf.String())
}
if !strings.Contains(out.String(), "Abgebrochen") {
if !strings.Contains(out.String(), "Aborted") {
t.Errorf("stdout = %q", out.String())
}
if _, err := os.Stat(filepath.Join(root, "talk", "config.toml")); !os.IsNotExist(err) {
t.Error("config.toml trotz Abbruch angelegt")
t.Error("config.toml created despite abort")
}
}
// TestSetupOhneTerminal: Pipe → keine Fragen, wirksame Konfiguration, Exit 0.
func TestSetupOhneTerminal(t *testing.T) {
// TestSetupWithoutTerminal: a pipe → no questions, effective configuration,
// exit 0.
func TestSetupWithoutTerminal(t *testing.T) {
root := t.TempDir()
setGetenv(t, map[string]string{"XDG_CONFIG_HOME": root, "TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
if code := runCLI([]string{"setup"}, &out, &errBuf); code != exitOK {
t.Fatalf("Exit %d, stderr %q", code, errBuf.String())
t.Fatalf("exit %d, stderr %q", code, errBuf.String())
}
if !strings.Contains(out.String(), "Wirksame Konfiguration") || !strings.Contains(out.String(), "ruhezeit: keine") {
if !strings.Contains(out.String(), "Effective configuration") || !strings.Contains(out.String(), "quiet hours: none") {
t.Errorf("stdout = %q", out.String())
}
}
+20 -20
View File
@@ -12,41 +12,41 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/speak"
)
// testPhrase ist die feste Testphrase je Sprache.
// testPhrase is the fixed test sentence per language.
var testPhrase = map[string]string{
"de": "Dies ist eine Testansage von talk. Wenn du das hörst, funktioniert alles.",
"en": "This is a test announcement from talk. If you can hear this, everything works.",
}
// runTest implementiert `talk test`: spricht die Testphrase DIREKT in diesem
// Prozess (nicht über den Daemon) — so sieht der Befehl, welche Stufe
// wirklich gesprochen hat und wie lange Synthese und Wiedergabe dauerten.
// Mute und Ruhezeit gelten hier bewusst nicht (der Nutzer will gerade
// testen); ein aktives Mute wird aber genannt.
// runTest implements `talk test`: speaks the test sentence DIRECTLY in this
// process (not via the daemon), so the command sees which tier really spoke
// and how long synthesis and playback took. Mute and quiet hours
// deliberately do not apply (the user is testing right now); an active mute
// is mentioned.
func runTest(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Writer) int {
if len(args) > 0 {
fmt.Fprintln(stderr, "talk test: keine Argumente erwartet (Sprache: --lang de|en)")
fmt.Fprintln(stderr, "talk test: no arguments expected (language: --lang de|en)")
return exitUsage
}
if code, bad := validateSayOptions(opts, stderr); bad {
return code
}
lang := opts.lang
if lang == "" {
lang = "de"
}
cfg, err := config.Load(config.Options{Getenv: getenv})
if err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
lang := opts.lang
if lang == "" {
lang = cfg.Lang
}
if dir, err := config.Dir(getenv); err == nil {
if (queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}).Muted() {
fmt.Fprintln(stderr, "talk test: Mute ist an — der Test spielt trotzdem; normale Ansagen bleiben stumm (talk unmute)")
fmt.Fprintln(stderr, "talk test: mute is on; the test plays anyway, normal announcements stay silent (talk unmute)")
}
}
if cfg.Audio.Sink == config.SinkNull {
fmt.Fprintln(stderr, "talk test: audio.sink = null — es wird kein Ton ausgegeben")
fmt.Fprintln(stderr, "talk test: audio.sink = null; no sound will be produced")
}
engine, err := speak.New(cfg, getenv, stderr)
@@ -59,19 +59,19 @@ func runTest(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Wri
out, err := engine.Speak(ctx, speak.Request{Text: testPhrase[lang], Lang: lang, Provider: opts.provider, Voice: opts.voice})
if err != nil {
fmt.Fprintf(stderr, "talk test: %v\n", err)
fmt.Fprintln(stderr, "Hinweis: `talk doctor` zeigt, welche Stufe warum fehlt.")
fmt.Fprintln(stderr, "hint: `talk doctor` shows which tier is missing and why.")
return exitFailure
}
fmt.Fprintf(stdout, "provider: %s\n", out.Provider)
fmt.Fprintf(stdout, "sprache: %s\n", lang)
fmt.Fprintf(stdout, "provider: %s\n", out.Provider)
fmt.Fprintf(stdout, "language: %s\n", lang)
switch {
case out.Discarded:
fmt.Fprintln(stdout, "wiedergabe: verworfen (audio.sink = null)")
fmt.Fprintln(stdout, "playback: discarded (audio.sink = null)")
case out.Direct:
fmt.Fprintf(stdout, "dauer: %s (Direktansage über das OS)\n", out.Synthesis.Round(time.Millisecond))
fmt.Fprintf(stdout, "duration: %s (direct speech via the OS)\n", out.Synthesis.Round(time.Millisecond))
default:
fmt.Fprintf(stdout, "synthese: %s (%d Bytes)\n", out.Synthesis.Round(time.Millisecond), out.Bytes)
fmt.Fprintf(stdout, "wiedergabe: %s\n", out.Playback.Round(time.Millisecond))
fmt.Fprintf(stdout, "synthesis: %s (%d bytes)\n", out.Synthesis.Round(time.Millisecond), out.Bytes)
fmt.Fprintf(stdout, "playback: %s\n", out.Playback.Round(time.Millisecond))
}
return exitOK
}
+11 -11
View File
@@ -6,32 +6,32 @@ import (
"testing"
)
// TestTestBefehl: `talk test` spricht direkt über die Kette (hier: fake
// espeak → osbuiltin) und nennt den Provider; sink null wird angesagt.
func TestTestBefehl(t *testing.T) {
// TestTestCommand: `talk test` speaks directly through the chain (here: fake
// espeak → osbuiltin) and names the provider; sink null is announced.
func TestTestCommand(t *testing.T) {
seen := withFakeEspeak(t)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null", "TALK_PROVIDER": "osbuiltin"})
var out, errBuf bytes.Buffer
code := runCLI([]string{"test", "--lang", "en"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk test: Exit %d, stderr %q", code, errBuf.String())
t.Fatalf("talk test: exit %d, stderr %q", code, errBuf.String())
}
if !strings.Contains(out.String(), "provider: osbuiltin") || !strings.Contains(out.String(), "sprache: en") {
if !strings.Contains(out.String(), "provider: osbuiltin") || !strings.Contains(out.String(), "language: en") {
t.Errorf("stdout = %q", out.String())
}
if !strings.Contains(errBuf.String(), "kein Ton") {
t.Errorf("stderr = %q, will Hinweis auf sink null", errBuf.String())
if !strings.Contains(errBuf.String(), "no sound") {
t.Errorf("stderr = %q, want a note about sink null", errBuf.String())
}
if _, err := readFile(seen); err != nil {
t.Errorf("fake espeak nicht gerufen: %v", err)
t.Errorf("fake espeak not called: %v", err)
}
}
// TestTestBefehlUngueltigeSprache: --lang prüft wie say.
func TestTestBefehlUngueltigeSprache(t *testing.T) {
// TestTestCommandInvalidLanguage: --lang is validated like say.
func TestTestCommandInvalidLanguage(t *testing.T) {
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
if code := runCLI([]string{"test", "--lang", "fr"}, &out, &errBuf); code == exitOK || !strings.Contains(errBuf.String(), "--lang") {
t.Errorf("Exit %d, stderr %q", code, errBuf.String())
t.Errorf("exit %d, stderr %q", code, errBuf.String())
}
}
+13 -13
View File
@@ -11,7 +11,7 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/provider"
)
// voiceRow ist eine Zeile von `talk voices` (auch die JSON-Form).
// voiceRow is one row of `talk voices` (also the JSON form).
type voiceRow struct {
Provider string `json:"provider"`
Lang string `json:"lang"`
@@ -20,21 +20,21 @@ type voiceRow struct {
Default bool `json:"default"`
}
// runVoices implementiert `talk voices`: Katalog der bekannten Stimmen plus
// die in der config.toml gesetzten (auch wenn sie nicht im Katalog stehen),
// gefiltert nach --provider/--lang; „*“ markiert die effektive Default-
// Stimme je Provider und Sprache. osbuiltin erscheint als eine Zeile ohne
// ID — das Betriebssystem wählt die Stimme.
// runVoices implements `talk voices`: the catalog of known voices plus the
// ones set in config.toml (even when not in the catalog), filtered by
// --provider/--lang; "*" marks the effective default voice per provider and
// language. osbuiltin appears as one row without an ID: the operating system
// picks the voice.
func runVoices(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Writer) int {
if len(args) > 0 {
fmt.Fprintln(stderr, "talk voices: keine Argumente erwartet (Filter: --provider, --lang)")
fmt.Fprintln(stderr, "talk voices: no arguments expected (filters: --provider, --lang)")
return exitUsage
}
if code, bad := validateSayOptions(opts, stderr); bad {
return code
}
if opts.provider != "" && !slices.Contains(provider.Names(), opts.provider) {
fmt.Fprintf(stderr, "talk voices: unbekannter Provider %q (verfügbar: %v)\n", opts.provider, provider.Names())
fmt.Fprintf(stderr, "talk voices: unknown provider %q (available: %v)\n", opts.provider, provider.Names())
return exitFailure
}
cfg, err := config.Load(config.Options{Getenv: getenv})
@@ -60,11 +60,11 @@ func runVoices(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.W
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", r.Provider, r.Lang, r.ID, r.Name, mark)
}
tw.Flush()
fmt.Fprintln(stderr, "Stimme setzen: --voice <id> einmalig, oder dauerhaft in der config.toml unter [voice.<provider>] de = \"<id>\".")
fmt.Fprintln(stderr, "Pick a voice: --voice <id> once, or permanently in config.toml under [voice.<provider>] de = \"<id>\".")
return exitOK
}
// voiceRows baut die Tabelle: Katalog + Config-Einträge, gefiltert.
// voiceRows builds the table: catalog + config entries, filtered.
func voiceRows(cfg config.Config, onlyProvider, onlyLang string) []voiceRow {
var rows []voiceRow
seen := map[[3]string]bool{}
@@ -84,11 +84,11 @@ func voiceRows(cfg config.Config, onlyProvider, onlyLang string) []voiceRow {
}
for _, name := range provider.DefaultOrder {
for _, lang := range []string{"de", "en"} {
// Config-Eintrag zuerst, damit er oben steht (auch ohne Katalog).
// Config entry first so it appears on top (also without catalog).
if id := cfg.Voice[name][lang]; id != "" {
label := provider.VoiceName(name, id)
if label == "" {
label = "(aus config.toml)"
label = "(from config.toml)"
}
add(name, lang, id, label)
}
@@ -98,7 +98,7 @@ func voiceRows(cfg config.Config, onlyProvider, onlyLang string) []voiceRow {
add(v.Provider, v.Lang, v.ID, v.Name)
}
for _, lang := range []string{"de", "en"} {
add("osbuiltin", lang, "", "Stimme des Betriebssystems (wählt das OS)")
add("osbuiltin", lang, "", "operating system voice (the OS picks)")
}
return rows
}
+22 -22
View File
@@ -9,25 +9,25 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/config"
)
// TestVoiceRowsDefaultUndConfig: Ohne Config markiert „*“ den Repo-Default;
// ein Config-Mapping verschiebt die Markierung und steht als eigene Zeile
// oben — auch für IDs außerhalb des Katalogs. Filter greifen.
func TestVoiceRowsDefaultUndConfig(t *testing.T) {
// TestVoiceRowsDefaultAndConfig: without config "*" marks the built-in
// default; a config mapping moves the mark and appears as its own row on
// top, also for IDs outside the catalog. Filters apply.
func TestVoiceRowsDefaultAndConfig(t *testing.T) {
cfg := config.Defaults()
rows := voiceRows(cfg, "edge", "de")
if len(rows) == 0 || rows[0].ID != "de-DE-KatjaNeural" || !rows[0].Default {
t.Fatalf("edge/de ohne Config: erste Zeile = %+v, want Katja als Default", rows)
t.Fatalf("edge/de without config: first row = %+v, want Katja as default", rows)
}
for _, r := range rows {
if r.Provider != "edge" || r.Lang != "de" {
t.Errorf("Filter verletzt: %+v", r)
t.Errorf("filter violated: %+v", r)
}
}
cfg.Voice = map[string]map[string]string{"edge": {"de": "de-DE-EigeneNeural"}}
cfg.Voice = map[string]map[string]string{"edge": {"de": "de-DE-CustomNeural"}}
rows = voiceRows(cfg, "edge", "de")
if rows[0].ID != "de-DE-EigeneNeural" || !rows[0].Default || rows[0].Name != "(aus config.toml)" {
t.Errorf("Config-ID außerhalb des Katalogs: erste Zeile = %+v", rows[0])
if rows[0].ID != "de-DE-CustomNeural" || !rows[0].Default || rows[0].Name != "(from config.toml)" {
t.Errorf("config ID outside the catalog: first row = %+v", rows[0])
}
defaults := 0
for _, r := range rows {
@@ -35,45 +35,45 @@ func TestVoiceRowsDefaultUndConfig(t *testing.T) {
defaults++
}
if r.ID == "de-DE-KatjaNeural" && r.Default {
t.Error("Katja weiter als Default markiert, obwohl Config eine andere Stimme setzt")
t.Error("Katja still marked as default although config sets another voice")
}
}
if defaults != 1 {
t.Errorf("%d Default-Markierungen, want genau 1", defaults)
t.Errorf("%d default marks, want exactly 1", defaults)
}
// osbuiltin erscheint ohne ID, nie als Default.
// osbuiltin appears without an ID, never as default.
all := voiceRows(cfg, "", "")
found := false
for _, r := range all {
if r.Provider == "osbuiltin" {
found = true
if r.ID != "" || r.Default {
t.Errorf("osbuiltin-Zeile = %+v, want leere ID ohne Default", r)
t.Errorf("osbuiltin row = %+v, want empty ID without default", r)
}
}
}
if !found {
t.Error("osbuiltin fehlt in der Gesamtliste")
t.Error("osbuiltin missing from the full list")
}
}
// TestVoicesCLI: Tabelle mit Kopfzeile, --json als Liste, unbekannter
// Provider ist ein Fehler.
// TestVoicesCLI: table with header, --json as a list, unknown provider is an
// error.
func TestVoicesCLI(t *testing.T) {
setGetenv(t, nil)
var out, errBuf bytes.Buffer
if code := runCLI([]string{"voices", "--lang", "en"}, &out, &errBuf); code != exitOK {
t.Fatalf("talk voices: Exit %d, stderr %q", code, errBuf.String())
t.Fatalf("talk voices: exit %d, stderr %q", code, errBuf.String())
}
if !strings.HasPrefix(out.String(), "PROVIDER") || !strings.Contains(out.String(), "en-US-AriaNeural") {
t.Errorf("Tabelle = %q", out.String())
t.Errorf("table = %q", out.String())
}
out.Reset()
errBuf.Reset()
if code := runCLI([]string{"voices", "--json", "--provider", "openai"}, &out, &errBuf); code != exitOK {
t.Fatalf("talk voices --json: Exit %d, stderr %q", code, errBuf.String())
t.Fatalf("talk voices --json: exit %d, stderr %q", code, errBuf.String())
}
var rows []voiceRow
if err := json.Unmarshal(out.Bytes(), &rows); err != nil || len(rows) == 0 {
@@ -81,13 +81,13 @@ func TestVoicesCLI(t *testing.T) {
}
for _, r := range rows {
if r.Provider != "openai" {
t.Errorf("--provider openai liefert %+v", r)
t.Errorf("--provider openai yields %+v", r)
}
}
out.Reset()
errBuf.Reset()
if code := runCLI([]string{"voices", "--provider", "siri"}, &out, &errBuf); code == exitOK || !strings.Contains(errBuf.String(), "unbekannter Provider") {
t.Errorf("unbekannter Provider: Exit %d, stderr %q", code, errBuf.String())
if code := runCLI([]string{"voices", "--provider", "siri"}, &out, &errBuf); code == exitOK || !strings.Contains(errBuf.String(), "unknown provider") {
t.Errorf("unknown provider: exit %d, stderr %q", code, errBuf.String())
}
}
+69 -73
View File
@@ -13,33 +13,33 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/langdetect"
)
// Kind nicht gefunden — Shell-Konvention 127.
// exitNotFound is the shell convention for "command not found".
const exitNotFound = 127
// runWrap ist der wrap-Eingangspfad (Task 07): `talk wrap -- <Befehl>
// [Argumente …]` führt den Befehl aus — stdin/stdout/stderr werden
// durchgereicht, stdout gehört allein dem Befehl —, misst die Wandzeit und
// sagt danach das Ergebnis an: „<label> erfolgreich. Dauer: X." bzw.
// „<label> fehlgeschlagen, Code N." (Label = config `wrap.label` oder der
// Basisname des Befehls; lokalisiert de/en nach langdetect des Labels).
// Die Ansage läuft über denselben Weg wie say: Daemon (enqueue, non-
// blocking; --wait respektiert; Autostart), ohne Daemon direkt. wrap erbt
// die say-Flags und reicht sie an die Ansage durch.
// runWrap is the wrap entry point: `talk wrap -- <command> [args …]` runs
// the command (stdin/stdout/stderr passed through; stdout belongs to the
// command alone), measures wall time and announces the result afterwards:
// "<label> succeeded. Duration: X." or "<label> failed, code N." (label =
// config `wrap.label` or the command's base name; localized de/en by
// language detection on the label, falling back to the configured lang).
// The announcement takes the same path as say: daemon (enqueue, non-
// blocking; --wait honoured; autostart), without a daemon directly. wrap
// inherits the say flags and passes them on.
//
// Exit-Code-Politik: wrap exitet IMMER mit dem Code des Befehls (talk-
// Fehler ≠ Befehls-Fehler). Scheitert die Ansage, gibt es eine Warning auf
// stderr — der Befehls-Code bleibt. Benutzungsfehler von talk selbst
// (kein Befehl, unbekannte Flags, ungültiges --lang) fallen dagegen VOR
// dem Start des Befehls auf: Er läuft dann gar nicht erst. Nur
// Konfigurations-Fehler (kaputte config.toml/Env) treten nach dem Lauf
// auf — der Befehl bleibt gelaufen, die Ansage wird mit Warning ausgesetzt.
// Exit code policy: wrap ALWAYS exits with the command's code (talk errors
// are not command errors). If the announcement fails, there is a warning on
// stderr and the command's code stands. Usage errors of talk itself (no
// command, unknown flags, invalid --lang) fail BEFORE the command starts,
// so it does not run at all. Only configuration errors (broken config.toml
// or env) surface after the run: the command has run, the announcement is
// skipped with a warning.
//
// Die Positionsargumente sind die Befehlszeile (Befehl + Argumente); das
// „--" selbst hat parseInterspersed bereits konsumiert — alles dahinter
// kam unverändert durch den Flag-Schutz hindurch.
// The positional arguments are the command line (command + args); the "--"
// itself was consumed by parseInterspersed, everything after it came
// through untouched.
func runWrap(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, "talk wrap: kein Befehl — Verwendung: talk wrap [Flags] -- <Befehl> [Argumente …]")
fmt.Fprintln(stderr, "talk wrap: no command given; usage: talk wrap [flags] -- <command> [args …]")
return exitUsage
}
if code, bad := validateSayOptions(opts, stderr); bad {
@@ -51,46 +51,44 @@ func runWrap(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io
duration := time.Since(start)
code := childExitCode(runErr)
if runErr != nil {
// Z. B. Binary nicht gefunden oder Signal-Tod: Der Hinweis gehört
// auf stderr; die Ansage nennt denselben Code, mit dem wrap exitet.
fmt.Fprintf(stderr, "talk wrap: Befehl nicht sauber beendet: %v\n", runErr)
// For example: binary not found or killed by a signal. The note goes
// to stderr; the announcement names the same code wrap exits with.
fmt.Fprintf(stderr, "talk wrap: command did not exit cleanly: %v\n", runErr)
}
cfg, err := config.Load(config.Options{Getenv: getenv})
if err != nil {
fmt.Fprintf(stderr, "talk wrap: Ansage ausgesetzt — Config: %v (Exit-Code %d des Befehls bleibt)\n", err, code)
fmt.Fprintf(stderr, "talk wrap: announcement skipped; config: %v (the command's exit code %d stands)\n", err, code)
return code
}
label := wrapLabel(cfg.Wrap.Label, args[0])
lang := opts.lang
if lang == "" {
lang = langdetect.Detect(label)
lang = langdetect.Detect(label, cfg.Lang)
}
announceWrap(cfg, announcementText(label, code, duration, lang), opts, code, stderr)
return code
}
// announceWrap bringt die wrap-Ansage unter — über denselben Weg wie say
// (announce: Daemon, ggf. Autostart, sonst direkt). Der Exit-Code der
// Ansage-Maschinerie verwirft NIE den Code des Befehls: Scheitert sie,
// gibt es eine Warning auf stderr (talk-Fehler ≠ Befehls-Fehler).
// Informationszeilen der Ansage (Item-ID, muted, discarded) gehen bei wrap
// auf stderr — stdout gehört allein dem (bereits beendeten) Befehl, damit
// Pipes und Redirects des Befehls nicht durch talk-Ausgaben verunreinigt
// werden.
// announceWrap delivers the wrap announcement the same way say does
// (announce: daemon, autostart if needed, otherwise direct). The
// announcement machinery NEVER overrides the command's exit code: if it
// fails, there is a warning on stderr. Information lines of the
// announcement (item ID, muted, discarded) go to stderr for wrap: stdout
// belongs to the (already finished) command, so its pipes and redirects
// stay clean.
func announceWrap(cfg config.Config, text string, opts *sayOptions, commandCode int, stderr io.Writer) {
if announce(cfg, text, opts, stderr, stderr) != exitOK {
fmt.Fprintf(stderr, "talk wrap: Ansage fehlgeschlagen — Exit-Code %d des Befehls bleibt\n", commandCode)
fmt.Fprintf(stderr, "talk wrap: announcement failed; the command's exit code %d stands\n", commandCode)
}
}
// execChild startet argv als Kindprozess und wartet auf sein Ende.
// stdin/stdout/stderr werden unverändert durchgereicht — der Befehl
// besitzt die Konsole, talk mischt sich nicht ein (die Ansage selbst geht
// erst NACH dem Ende des Befehls raus). Reines os/exec ohne
// plattformspezifische Aufrufe: läuft unverändert auf Windows, macOS und
// Linux (Kindprozesse startet das jeweilige Betriebssystem-Exec selbst).
// execChild starts argv as a child process and waits for it. stdin/stdout/
// stderr are passed through unchanged: the command owns the console, talk
// does not interfere (the announcement goes out only AFTER the command has
// ended). Plain os/exec without platform-specific calls: runs unchanged on
// Windows, macOS and Linux.
func execChild(argv []string, stdin io.Reader, stdout, stderr io.Writer) error {
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin = stdin
@@ -99,17 +97,16 @@ func execChild(argv []string, stdin io.Reader, stdout, stderr io.Writer) error {
return cmd.Run()
}
// childExitCode übersetzt das Run-Ergebnis des Kindprozesses in den
// Exit-Code, mit dem wrap selbst exitet (Durchreich-Vertrag):
// childExitCode translates the child's Run result into the exit code wrap
// itself exits with (pass-through contract):
//
// - normales Ende: der Exit-Code des Befehls (0 = Erfolg).
// - Ende durch Signal: portabel auf 1 normiert. Die Unix-Konvention
// 128+Signalnummer bräuchte syscall.WaitStatus (Signaltode meldet Go
// als ExitCode -1) und wäre nicht mehr frei cross-compilierbar; die
// Ansage nennt bewusst denselben normierten Code, mit dem wrap exitet.
// - Start fehlgeschlagen (Binary nicht gefunden/nicht ausführbar):
// 127, die Shell-Konvention — der Befehl ist gescheitert, ohne je
// gelaufen zu sein.
// - normal exit: the command's exit code (0 = success).
// - killed by a signal: normalized to 1, portably. The Unix convention
// 128+signal would need syscall.WaitStatus (Go reports signal deaths as
// ExitCode -1) and would no longer cross-compile freely; the
// announcement deliberately names the same normalized code.
// - start failed (binary not found/not executable): 127, the shell
// convention; the command failed without ever running.
func childExitCode(runErr error) int {
if runErr == nil {
return 0
@@ -124,9 +121,9 @@ func childExitCode(runErr error) int {
return exitNotFound
}
// wrapLabel löst das Ansage-Label: config `wrap.label` (Env-Override
// TALK_WRAP_LABEL) schlägt den Basisnamen des Befehls
// (`/usr/bin/make` → „make").
// wrapLabel resolves the announcement label: config `wrap.label` (env
// override TALK_WRAP_LABEL) beats the command's base name
// (`/usr/bin/make` → "make").
func wrapLabel(cfgLabel, command string) string {
if cfgLabel != "" {
return cfgLabel
@@ -134,16 +131,16 @@ func wrapLabel(cfgLabel, command string) string {
return filepath.Base(command)
}
// formatDuration macht eine Wandzeit menschenlesbar, lokalisiert de/en:
// „3 Minuten 42 Sekunden" / „3 minutes 42 seconds". Gerundet auf ganze
// Sekunden (halb aufwärts); Nullteile fallen weg („1 Minute"), nur bei
// insgesamt null bleibt „0 Sekunden" stehen. Die Einzahl wird dekliniert
// („1 Sekunde", „1 minute"). Alles über einer Stunde nennt auch Stunden.
// formatDuration renders wall time for humans, localized de/en: "3 Minuten
// 42 Sekunden" / "3 minutes 42 seconds". Rounded to whole seconds (half
// up); zero parts are dropped ("1 Minute"), only an overall zero keeps
// "0 Sekunden". Singulars are declined ("1 Sekunde", "1 minute").
// Anything over an hour names hours too.
func formatDuration(d time.Duration, lang string) string {
type unit struct{ singular, plural string }
units := []unit{{"Stunde", "Stunden"}, {"Minute", "Minuten"}, {"Sekunde", "Sekunden"}}
if lang == "en" {
units = []unit{{"hour", "hours"}, {"minute", "minutes"}, {"second", "seconds"}}
units := []unit{{"hour", "hours"}, {"minute", "minutes"}, {"second", "seconds"}}
if lang == "de" {
units = []unit{{"Stunde", "Stunden"}, {"Minute", "Minuten"}, {"Sekunde", "Sekunden"}}
}
secs := int64(d.Round(time.Second) / time.Second)
n := []int64{secs / 3600, secs / 60 % 60, secs % 60}
@@ -164,21 +161,20 @@ func formatDuration(d time.Duration, lang string) string {
return strings.Join(parts, " ")
}
// announcementText baut die wrap-Ansage nach Brief: Erfolg
// „<label> erfolgreich. Dauer: X." bzw. Misserfolg
// „<label> fehlgeschlagen, Code N." — lokalisiert (englisch:
// „<label> succeeded. Duration: X." / „<label> failed, code N."). Die
// Sprache bestimmt die Spracherkennung des Labels (oder --lang, falls
// gesetzt); alles außer "en" spricht deutsch (langdetect-Default).
// announcementText builds the wrap announcement: success "<label>
// succeeded. Duration: X." / failure "<label> failed, code N.", in German
// "<label> erfolgreich. Dauer: X." / "<label> fehlgeschlagen, Code N.". The
// language comes from detection on the label (or --lang); anything but
// "de" speaks English.
func announcementText(label string, code int, d time.Duration, lang string) string {
if lang == "en" {
if lang == "de" {
if code == 0 {
return fmt.Sprintf("%s succeeded. Duration: %s.", label, formatDuration(d, lang))
return fmt.Sprintf("%s erfolgreich. Dauer: %s.", label, formatDuration(d, lang))
}
return fmt.Sprintf("%s failed, code %d.", label, code)
return fmt.Sprintf("%s fehlgeschlagen, Code %d.", label, code)
}
if code == 0 {
return fmt.Sprintf("%s erfolgreich. Dauer: %s.", label, formatDuration(d, lang))
return fmt.Sprintf("%s succeeded. Duration: %s.", label, formatDuration(d, lang))
}
return fmt.Sprintf("%s fehlgeschlagen, Code %d.", label, code)
return fmt.Sprintf("%s failed, code %d.", label, code)
}
+101 -87
View File
@@ -12,9 +12,9 @@ import (
"time"
)
// TestFormatDuration ist tischgesteuert für die menschliche Dauer-Format
// (de/en): Gerundung auf ganze Sekunden, Nullteile fallen weg, Einzahl
// wird dekliniert, Stunden ab einer Stunde, „0 Sekunden" als Fallback.
// TestFormatDuration is table-driven for the human duration format (de/en):
// rounding to whole seconds, zero parts dropped, singulars declined, hours
// from one hour on, "0 seconds" as the fallback.
func TestFormatDuration(t *testing.T) {
cases := []struct {
d time.Duration
@@ -23,7 +23,7 @@ func TestFormatDuration(t *testing.T) {
}{
{0, "0 Sekunden", "0 seconds"},
{300 * time.Millisecond, "0 Sekunden", "0 seconds"},
{500 * time.Millisecond, "1 Sekunde", "1 second"}, // halb aufwärts
{500 * time.Millisecond, "1 Sekunde", "1 second"}, // half up
{1500 * time.Millisecond, "2 Sekunden", "2 seconds"},
{1 * time.Second, "1 Sekunde", "1 second"},
{42 * time.Second, "42 Sekunden", "42 seconds"},
@@ -45,8 +45,8 @@ func TestFormatDuration(t *testing.T) {
}
}
// TestAnnouncementText: die Ansage-Texte nach Brief, lokalisiert de/en —
// Erfolg mit Dauer, Misserfolg mit Code (ohne Dauer).
// TestAnnouncementText: the announcement texts, localized de/en: success
// with duration, failure with code (without duration).
func TestAnnouncementText(t *testing.T) {
d := 222 * time.Second
cases := []struct {
@@ -66,7 +66,7 @@ func TestAnnouncementText(t *testing.T) {
}
}
// TestWrapLabel: config-Label schlägt den Basisnamen des Befehls.
// TestWrapLabel: the config label beats the command's base name.
func TestWrapLabel(t *testing.T) {
if got := wrapLabel("", "/usr/bin/make"); got != "make" {
t.Errorf("wrapLabel(\"\", /usr/bin/make) = %q, want %q", got, "make")
@@ -79,37 +79,37 @@ func TestWrapLabel(t *testing.T) {
}
}
// TestWrapOhneBefehl: `talk wrap` ohne Befehl ist ein klarer
// Benutzungsfehler — der Befehl (nicht vorhanden) läuft natürlich nicht.
func TestWrapOhneBefehl(t *testing.T) {
// TestWrapWithoutCommand: `talk wrap` without a command is a clear usage
// error; the (absent) command naturally does not run.
func TestWrapWithoutCommand(t *testing.T) {
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
code := runCLI([]string{"wrap"}, &out, &errBuf)
if code != exitUsage {
t.Fatalf("talk wrap ohne Befehl: Exit-Code = %d, want %d", code, exitUsage)
t.Fatalf("talk wrap without command: exit code = %d, want %d", code, exitUsage)
}
if !strings.Contains(errBuf.String(), "kein Befehl") {
t.Errorf("stderr = %q, will Hinweis auf fehlenden Befehl", errBuf.String())
if !strings.Contains(errBuf.String(), "no command") {
t.Errorf("stderr = %q, want a note about the missing command", errBuf.String())
}
}
// TestWrapValidiertSayFlagsVorDemStart: ungültige geerbte say-Flags
// scheitern VOR dem Befehlsstart (Benutzungsfehler laufen nie den Befehl).
func TestWrapValidiertSayFlagsVorDemStart(t *testing.T) {
// TestWrapValidatesSayFlagsBeforeStart: invalid inherited say flags fail
// BEFORE the command starts (usage errors never run the command).
func TestWrapValidatesSayFlagsBeforeStart(t *testing.T) {
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
code := runCLI([]string{"wrap", "--lang=fr", "--", "true"}, &out, &errBuf)
if code != exitFailure {
t.Fatalf("talk wrap --lang=fr: Exit-Code = %d, want %d", code, exitFailure)
t.Fatalf("talk wrap --lang=fr: exit code = %d, want %d", code, exitFailure)
}
if !strings.Contains(errBuf.String(), "--lang") {
t.Errorf("stderr = %q, will --lang-Fehler", errBuf.String())
t.Errorf("stderr = %q, want a --lang error", errBuf.String())
}
}
// wrapFakeTTS ist ein ElevenLabs-Fake, der den ANSAGE-TEXT aufzeichnet
// (der CLI-Fake in say_test.go beobachtet nur Pfad/Key — wrap braucht den
// gesprochenen Text als Beobachtung).
// wrapFakeTTS is an ElevenLabs fake recording the ANNOUNCEMENT TEXT (the
// CLI fake in say_test.go only observes path/key; wrap needs the spoken text
// as its observation).
func wrapFakeTTS(t *testing.T, status int) (url string, texts chan string) {
t.Helper()
texts = make(chan string, 8)
@@ -126,10 +126,10 @@ func wrapFakeTTS(t *testing.T, status int) (url string, texts chan string) {
return ts.URL, texts
}
// wrapEnv ist die Degraded-Grundausstattung der In-Prozess-wrap-Tests:
// Daemon garantiert tot (TALK_PORT=1), Kette auf den Fake gepinnt — die
// Ansage läuft deterministisch lokal gegen den Fake (sink null). extra
// ergänzt weitere Env-Werte (ein Aufruf, kein Verdrängen der Basis).
// wrapEnv is the base kit of the in-process wrap tests: daemon guaranteed
// dead (TALK_PORT=1), chain pinned to the fake; the announcement runs
// deterministically on the direct path against the fake (sink null). extra
// adds env values (one call, no displacing of the base).
func wrapEnv(t *testing.T, endpoint string, extra map[string]string) {
t.Helper()
kv := map[string]string{
@@ -144,19 +144,19 @@ func wrapEnv(t *testing.T, endpoint string, extra map[string]string) {
setGetenv(t, kv)
}
// needUnixBin überspringt den Test, wenn das Binary fehlt (die
// In-Prozess-Tests starten echte Kinder: true/sh — auf dem Linux-Dev-Host
// und in CI immer vorhanden).
// needUnixBin skips the test when the binary is missing (the in-process
// tests start real children: true/sh; always present on Linux and in CI).
func needUnixBin(t *testing.T, name string) {
t.Helper()
if _, err := exec.LookPath(name); err != nil {
t.Skipf("%s fehlt: %v", name, err)
t.Skipf("%s missing: %v", name, err)
}
}
// TestWrapDurchreichungErfolg: `talk wrap -- true` (Daemon tot → Degraded,
// Ansage gegen den Fake) exitet 0 und sagt den Erfolg mit Dauer.
func TestWrapDurchreichungErfolg(t *testing.T) {
// TestWrapPassThroughSuccess: `talk wrap -- true` (daemon dead → direct,
// announcement against the fake) exits 0 and announces success with the
// duration, in English by default.
func TestWrapPassThroughSuccess(t *testing.T) {
needUnixBin(t, "true")
url, texts := wrapFakeTTS(t, 200)
wrapEnv(t, url, nil)
@@ -164,103 +164,117 @@ func TestWrapDurchreichungErfolg(t *testing.T) {
var out, errBuf bytes.Buffer
code := runCLI([]string{"wrap", "--", "true"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk wrap -- true: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
t.Fatalf("talk wrap -- true: exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
}
select {
case text := <-texts:
// true läuft Millisekunden — Dauer rund 0; unter Last auch gern
// 1 Sekunde (Einzahl). Nur Struktur verifiziert.
if !regexp.MustCompile(`^true erfolgreich\. Dauer: \d+ Sekunden?\.$`).MatchString(text) {
t.Errorf("Ansage = %q, will „true erfolgreich. Dauer: N Sekunde(n).“", text)
// true runs for milliseconds: duration about 0; under load also
// 1 second (singular). Only the structure is verified.
if !regexp.MustCompile(`^true succeeded\. Duration: \d+ seconds?\.$`).MatchString(text) {
t.Errorf("announcement = %q, want \"true succeeded. Duration: N second(s).\"", text)
}
default:
t.Error("Ansage lief nicht (Fake nicht gerufen)")
t.Error("announcement did not run (fake not called)")
}
}
// TestWrapDurchreichungFehlschlag: `talk wrap -- sh -c "exit 3"` exitet 3
// und sagt den Misserfolg mit genau diesem Code.
func TestWrapDurchreichungFehlschlag(t *testing.T) {
// TestWrapPassThroughFailure: `talk wrap -- sh -c "exit 3"` exits 3 and
// announces the failure with exactly that code; with TALK_LANG=de in German.
func TestWrapPassThroughFailure(t *testing.T) {
needUnixBin(t, "sh")
url, texts := wrapFakeTTS(t, 200)
wrapEnv(t, url, nil)
for _, tc := range []struct {
lang string
want string
}{
{"", "sh failed, code 3."},
{"de", "sh fehlgeschlagen, Code 3."},
} {
t.Run("lang="+tc.lang, func(t *testing.T) {
url, texts := wrapFakeTTS(t, 200)
extra := map[string]string{}
if tc.lang != "" {
extra["TALK_LANG"] = tc.lang
}
wrapEnv(t, url, extra)
var out, errBuf bytes.Buffer
code := runCLI([]string{"wrap", "--", "sh", "-c", "exit 3"}, &out, &errBuf)
if code != 3 {
t.Fatalf("talk wrap -- sh -c 'exit 3': Exit-Code = %d, want 3", code)
}
select {
case text := <-texts:
if text != "sh fehlgeschlagen, Code 3." {
t.Errorf("Ansage = %q, want %q", text, "sh fehlgeschlagen, Code 3.")
}
default:
t.Error("Ansage lief nicht (Fake nicht gerufen)")
var out, errBuf bytes.Buffer
code := runCLI([]string{"wrap", "--", "sh", "-c", "exit 3"}, &out, &errBuf)
if code != 3 {
t.Fatalf("talk wrap -- sh -c 'exit 3': exit code = %d, want 3", code)
}
select {
case text := <-texts:
if text != tc.want {
t.Errorf("announcement = %q, want %q", text, tc.want)
}
default:
t.Error("announcement did not run (fake not called)")
}
})
}
}
// TestWrapStdoutGehoertBefehl: stdout der wrap-CLI führt NUR die Ausgabe
// des Befehls — die Informationszeilen der Ansage (hier: Degraded-Protokoll
// und Ketten-Logs) gehen auf stderr. Pipes des Befehls bleiben sauber.
func TestWrapStdoutGehoertBefehl(t *testing.T) {
// TestWrapStdoutBelongsToCommand: the wrap CLI's stdout carries ONLY the
// command's output; the announcement's information lines (direct-path note
// and chain logs) go to stderr. The command's pipes stay clean.
func TestWrapStdoutBelongsToCommand(t *testing.T) {
needUnixBin(t, "sh")
url, _ := wrapFakeTTS(t, 200)
wrapEnv(t, url, nil)
var out, errBuf bytes.Buffer
code := runCLI([]string{"wrap", "--", "sh", "-c", "echo kinderausgabe"}, &out, &errBuf)
code := runCLI([]string{"wrap", "--", "sh", "-c", "echo child-output"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk wrap -- sh -c echo: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
t.Fatalf("talk wrap -- sh -c echo: exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
}
if out.String() != "kinderausgabe\n" {
t.Errorf("stdout = %q, will ausschließlich die Befehlsausgabe %q", out.String(), "kinderausgabe\n")
if out.String() != "child-output\n" {
t.Errorf("stdout = %q, want only the command's output %q", out.String(), "child-output\n")
}
}
// TestWrapAnsageScheitertCodeBleibt: Stirbt die Ansage (Endpunkt tot,
// Kette gepinnt → kein Fallback), bleibt der Exit-Code des Befehls
// trotzdem — nur eine Warning auf stderr (talk-Fehler ≠ Befehls-Fehler).
func TestWrapAnsageScheitertCodeBleibt(t *testing.T) {
// TestWrapAnnouncementFailsCodeStands: if the announcement dies (endpoint
// dead, chain pinned → no fallback), the command's exit code still stands;
// only a warning on stderr (talk errors are not command errors).
func TestWrapAnnouncementFailsCodeStands(t *testing.T) {
needUnixBin(t, "sh")
wrapEnv(t, "http://127.0.0.1:1/", nil) // Endpunkt garantiert tot
wrapEnv(t, "http://127.0.0.1:1/", nil) // endpoint guaranteed dead
var out, errBuf bytes.Buffer
code := runCLI([]string{"wrap", "--", "sh", "-c", "exit 4"}, &out, &errBuf)
if code != 4 {
t.Fatalf("talk wrap (Ansage scheitert): Exit-Code = %d, want 4 (Befehl gewinnt)", code)
t.Fatalf("talk wrap (announcement fails): exit code = %d, want 4 (command wins)", code)
}
if !strings.Contains(errBuf.String(), "Ansage fehlgeschlagen") {
t.Errorf("stderr = %q, will Warning, dass die Ansage scheiterte", errBuf.String())
if !strings.Contains(errBuf.String(), "announcement failed") {
t.Errorf("stderr = %q, want a warning that the announcement failed", errBuf.String())
}
}
// TestWrapBefehlNichtGefunden: unbekannter Befehl → Shell-Konvention 127,
// Ansage nennt denselben Code.
func TestWrapBefehlNichtGefunden(t *testing.T) {
// TestWrapCommandNotFound: unknown command → shell convention 127, the
// announcement names the same code.
func TestWrapCommandNotFound(t *testing.T) {
url, texts := wrapFakeTTS(t, 200)
wrapEnv(t, url, nil)
var out, errBuf bytes.Buffer
code := runCLI([]string{"wrap", "--", "diesen-befehl-gibt-es-nicht"}, &out, &errBuf)
code := runCLI([]string{"wrap", "--", "this-command-does-not-exist"}, &out, &errBuf)
if code != exitNotFound {
t.Fatalf("talk wrap -- <unbekannt>: Exit-Code = %d, want %d", code, exitNotFound)
t.Fatalf("talk wrap -- <unknown>: exit code = %d, want %d", code, exitNotFound)
}
if !strings.Contains(errBuf.String(), "nicht sauber beendet") {
t.Errorf("stderr = %q, will Hinweis auf den Startfehler", errBuf.String())
if !strings.Contains(errBuf.String(), "did not exit cleanly") {
t.Errorf("stderr = %q, want a note about the start failure", errBuf.String())
}
select {
case text := <-texts:
if text != "diesen-befehl-gibt-es-nicht fehlgeschlagen, Code 127." {
t.Errorf("Ansage = %q, want Code-127-Ansage", text)
if text != "this-command-does-not-exist failed, code 127." {
t.Errorf("announcement = %q, want the code 127 announcement", text)
}
default:
t.Error("Ansage lief nicht (Fake nicht gerufen)")
t.Error("announcement did not run (fake not called)")
}
}
// TestWrapLabelAusEnv: TALK_WRAP_LABEL ersetzt den Basisnamen.
func TestWrapLabelAusEnv(t *testing.T) {
// TestWrapLabelFromEnv: TALK_WRAP_LABEL replaces the base name.
func TestWrapLabelFromEnv(t *testing.T) {
needUnixBin(t, "false")
url, texts := wrapFakeTTS(t, 200)
wrapEnv(t, url, map[string]string{"TALK_WRAP_LABEL": "Deployment"})
@@ -268,14 +282,14 @@ func TestWrapLabelAusEnv(t *testing.T) {
var out, errBuf bytes.Buffer
code := runCLI([]string{"wrap", "--", "false"}, &out, &errBuf)
if code != 1 {
t.Fatalf("talk wrap -- false: Exit-Code = %d, want 1", code)
t.Fatalf("talk wrap -- false: exit code = %d, want 1", code)
}
select {
case text := <-texts:
if text != "Deployment fehlgeschlagen, Code 1." {
t.Errorf("Ansage = %q, want %q", text, "Deployment fehlgeschlagen, Code 1.")
if text != "Deployment failed, code 1." {
t.Errorf("announcement = %q, want %q", text, "Deployment failed, code 1.")
}
default:
t.Error("Ansage lief nicht (Fake nicht gerufen)")
t.Error("announcement did not run (fake not called)")
}
}
+99 -99
View File
@@ -1,133 +1,133 @@
# Spec: talk 2.0 — Cross-Platform Voice Notifications für KI-Agenten
# Spec: talk 2.0: Cross-Platform Voice Notifications for AI Agents
Status: ready-for-agent
Created: 2026-10-18
Source: Grilling-Session 2026-10-18 (shared understanding, alle Äste besucht)
Source: grilling session 2026-10-18 (shared understanding, all branches visited)
---
## Problem Statement
KI-Agenten (Coding-Agenten wie opencode/Claude Code, Firmen-Chats, Hintergrund-Jobs) können mich heute nur visuell erreichen — Terminal-Output, Toast, push notification. Wenn ich weg vom Bildschirm bin (Kaffee, anderes Fenster, Meeting am zweiten Monitor), verpasse ich, dass ein Build fertig ist, ein Agent auf meine Freigabe wartet oder ein Job gecrasht ist.
Today, AI agents (coding agents such as opencode/Claude Code, company chats, background jobs) can only reach me visually: terminal output, toast, push notification. When I am away from the screen (coffee, another window, a meeting on the second monitor), I miss that a build has finished, that an agent is waiting for my approval, or that a job has crashed.
Der bestehende talk/crunch-Stack löst das prinzipiell, ist aber unbrauchbar als Basis: bash + mpv + notify-send + agenix = Linux-Desktop-only, ElevenLabs-only (Key pro Host, in der Praxis nur auf einem bestätigt funktionierend), kein natives Tool-Interface für Agenten (nur Shell-Umwege), keine Queue (Audio überlappt), flüchtiger Mute-Toggle, und null Geschichte auf dem Windows-Fleet der Firma.
The existing talk/crunch stack solves this in principle but is unusable as a foundation: bash + mpv + notify-send + agenix means Linux desktop only, ElevenLabs only (key per host, in practice confirmed working on just one), no native tool interface for agents (shell workarounds only), no queue (audio overlaps), a volatile mute toggle, and no story at all for the company's Windows fleet.
## Solution
**talk 2.0**: Ein cross-platform Werkzeug (Windows/macOS/Linux, amd64+arm64) in Go als statische Binaries — `talk` (CLI mit Bubble-Tea-TUI) und `talkd` (Daemon mit Queue). Jeder KI-Agent, der ein Programm ausführen kann, kann damit sprechen: `talk "Build fertig"`. Lokale MCP-Clients bekommen zusätzlich einen nativen MCP-Endpoint auf localhost. Die TTS-Qualität skaliert mit verfügbaren Keys über eine Vier-Stufen-Fallback-Kette bis hinunter zu kostenlosen/offline-Stimmen — Stimme funktioniert also überall ab Minute eins. Sprechen ist striktes Opt-in durch den Nutzer; der Daemon erzwingt Rate-Limit, Mute und Ruhezeiten als Backstop unter allem Prompt-Verhalten.
**talk 2.0**: a cross-platform tool (Windows/macOS/Linux, amd64+arm64) written in Go as static binaries: `talk` (CLI with a Bubble Tea TUI) and `talkd` (daemon with queue). Any AI agent that can run a program can speak with it: `talk "Build done"`. Local MCP clients additionally get a native MCP endpoint on localhost. TTS quality scales with the available keys through a four-stage fallback chain down to free/offline voices, so voice works everywhere from minute one. Speaking is strictly opt-in by the user; the daemon enforces rate limit, mute and quiet hours as a backstop beneath all prompt behaviour.
Verteilung: Gitea-Releases nach agent-lib-Muster (Asset-Schema `talk_<os>_<arch>` + sha256); Firma: interne Spiegelung + Fleet-Role (Ansible, Program Files + PATH); privat: Nix-Flake. Skill lebt im Repo und rollt in der Firma über az-agent-defaults/agent-lib aus.
Distribution: Gitea releases following the agent-lib pattern (asset schema `talk_<os>_<arch>` + sha256); company: internal mirror + fleet role (Ansible, Program Files + PATH); private: Nix flake. The skill lives in the repo and rolls out in the company via az-agent-defaults/agent-lib.
## User Stories
1. Als **Nutzer** will ich `talk "Text"` aufrufen können, damit ich sofort weiß, wie die Schnittstelle funktioniert, ohne Doku zu lesen.
2. Als **Nutzer** will ich, dass der Aufruf non-blocking zurückkehrt, damit mein Skript/Agent nicht auf das Audio wartet.
3. Als **Skript-Autor** will ich `talk "Text" --wait`, damit Cron-Jobs erst nach der Ansage weiterlaufen bzw. enden.
4. Als **Nutzer** will ich `talk wrap -- <befehl>` nutzen können, damit mir Dauer + Erfolg/Misserfolg eines beliebigen Befehls angesagt werden.
5. Als **KI-Agent** will ich das Programm mit Argument oder via stdin füttern können, damit ich es aus jeder Umgebung heraus aufrufen kann.
6. Als **KI-Agent (MCP-fähig)** will ich einen lokalen MCP-Endpoint mit einem speak-Tool nutzen können, damit ich Voice nativ als Tool habe statt Shell-Umwege.
7. Als **Browser-KI-Chat (später)** will ich `POST /speak` auf localhost mit CORS-Erlaubnis rufen können, damit eine Web-UI (z. B. LibreChat) mich ansprechen kann, ohne dass talk dafür geändert werden muss.
8. Als **Nutzer mit ElevenLabs-Key** will ich die Premium-Stimme bekommen, damit Ansagen so klingen wie gewohnt.
9. Als **Nutzer mit nur einem OpenAI-Key** will ich OpenAI-TTS als Provider nutzen können, damit ich keinen zweiten Account brauche.
10. Als **Nutzer ohne jeden Key** will ich automatisch Edge-TTS (kostenlos) bekommen, damit Stimme out-of-the-box funktioniert.
11. Als **Offline-Nutzer** will ich auf OS-eingebaute TTS-Stimmen (say/SAPI/espeak) zurückfallen, damit auch ohne Netz/Key gesprochen wird.
12. Als **Nutzer** will ich, dass die Provider-Kette automatisch durchfällt (Key ungültig, Kontingent leer, Netz weg), damit nie stumm bleibt, wenn nicht sein muss.
13. Als **Nutzer** will ich die Sprache des Texts automatisch erkannt bekommen (de/en), damit englische Agent-Antworten nicht mit deutscher Stimme verhackstückt werden.
14. Als **Nutzer** will ich pro Provider und Sprache eine Stimme in der config.toml setzen, damit meine gewohnte Stimme überall gleich klingt.
15. Als **Nutzer** will ich `talk setup` als geführten Wizard (Stimmen probehören, Key testen, Config schreiben), damit ich ohne Terminal-Erfahrung in 2 Minuten eingerichtet bin.
16. Als **Nutzer** will ich `talk status` als Live-Dashboard (Queue, Mute, aktiver Provider, Version), damit ich immer weiß, was das Tool gerade tut.
17. Als **Nutzer** will ich `talk voices` als interaktive Liste mit Vorhör-Preview, damit ich Stimmen ohne IDs auswendig zu lernen vergleichen kann.
18. Als **Nutzer** will ich `talk mute` / `talk unmute` als persistenten Schalter, damit alle Agenten und Skripte still sind, bis ich es erlaube.
19. Als **Nutzer** will ich konfigurierbare Ruhezeiten (`quiet_hours = "18:00-08:00"`), damit 23:47-Uhr-Cron-Jobs mich nicht wecken.
20. Als **Nutzer** will ich `talk cancel`, damit eine laufende oder geplante Ansage sofort stoppt.
21. Als **Nutzer** will ich `--priority`, damit Dringendes die Queue vorn überspringt und Gedrängel bei Rate-Limit verworfen werden darf.
22. Als **Nutzer** will ich, dass sich Audio-Nachrichten nie überlappen (Queue serialisiert), damit bei drei gleichzeitigen Agenten alles verständlich bleibt.
23. Als **Nutzer** will ich ein Rate-Limit als Daemon-Gesetz (Standard ~1 Ansage/30 s), damit kein Agent-Spam möglich ist, egal was im Prompt steht.
24. Als **Agent-nutzender Nutzer** will ich Opt-in-Etikette im Skill dokumentiert: Agent spricht nur auf meine Anweisung oder Standing-Order in der AGENTS.md, session-sticky bis Widerruf, damit keine Agentin von sich aus plappert.
25. Als **Agent-nutzender Nutzer** will ich, dass die Ansage nützlich ist (Status / nächster Schritt / wofür ich gebraucht werde), nicht die vorgelesene Langantwort, damit 10 Sekunden Ansage reichen.
26. Als **Fleet-Administrator** will ich statische Binaries von Gitea-Releases versionpinnt + sha256-verifiziert ausrollen (agent-lib/officecli-Muster), damit das Ansible-Rollout deterministisch ist.
27. Als **Fleet-Administrator** will ich Keys user-spezifisch als Env-Variablen (`TALK_*`) ausrollen nach unserem AZ_FLEET-Muster, damit kein Secret in Config-Dateien landet.
28. Als **Fleet-Administrator** will ich `talk doctor` als Diagnose-Output, damit „kein Ton bei Kollege X" in 5 Sekunden selbsterklärend ist.
29. Als **Fleet-Benutzer ohne Adminrechte** will ich, dass talk ohne Runtime-Abhängigkeiten (kein ffmpeg, kein Player, kein Python) läuft, damit es auf jedem Firmenrechner einfach funktioniert.
30. Als **Nutzer auf einem Rechner ohne laufenden Daemon** will ich einen Degraded Mode (Lock-File, sequenziell), damit talk auch ohne talkd nie kaputt ist.
31. Als **Privatnutzer mit NixOS-Hosts** will ich einen Flake-Output im Repo, damit meine Hosts talk wie gewohnt deklarativ beziehen.
32. Als **OS-Nutzer** will ich eine Desktop-Notification parallel zum Audio mit Abbrechen-Button, wo das OS es hergibt, damit ich stummschalten kann, ohne ein Terminal zu öffnen.
33. Als **Nicht-Terminal-Nutzer** will ich, dass TUI-Befehle ohne TTY auf plain Output zurückfallen, damit Pipes und Automatisierung niemals brechen.
34. Als **Projekt-Betreuer** will ich PR-getriebene Tests + Release-Matrix in Gitea Actions, damit jeder Tag automatisch konsistent gebaute Artefakte für alle Plattformen produziert.
1. As a **user** I want to call `talk "text"` so that I immediately know how the interface works without reading docs.
2. As a **user** I want the call to return non-blocking so that my script/agent does not wait for the audio.
3. As a **script author** I want `talk "text" --wait` so that cron jobs only continue or exit after the announcement.
4. As a **user** I want to use `talk wrap -- <command>` so that the duration and success/failure of any command are announced to me.
5. As an **AI agent** I want to feed the program via argument or stdin so that I can call it from any environment.
6. As an **AI agent (MCP-capable)** I want a local MCP endpoint with a speak tool so that I have voice natively as a tool instead of shell workarounds.
7. As a **browser AI chat (later)** I want to call `POST /speak` on localhost with CORS permission so that a web UI (e.g. LibreChat) can address me without talk having to change.
8. As a **user with an ElevenLabs key** I want the premium voice so that announcements sound the way I am used to.
9. As a **user with only an OpenAI key** I want to use OpenAI TTS as a provider so that I do not need a second account.
10. As a **user without any key** I want to get Edge TTS (free) automatically so that voice works out of the box.
11. As an **offline user** I want to fall back to OS built-in TTS voices (say/SAPI/espeak) so that speech works even without network or key.
12. As a **user** I want the provider chain to fall through automatically (invalid key, quota exhausted, network gone) so that it never stays silent when it does not have to.
13. As a **user** I want the language of the text detected automatically (de/en) so that English agent replies are not mangled by a German voice.
14. As a **user** I want to set a voice per provider and language in config.toml so that my familiar voice sounds the same everywhere.
15. As a **user** I want `talk setup` as a guided wizard (preview voices, test key, write config) so that I am set up in 2 minutes without terminal experience.
16. As a **user** I want `talk status` as a live dashboard (queue, mute, active provider, version) so that I always know what the tool is doing.
17. As a **user** I want `talk voices` as an interactive list with audio preview so that I can compare voices without memorising IDs.
18. As a **user** I want `talk mute` / `talk unmute` as a persistent switch so that all agents and scripts stay silent until I allow it.
19. As a **user** I want configurable quiet hours (`quiet_hours = "18:00-08:00"`) so that 23:47 cron jobs do not wake me.
20. As a **user** I want `talk cancel` so that a running or scheduled announcement stops immediately.
21. As a **user** I want `--priority` so that urgent items skip to the front of the queue and crowding under the rate limit may be discarded.
22. As a **user** I want audio messages to never overlap (the queue serialises) so that everything stays intelligible with three agents at once.
23. As a **user** I want a rate limit as daemon law (default ~1 announcement/30 s) so that agent spam is impossible, whatever the prompt says.
24. As an **agent-using user** I want opt-in etiquette documented in the skill: the agent speaks only on my instruction or a standing order in AGENTS.md, session-sticky until revoked, so that no agent chatters on its own.
25. As an **agent-using user** I want the announcement to be useful (status / next step / what I am needed for), not the long answer read aloud, so that 10 seconds of announcement are enough.
26. As a **fleet administrator** I want to roll out static binaries from Gitea releases, version-pinned and sha256-verified (agent-lib/officecli pattern), so that the Ansible rollout is deterministic.
27. As a **fleet administrator** I want to roll out keys per user as environment variables (`TALK_*`) following our AZ_FLEET pattern so that no secret ends up in config files.
28. As a **fleet administrator** I want `talk doctor` as diagnostic output so that "no sound at colleague X" is self-explanatory in 5 seconds.
29. As a **fleet user without admin rights** I want talk to run without runtime dependencies (no ffmpeg, no player, no Python) so that it simply works on every company machine.
30. As a **user on a machine without a running daemon** I want a degraded mode (lock file, sequential) so that talk is never broken even without talkd.
31. As a **private user with NixOS hosts** I want a flake output in the repo so that my hosts pull talk declaratively as usual.
32. As an **OS user** I want a desktop notification alongside the audio with a cancel button, where the OS allows it, so that I can silence it without opening a terminal.
33. As a **non-terminal user** I want TUI commands to fall back to plain output without a TTY so that pipes and automation never break.
34. As a **project maintainer** I want PR-driven tests + a release matrix in Gitea Actions so that every tag automatically produces consistently built artifacts for all platforms.
## Implementation Decisions
- **Sprache/Laufzeit**: Go; statische Binaries für win/mac/linux × amd64/arm64. Keine Runtime-Abhängigkeiten.
- **Binaries**: `talk` (CLI) + `talkd` (Daemon). Repo: `code.m3ta.dev/m3tam3re/talk`, MIT-Lizenz.
- **Daemon**: talkd besitzt die Queue (Serialisierung, Prioritäten, Rate-Limit, Verwerfen bei Gedrängel), den persistenten Mute-State, die Ruhezeiten-Fenster (lokale Zeit, still verwerfen oder toast-only, konfigurierbar), die HTTP-API (`POST /speak` auf 127.0.0.1, CORS-Domains konfigurierbar) und einen MCP-Endpoint (Streamable HTTP, localhost).
- **Degraded Mode**: ohne laufenden talkd fällt das CLI auf Lock-File-Sequential zurück; nichts bricht still.
- **Audio**: embedded Playback (oto) — kein mpv/ffmpeg nötig. Audio-Sink wählbar inkl. `null` (CI, Server, doctor).
- **Notifications**: best effort über beeep (notify-send / macOS Notification Center / Windows Toast); Cancel-Button, wo das OS ihn hergibt.
- **Provider-Kette V1** (Auto-Fallback in dieser Reihenfolge, überschreibbar per Config/Flag): ElevenLabs → OpenAI TTS → Edge-TTS (frei, kein Key) → OS built-in (macOS say / Windows SAPI / Linux espeak).
- **Keys/Config**: Env-first (`TALK_ELEVENLABS_KEY`, `TALK_OPENAI_KEY`, …) plus optionale `config.toml` im platform-nativen Config-Dir (`~/.config/talk` / `~/Library/Application Support/talk` / `%APPDATA%\talk`). Provider-Endpunkte konfigurierbar (Testbarkeit, Firmenserver später). Per-User-Keys in V1; zentraler Firmenserver/Gateway bewusst später.
- **Sprache/Stimmen**: de/en-Heuristik (reine Funktion, kein ML); `voice.<provider>.<lang>`-Mapping in der config.toml; persönliche ElevenLabs-Stimme (KXxZ…) als privater Default, neutrale Defaults im Repo.
- **CLI-Verhalten**: non-blocking Default; Flags `--wait`, `--now` (Queue-Überprung für Dringendes), `--priority`, `--voice`, `--lang`, `--provider`. Befehle: say, wrap, mute, unmute, status, cancel, voices, test, doctor, setup.
- **TUI (Bubble Tea)**: interaktiv = setup (Wizard), status (Live-Dashboard), voices (Picker mit Preview); plain & scriptbar = say, wrap, mute, cancel, test, doctor; kein TTY → immer plain.
- **Etikette (Skill-Policy)**: striktes Opt-in — Sprechen nur auf Nutzer-Trigger des Skills oder Standing-Order in der AGENTS.md; session-sticky bis Widerruf (Prompt-Level, kein Daemon-Session-State); Ansageinhalt = Status/nächster Schritt/wofür der Nutzer gebraucht wird, nie die Langantwort. Daemon-Backstop: Rate-Limit + Mute + Ruhezeiten gelten unabhängig vom Prompt.
- **Verteilung**: GoReleaser → Gitea-Releases, Asset-Schema `talk_<os>_<arch>` + sha256 (agent-lib-Vertrag); Firma spiegelt intern (pin_mirror-Muster) + Fleet-Role nach agent-lib/officecli-Vorbild (Program Files + Machine-PATH, admin-geschützte ProgramData-Config); Skill-Rollout Firma via az-agent-defaults → agent-lib Three-State-Sync; privat via flake.nix.
- **CI**: Gitea Actions — PR → Tests, Tag → Release-Matrix.
- **Legacy-Abriss**: alte voice-notify/crunch-jobs-Skills (AGENTS-Repo) werden Redirect-Stubs; nixpkgs-PRs #24/#7 werden als superseded geschlossen; m3ta-nixpkgs talk/crunch nach Migration deprecatet; ElevenLabs-Key (exponiert in altem Prototyp-Skript) wird rotiert, danach das Skript gelöscht.
- **Naming**: Name bleibt `talk` (akzeptierte Kollision mit historischem Unix-talk; `say` vermieden wegen macOS).
- **Language/runtime**: Go; static binaries for win/mac/linux x amd64/arm64. No runtime dependencies.
- **Binaries**: `talk` (CLI) + `talkd` (daemon). Repo: `code.m3ta.dev/m3tam3re/talk`, MIT licence.
- **Daemon**: talkd owns the queue (serialisation, priorities, rate limit, discarding under crowding), the persistent mute state, the quiet-hours windows (local time, discard silently or toast-only, configurable), the HTTP API (`POST /speak` on 127.0.0.1, CORS domains configurable) and an MCP endpoint (Streamable HTTP, localhost).
- **Degraded mode**: without a running talkd the CLI falls back to lock-file sequential; nothing breaks silently.
- **Audio**: embedded playback (oto), no mpv/ffmpeg needed. Audio sink selectable, including `null` (CI, server, doctor).
- **Notifications**: best effort via beeep (notify-send / macOS Notification Center / Windows toast); cancel button where the OS allows it.
- **Provider chain V1** (auto-fallback in this order, overridable via config/flag): ElevenLabs -> OpenAI TTS -> Edge TTS (free, no key) -> OS built-in (macOS say / Windows SAPI / Linux espeak).
- **Keys/config**: env-first (`TALK_ELEVENLABS_KEY`, `TALK_OPENAI_KEY`, ...) plus an optional `config.toml` in the platform-native config dir (`~/.config/talk` / `~/Library/Application Support/talk` / `%APPDATA%\talk`). Provider endpoints configurable (testability, company server later). Per-user keys in V1; a central company server/gateway deliberately later.
- **Language/voices**: de/en heuristic (pure function, no ML); `voice.<provider>.<lang>` mapping in config.toml; personal ElevenLabs voice (KXxZ...) as private default, neutral defaults in the repo.
- **CLI behaviour**: non-blocking by default; flags `--wait`, `--now` (queue bypass for urgent items), `--priority`, `--voice`, `--lang`, `--provider`. Commands: say, wrap, mute, unmute, status, cancel, voices, test, doctor, setup.
- **TUI (Bubble Tea)**: interactive = setup (wizard), status (live dashboard), voices (picker with preview); plain and scriptable = say, wrap, mute, cancel, test, doctor; no TTY -> always plain.
- **Etiquette (skill policy)**: strict opt-in. Speaking only on a user trigger of the skill or a standing order in AGENTS.md; session-sticky until revoked (prompt level, no daemon session state); announcement content = status / next step / what the user is needed for, never the long answer. Daemon backstop: rate limit + mute + quiet hours apply regardless of the prompt.
- **Distribution**: GoReleaser -> Gitea releases, asset schema `talk_<os>_<arch>` + sha256 (agent-lib contract); the company mirrors internally (pin_mirror pattern) + fleet role modelled on agent-lib/officecli (Program Files + machine PATH, admin-protected ProgramData config); company skill rollout via az-agent-defaults -> agent-lib three-state sync; private via flake.nix.
- **CI**: Gitea Actions: PR -> tests, tag -> release matrix.
- **Legacy teardown**: the old voice-notify/crunch-jobs skills (AGENTS repo) become redirect stubs; nixpkgs PRs #24/#7 are closed as superseded; m3ta-nixpkgs talk/crunch deprecated after migration; the ElevenLabs key (exposed in the old prototype script) is rotated, then the script is deleted.
- **Naming**: the name stays `talk` (accepted collision with the historical Unix talk; `say` avoided because of macOS).
## Testing Decisions
- **Guter Test** testet ausschließlich extern beobachtbares Verhalten an der Schnittstelle, nie Implementationsdetails.
- **Primäre Seam (eine)**: die Prozessgrenze von talkd — Daemon + CLI als Black Box, mit zwei über die Config injizierbaren Fakes: (1) Provider-Endpunkte zeigen im Test auf einen Fake-HTTP-Server mit Audio-Bytes; (2) `audio.sink = "null"` schaltet echtes Playback ab. An dieser Seam werden getestet: Queue-Serialisierung/Priorität/Rate-Limit/Verwerfen, Mute-Persistenz, Ruhezeiten, Fallback-Kette (Fake-Provider 500 → nächster Provider), non-blocking + `--wait`, Degraded Mode, HTTP-`/speak`-Vertrag inkl. CORS, MCP-Endpoint-Grundvertrag, status/cancel/doctor-Output.
- **Zwei reine Neben-Seams** (tischgesteuerte Unit-Tests, keine Mocks): Spracherkennung (de/en) und Config-Auflösung (Env + TOML-Merge).
- **Bewusst ungetestet** (hardware-/OS-gebunden): echtes Audio-Rendering und OS-Toasts — abgedeckt durch `talk doctor` als manuelles Diagnosewerkzeug.
- **Vorläufer**: keine — grüne Wiese. CI läuft von Ticket 1 an in Gitea Actions mit.
- **A good test** tests only externally observable behaviour at the interface, never implementation details.
- **Primary seam (one)**: the process boundary of talkd. Daemon + CLI as a black box, with two fakes injectable via config: (1) provider endpoints point to a fake HTTP server serving audio bytes in tests; (2) `audio.sink = "null"` disables real playback. Tested at this seam: queue serialisation/priority/rate limit/discarding, mute persistence, quiet hours, fallback chain (fake provider 500 -> next provider), non-blocking + `--wait`, degraded mode, HTTP `/speak` contract including CORS, MCP endpoint basic contract, status/cancel/doctor output.
- **Two pure side seams** (table-driven unit tests, no mocks): language detection (de/en) and config resolution (env + TOML merge).
- **Deliberately untested** (hardware/OS-bound): real audio rendering and OS toasts, covered by `talk doctor` as a manual diagnostic tool.
- **Predecessors**: none, greenfield. CI runs in Gitea Actions from ticket 1 onwards.
## Out of Scope
- LibreChat-Frontend-Anbindung (CORS-Tür wird gebaut, Integration selbst nicht)
- Browser-Extension als Brücke
- Zentraler Company-Gateway-Server (ein Key für alle)
- Timer/Scheduling (`crunch at|in|daily` bleibt OS-nativ: cron/systemd/Task Scheduler)
- Lokale Neural-Stimmen (Piper/Kokoro) — späterer Ausbau
- Kalenderbewusste Ruhezeiten (nur statische Zeitfenster in V1)
- Homebrew/Scoop/AUR-Feeds — später optional
- Mehrnutzer-Maschinen (Ein-Nutzer-pro-Host wie im Fleet)
- LibreChat frontend integration (the CORS door is built, the integration itself is not)
- Browser extension as a bridge
- Central company gateway server (one key for everyone)
- Timers/scheduling (`crunch at|in|daily` stays OS-native: cron/systemd/Task Scheduler)
- Local neural voices (Piper/Kokoro), later expansion
- Calendar-aware quiet hours (static time windows only in V1)
- Homebrew/Scoop/AUR feeds, optional later
- Multi-user machines (one user per host as in the fleet)
## Further Notes
- **Pending (Nutzer)**: ElevenLabs-Key-Rotation im Dashboard VOR Löschung des alten Prototyp-Skripts.
- Persönlicher Default-Voice: ElevenLabs `KXxZd16DiBqt82nbarJx` (aus altem Stack übernommen als privater Default, nicht Repo-Default).
- Session-Stickiness der Etikette ist Prompt/Skill-Level — der Daemon kennt keine Agent-Sessions.
- Offene Später-Entscheidung: LibreChat-Anbindung via Frontend-Patch vs. Browser-Extension — blockiert nichts, Tür steht mit CORS-fähiger HTTP-API.
- Verwandte Firmen-ADRs: 0009 (agent-lib), 0012/0013 (Secrets-Env-Bridge/Machine-Env-Keys), 0016/0017 (user-scharfe Keys, MCP-Audience-Scoping) — talk folgt diesen Mustern.
- **Pending (user)**: rotate the ElevenLabs key in the dashboard BEFORE deleting the old prototype script.
- Personal default voice: ElevenLabs `KXxZd16DiBqt82nbarJx` (carried over from the old stack as private default, not the repo default).
- Session stickiness of the etiquette is prompt/skill level; the daemon knows nothing about agent sessions.
- Open decision for later: LibreChat integration via frontend patch vs. browser extension. Blocks nothing; the door is open with the CORS-capable HTTP API.
- Related company ADRs: 0009 (agent-lib), 0012/0013 (secrets env bridge / machine env keys), 0016/0017 (user-scoped keys, MCP audience scoping). talk follows these patterns.
---
## Nachtrag 2026-10-24 — Entscheidungen nach Architektur-Review
## Addendum 2026-10-24: decisions after the architecture review
Diese Punkte ersetzen die entsprechenden Aussagen oben (Grilling-Stand
These points replace the corresponding statements above (grilling state of
2026-10-18):
- **Ein Binary statt `talk` + `talkd`.** Der Daemon ist `talk daemon run`;
`talk say`/`talk wrap` starten ihn beim ersten Sprechbefehl selbst im
Hintergrund (`daemon.autostart`, Default an; `talk daemon start|stop`
explizit). Der Degraded Mode mit Lock-Datei entfällt; ohne erreichbaren
oder startbaren Daemon spricht talk direkt (unserialisiert, mit Hinweis).
- **CLI ist die Agenten-Schnittstelle.** MCP bleibt als Tür bestehen, ist
aber kein Fokus mehr; Browser-Zugriff ist serverseitig auf POST /speak
von gelisteten Origins beschränkt (Origin-Guard, nicht nur CORS).
- **`now:true` bricht bewusst aus der Ruhezeit aus** (bestätigt).
- **Zwei weitere Gesetze:** `queue.max_text` (400 Zeichen, Kürzung an der
Wortgrenze) und `queue.max_age` (2 min, veraltete Stau-Ansagen
verfallen).
- **Zielbild Veröffentlichung:** talk soll perspektivisch öffentlich
nutzbar sein. Oberstes Kriterium ist Usability für Nicht-Terminal-
Nutzer; die Bubble-Tea-TUI ist kein Selbstzweck — einfache, klare
Befehle (`doctor`, `setup`, `test`, `voices`) haben Vorrang.
- **Release ohne GoReleaser:** `scripts/release.sh` baut die sechs Targets
mit `go build`; `.gitea/workflows/release.yml` legt bei Tag `v*` das
Gitea-Release an. Asset-Vertrag unverändert (`talk_<os>_<arch>[.exe]` +
`checksums.txt`). `flake.nix` liefert Paket, App, Overlay und devShell.
- **Notifications ohne beeep:** Desktop-Toast über die Bordmittel des OS
(notify-send / osascript / PowerShell), exec-basiert wie osbuiltin;
zunächst nur für `quiet_mode = "toast"`. Cancel-Button: später.
- **One binary instead of `talk` + `talkd`.** The daemon is `talk daemon run`;
`talk say`/`talk wrap` start it themselves in the background on the first
speak command (`daemon.autostart`, on by default; `talk daemon start|stop`
explicitly). The degraded mode with a lock file is dropped; without a
reachable or startable daemon, talk speaks directly (unserialised, with a
notice).
- **The CLI is the agent interface.** MCP remains as a door but is no longer
a focus; browser access is restricted server-side to POST /speak from
listed origins (origin guard, not just CORS).
- **`now:true` deliberately breaks out of quiet hours** (confirmed).
- **Two more laws:** `queue.max_text` (400 characters, truncated at a word
boundary) and `queue.max_age` (2 min, stale backlog announcements expire).
- **Target picture for publication:** talk should eventually be usable
publicly. The top criterion is usability for non-terminal users; the
Bubble Tea TUI is not an end in itself. Simple, clear commands (`doctor`,
`setup`, `test`, `voices`) take precedence.
- **Release without GoReleaser:** `scripts/release.sh` builds the six targets
with `go build`; `.gitea/workflows/release.yml` creates the Gitea release
on tag `v*`. Asset contract unchanged (`talk_<os>_<arch>[.exe]` +
`checksums.txt`). `flake.nix` provides package, app, overlay and devShell.
- **Notifications without beeep:** desktop toast via the OS's own tools
(notify-send / osascript / PowerShell), exec-based like osbuiltin;
initially only for `quiet_mode = "toast"`. Cancel button: later.
+12 -12
View File
@@ -1,13 +1,13 @@
{
# talk — Sprach-Ausgaben für Skripte und KI-Agenten.
# talk: voice notifications for scripts and AI agents.
#
# Nutzung in einer NixOS-/Home-Manager-Konfiguration:
# Usage in a NixOS / Home Manager configuration:
# inputs.talk.url = "git+https://code.m3ta.dev/m3tam3re/talk";
# environment.systemPackages = [ inputs.talk.packages.${system}.talk ];
# oder als Overlay: nixpkgs.overlays = [ inputs.talk.overlays.default ];
# or as an overlay: nixpkgs.overlays = [ inputs.talk.overlays.default ];
#
# Dieses Flake ersetzt langfristig m3ta-nixpkgs talk/crunch (bash + mpv).
description = "talk — Voice-Notifications für Skripte und KI-Agenten (ein Binary, Daemon mit Queue)";
# In the long run this flake replaces m3ta-nixpkgs talk/crunch (bash + mpv).
description = "talk: voice notifications for scripts and AI agents (one binary, daemon with queue)";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
@@ -21,16 +21,16 @@
inherit version;
src = self;
subPackages = [ "cmd/talk" ];
# Nach Änderungen an go.mod/go.sum neu setzen: `nix build` nennt den
# erwarteten Hash.
# Update after changes to go.mod/go.sum: `nix build` prints the
# expected hash.
vendorHash = "sha256-CbF3juTA7V05hh1ElKKACddsCn4bIPeLZxsz2EZxs9I=";
env.CGO_ENABLED = 0;
ldflags = [ "-s" "-w" "-X" "main.version=${version}" "-X" "main.commit=${self.shortRev or "dirty"}" ];
# Prozessgrenzen-Tests starten Daemons und binden Ports — nicht in der Sandbox.
# The process-boundary tests start daemons and bind ports; not in the sandbox.
doCheck = false;
# Audio: oto spricht PulseAudio/PipeWire direkt (reines Go) und fällt auf
# ALSA zurück, das es zur Laufzeit per dlopen lädt — auf NixOS nur
# auffindbar, wenn wir es in den Pfad legen. OS-Stimme: espeak-ng.
# Audio: oto talks to PulseAudio/PipeWire directly (pure Go) and falls
# back to ALSA, which it loads at runtime via dlopen; on NixOS that is
# only found if we put it on the path. OS voice: espeak-ng.
nativeBuildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.makeWrapper ];
postInstall = pkgs.lib.optionalString pkgs.stdenv.isLinux ''
wrapProgram $out/bin/talk \
@@ -38,7 +38,7 @@
--prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.espeak-ng pkgs.libnotify ]}
'';
meta = with pkgs.lib; {
description = "Voice-Notifications für Skripte und KI-Agenten";
description = "Voice notifications for scripts and AI agents";
homepage = "https://code.m3ta.dev/m3tam3re/talk";
license = licenses.mit;
mainProgram = "talk";
+19 -20
View File
@@ -11,19 +11,19 @@ import (
"time"
)
// Client ist der HTTP-Client der CLI für den Daemon auf 127.0.0.1 —
// dieselbe Vertragsschnittstelle, die server.go bedient.
// Client is the CLI's HTTP client for the daemon on 127.0.0.1; the same
// contract that server.go serves.
type Client struct {
// BaseURL ist die Daemon-Adresse (http://127.0.0.1:<port>).
// BaseURL is the daemon address (http://127.0.0.1:<port>).
BaseURL string
// HTTP ist der verwendete Client; nil wird von NewClient gesetzt.
// HTTP is the client in use; NewClient sets it when nil.
HTTP *http.Client
}
// NewClient baut den Client für einen Daemon-Port. Zeit-Budgets steuert der
// Aufrufer pro Aufruf über den Context (Daemon-Erkennung kurz, /speak
// großzügiger); das Client-Timeout ist nur die harte Decke.
// NewClient builds the client for a daemon port. The caller controls time
// budgets per call via the context (daemon detection short, /speak more
// generous); the client timeout is only the hard ceiling.
func NewClient(port int) *Client {
return &Client{
BaseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
@@ -31,10 +31,10 @@ func NewClient(port int) *Client {
}
}
// HTTPError ist eine Daemon-Antwort mit unerwartetem Status-Code (z. B.
// 400 Validierung). Netz-Fehler (Daemon tot) sind dagegen reguläre
// *url.Error-Werte — die CLI unterscheidet danach: HTTP-Fehler anzeigen,
// Netz-Fehler → Degraded Mode.
// HTTPError is a daemon response with an unexpected status code (e.g. 400
// validation). Network errors (daemon dead) are regular *url.Error values
// instead; the CLI distinguishes by that: show HTTP errors, fall into
// degraded mode on network errors.
type HTTPError struct {
Status int
Body string
@@ -44,7 +44,7 @@ func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.Status, e.Body)
}
// Status ruft GET /status (Daemon-Erkennung, --wait-Polling).
// Status calls GET /status (daemon detection, --wait polling).
func (c *Client) Status(ctx context.Context) (*StatusResponse, error) {
var st StatusResponse
if err := c.do(ctx, http.MethodGet, "/status", nil, http.StatusOK, &st); err != nil {
@@ -53,7 +53,7 @@ func (c *Client) Status(ctx context.Context) (*StatusResponse, error) {
return &st, nil
}
// Speak ruft POST /speak (enqueue).
// Speak calls POST /speak (enqueue).
func (c *Client) Speak(ctx context.Context, req SpeakRequest) (*SpeakResponse, error) {
var resp SpeakResponse
if err := c.do(ctx, http.MethodPost, "/speak", req, http.StatusAccepted, &resp); err != nil {
@@ -62,8 +62,7 @@ func (c *Client) Speak(ctx context.Context, req SpeakRequest) (*SpeakResponse, e
return &resp, nil
}
// Cancel ruft POST /cancel; Rückgabe ist die Zahl der abgebrochenen
// Ansagen.
// Cancel calls POST /cancel; returns the number of cancelled announcements.
func (c *Client) Cancel(ctx context.Context) (int, error) {
var resp CancelResponse
if err := c.do(ctx, http.MethodPost, "/cancel", nil, http.StatusOK, &resp); err != nil {
@@ -72,7 +71,7 @@ func (c *Client) Cancel(ctx context.Context) (int, error) {
return resp.Cancelled, nil
}
// SetMute ruft POST /mute (muted=true) bzw. POST /unmute (muted=false).
// SetMute calls POST /mute (muted=true) or POST /unmute (muted=false).
func (c *Client) SetMute(ctx context.Context, muted bool) error {
path := "/mute"
if !muted {
@@ -81,14 +80,14 @@ func (c *Client) SetMute(ctx context.Context, muted bool) error {
return c.do(ctx, http.MethodPost, path, nil, http.StatusOK, nil)
}
// Shutdown ruft POST /shutdown (`talk daemon stop`).
// Shutdown calls POST /shutdown (`talk daemon stop`).
func (c *Client) Shutdown(ctx context.Context) error {
return c.do(ctx, http.MethodPost, "/shutdown", nil, http.StatusOK, nil)
}
// do ist der gemeinsame Kern: Request bauen, senden, Status prüfen,
// dekodieren. Netz-Fehler kommen ungefiltert zurück (Aufrufer unterscheidet
// über errors.As(&HTTPError)).
// do is the shared core: build the request, send it, check the status,
// decode. Network errors are returned unfiltered (the caller distinguishes
// via errors.As(&HTTPError)).
func (c *Client) do(ctx context.Context, method, path string, body any, wantStatus int, out any) error {
var rd io.Reader
if body != nil {
+39 -42
View File
@@ -5,28 +5,26 @@ import (
"strings"
)
// CORS-Tür für POST /speak (Task 06) — und NUR für diesen Endpunkt: Ein
// Browser (z. B. eine spätere LibreChat-Integration) darf genau die Ansage-
// Schnittstelle cross-origin rufen, keine Steuer-Endpunkte. Die Erlaubnis
// kommt ausschließlich aus der Config-Liste server.allowed_origins
// (Env TALK_ALLOWED_ORIGINS): exakte Origins wie
// "https://chat.az-gruppe.com"; der Eintrag "*" erlaubt jede Origin
// ausdrücklich. Bewusster Default: LEER — dann sendet der Daemon gar keine
// CORS-Header und der Browser blockt (Tür zu, bis sie konfiguriert wird).
// CORS door for POST /speak, and ONLY for this endpoint: a browser (e.g. a
// later LibreChat integration) may call exactly the announcement interface
// cross-origin, never the control endpoints. Permission comes exclusively
// from the config list server.allowed_origins (env TALK_ALLOWED_ORIGINS):
// exact origins such as "https://chat.example.com"; the entry "*" explicitly
// allows any origin. Deliberate default: EMPTY. The daemon then sends no
// CORS headers at all and the browser blocks (door closed until configured).
//
// Die Tür ist serverseitig zu, nicht nur browserseitig (originGuard): CORS
// allein verhindert nur das LESEN der Antwort, nicht die Wirkung — ein
// Browser schickt einen POST mit text/plain-Body oder ein Formular ohne
// Preflight ab, und ein Server, der den Body dennoch als JSON liest, hätte
// gesprochen, gemutet oder abgebrochen. Deshalb lehnt talkd jede Anfrage
// mit fremdem Origin-Header ab (403), die nicht der erlaubte Weg über
// /speak ist. Nur der Preflight OPTIONS /speak bleibt für ungelistete
// Origins ein 204 ohne CORS-Header (er hat keine Wirkung; der Browser
// verwirft den geplanten POST). CLI, curl und andere Nicht-Browser-Clients
// senden keinen Origin-Header und sind nicht betroffen.
// The door is closed server-side, not only browser-side (originGuard): CORS
// alone only prevents READING the response, not the effect. A browser sends
// a POST with a text/plain body or a form without preflight, and a server
// that still reads the body as JSON would have spoken, muted or cancelled.
// Therefore talkd rejects every request with a foreign Origin header (403)
// that is not the permitted path via /speak. Only the preflight OPTIONS
// /speak stays a 204 without CORS headers for unlisted origins (it has no
// effect; the browser drops the planned POST). CLI, curl and other
// non-browser clients send no Origin header and are unaffected.
// CORS-Antwort-Header (Namen zentral, damit Preflight und echter POST
// dieselben setzen).
// CORS response headers (names in one place so preflight and the real POST
// set the same ones).
const (
headerAllowOrigin = "Access-Control-Allow-Origin"
headerAllowMethods = "Access-Control-Allow-Methods"
@@ -35,13 +33,13 @@ const (
headerVary = "Vary"
)
// preflightAge ist die Preflight-Cache-Dauer: 10 Minuten reichen für eine
// Chat-Session und halten den OPTIONS-Chatter klein.
// preflightAge is the preflight cache duration: 10 minutes cover a chat
// session and keep the OPTIONS chatter small.
const preflightAge = "600"
// corsACAOLiefert den Wert für Access-Control-Allow-Origin: bei Wildcard-
// Konfiguration das literale "*", sonst die exakt gematchte Origin (die
// identisch mit dem Config-Eintrag ist). Leer heißt: nicht erlaubt.
// corsACAO returns the value for Access-Control-Allow-Origin: the literal
// "*" for a wildcard configuration, otherwise the exactly matched origin
// (identical to the config entry). Empty means: not allowed.
func (h *handler) corsACAO(r *http.Request) string {
origin := r.Header.Get("Origin")
if origin == "" {
@@ -58,10 +56,9 @@ func (h *handler) corsACAO(r *http.Request) string {
return ""
}
// setSpeakCors setzt die CORS-Header auf echten POST-/speak-Antworten,
// wenn die Anfrage-Origin erlaubt ist. Vary: Origin steht immer (auch bei
// geschlossener Tür), damit Caches die Antwort nie fälschlich origin-
// übergreifend wiederverwenden.
// setSpeakCors sets the CORS headers on real POST /speak responses when the
// request origin is allowed. Vary: Origin is always present (even with the
// door closed) so caches never wrongly reuse the response across origins.
func (h *handler) setSpeakCors(w http.ResponseWriter, r *http.Request) {
w.Header().Add(headerVary, "Origin")
if acao := h.corsACAO(r); acao != "" {
@@ -69,11 +66,11 @@ func (h *handler) setSpeakCors(w http.ResponseWriter, r *http.Request) {
}
}
// speakPreflight behandelt OPTIONS /speak (CORS-Preflight): 204 mit den
// Freigabe-Headern bei gelisteter Origin, 204 ohne jegliche CORS-Header
// sonst (ungelistet, fehlend oder Tür komplett zu) — der Browser verwirft
// den eigentlichen Request, während der Daemon für Nicht-Browser-Clients
// erreichbar bleibt.
// speakPreflight handles OPTIONS /speak (CORS preflight): 204 with the
// permission headers for a listed origin, 204 without any CORS headers
// otherwise (unlisted, missing or door completely closed). The browser
// drops the actual request while the daemon stays reachable for
// non-browser clients.
func (h *handler) speakPreflight(w http.ResponseWriter, r *http.Request) {
w.Header().Add(headerVary, "Origin")
if acao := h.corsACAO(r); acao != "" {
@@ -85,13 +82,13 @@ func (h *handler) speakPreflight(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// originGuard ist die Middleware vor dem Router: Anfragen mit Origin-Header
// (also aus einem Browser) kommen nur als OPTIONS /speak (Preflight, ohne
// Wirkung) oder als POST /speak von gelisteter Origin durch. Alles andere —
// ungelistete Origin auf /speak, jeder Browser-Zugriff auf Steuer-Endpunkte
// und /mcp — wird mit 403 abgelehnt, bevor ein Body gelesen oder eine
// Wirkung ausgelöst wird. Ohne Origin-Header (CLI, curl, MCP-Clients)
// passiert nichts.
// originGuard is the middleware in front of the router: requests with an
// Origin header (i.e. from a browser) only pass as OPTIONS /speak (preflight,
// no effect) or as POST /speak from a listed origin. Everything else
// (unlisted origin on /speak, any browser access to control endpoints and
// /mcp) is rejected with 403 before a body is read or an effect is
// triggered. Without an Origin header (CLI, curl, MCP clients) nothing
// happens.
func (h *handler) originGuard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
@@ -106,6 +103,6 @@ func (h *handler) originGuard(next http.Handler) http.Handler {
}
w.Header().Add(headerVary, "Origin")
writeError(w, http.StatusForbidden,
"Origin "+origin+" ist nicht erlaubt (Browser-Zugriff nur auf POST /speak von gelisteten Origins in server.allowed_origins)")
"origin "+origin+" is not allowed (browsers may only POST /speak from origins listed in server.allowed_origins)")
})
}
+9 -10
View File
@@ -1,11 +1,10 @@
// Package api definiert die HTTP-Oberfläche des Daemons auf 127.0.0.1:
// POST /speak (enqueue, 202 {id}; bei Mute 202 {muted:true}; bei Gesetz-
// Verwurf 202 {discarded:"rate_limit"|"quiet_hours"} — Task 05),
// GET /status (inkl. Gesetz-State, mcp/cors — Task 06), POST /cancel,
// POST /mute, POST /unmute. Task 06 ergänzt die CORS-Tür für POST /speak
// (OPTIONS-Preflight + ACAO aus server.allowed_origins, siehe cors.go)
// und den MCP-Endpoint POST /mcp als schlankes eigenes JSON-RPC-Subset
// (initialize, notifications/initialized, tools/list, tools/call mit dem
// speak-Tool — siehe mcp.go). Der Client-Teil ist die CLI-Seite derselben
// Vertragsschnittstelle.
// Package api defines the daemon's HTTP surface on 127.0.0.1: POST /speak
// (enqueue, 202 {id}; 202 {muted:true} when muted; 202
// {discarded:"rate_limit"|"quiet_hours"} when a law discards), GET /status
// (including law state, mcp/cors), POST /cancel, POST /mute, POST /unmute.
// It also provides the CORS door for POST /speak (OPTIONS preflight + ACAO
// from server.allowed_origins, see cors.go) and the MCP endpoint POST /mcp
// as a lean JSON-RPC subset of its own (initialize,
// notifications/initialized, tools/list, tools/call with the speak tool;
// see mcp.go). The client part is the CLI side of the same contract.
package api
+94 -98
View File
@@ -10,33 +10,32 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/queue"
)
// MCP-Endpoint (Task 06): Streamable HTTP auf POST /mcp — eine eigene,
// schlanke Implementierung des JSON-RPC-2.0-Subsets, das MCP tatsächlich
// braucht (bewusst KEIN SDK-Dependency; der Protokollumfang hier ist vier
// Methoden plus ping). Semantik nach MCP 2025-06-18, mit zwei
// dokumentierten Vereinfachungen, die der Transport hergibt:
// MCP endpoint: Streamable HTTP on POST /mcp, a lean implementation of our
// own of the JSON-RPC 2.0 subset that MCP actually needs (deliberately NO
// SDK dependency; the protocol scope here is four methods plus ping).
// Semantics per MCP 2025-06-18, with two documented simplifications the
// transport allows:
//
// - Zustandslos: Jeder POST ist unabhängig; es gibt keine Session-IDs
// (Mcp-Session-Id wird ignoriert) und damit auch keine Durchsetzung
// der Initialize-vor-allem-Regel — ein curl kann tools/call direkt
// rufen. MCP-Clients stört das nicht: Sie fahren den Handshake
// freiwillig.
// - Keine server-initiierten Streams: talkd pusht nie (die Tool-Liste
// ist statisch). GET /mcp antwortet daher 405 — die vom Streamable-
// HTTP-Transport vorgesehene Antwort für Server ohne SSE-Stream.
// - Stateless: every POST is independent; there are no session IDs
// (Mcp-Session-Id is ignored) and therefore no enforcement of the
// initialize-first rule; a curl can call tools/call directly. MCP
// clients do not mind: they run the handshake voluntarily.
// - No server-initiated streams: talkd never pushes (the tool list is
// static). GET /mcp therefore answers 405, the response the Streamable
// HTTP transport specifies for servers without an SSE stream.
//
// Antworten: Requests → 200 application/json (JSON-RPC-Fehlerobjekte
// ebenfalls 200 — der HTTP-Teil war ja erfolgreich); Notifications → 202
// ohne Body (JSON-RPC 2.0). Batches (Arrays) sind seit 2025-06-18 nicht
// mehr Teil von MCP → -32600.
// Responses: requests get 200 application/json (JSON-RPC error objects also
// 200; the HTTP part did succeed); notifications get 202 without a body
// (JSON-RPC 2.0). Batches (arrays) are no longer part of MCP since
// 2025-06-18 and yield -32600.
// mcpProtocolVersion ist die Version, die talkd selbst spricht, wenn der
// Client keine angibt (initialize ohne protocolVersion). Angefragte
// Versionen werden akzeptiert und zurückgegeben (Echo) — inhaltlich
// implementiert ist der oben dokumentierte 2025-06-18-Umfang.
// mcpProtocolVersion is the version talkd itself speaks when the client
// names none (initialize without protocolVersion). Requested versions are
// accepted and echoed back; what is implemented is the 2025-06-18 scope
// documented above.
const mcpProtocolVersion = "2025-06-18"
// JSON-RPC-2.0-Fehlercodes (benutztes Subset).
// JSON-RPC 2.0 error codes (subset in use).
const (
rpcParseError = -32700
rpcInvalidRequest = -32600
@@ -45,12 +44,11 @@ const (
rpcInternalError = -32603
)
// mcpServerName ist der serverInfo-Name im Initialize-Ergebnis.
// mcpServerName is the serverInfo name in the initialize result.
const mcpServerName = "talkd"
// jsonRPCRequest ist eine eingehende JSON-RPC-Nachricht. ID als
// RawMessage: So bleibt zwischen „Feld fehlt" (Notification) und id:0
// bzw. id:string unterscheidbar.
// jsonRPCRequest is an incoming JSON-RPC message. ID as RawMessage keeps
// "field missing" (notification) distinguishable from id:0 or id:string.
type jsonRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
@@ -58,9 +56,9 @@ type jsonRPCRequest struct {
Params json.RawMessage `json:"params"`
}
// jsonRPCResponse ist eine ausgehende JSON-RPC-Nachricht. Genau eines aus
// Result/Error ist gesetzt. ID ist nie weggelassen — bei Parse-Fehlern
// ohne bekannte ID null (JSON-RPC 2.0).
// jsonRPCResponse is an outgoing JSON-RPC message. Exactly one of
// Result/Error is set. ID is never omitted; null on parse errors without a
// known ID (JSON-RPC 2.0).
type jsonRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
@@ -68,112 +66,112 @@ type jsonRPCResponse struct {
Error *rpcError `json:"error,omitempty"`
}
// rpcError ist das Fehlerobjekt einer JSON-RPC-Antwort.
// rpcError is the error object of a JSON-RPC response.
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// mcpInitializeParams sind die relevanten Teile der initialize-Parameter
// (Client-Capabilities interessieren talkd nicht — Tools genügen).
// mcpInitializeParams holds the relevant parts of the initialize params
// (client capabilities do not interest talkd; tools suffice).
type mcpInitializeParams struct {
ProtocolVersion string `json:"protocolVersion"`
}
// mcpServerInfo beschreibt den Server im Initialize-Ergebnis.
// mcpServerInfo describes the server in the initialize result.
type mcpServerInfo struct {
Name string `json:"name"`
Version string `json:"version"`
}
// mcpCapabilities meldet im Initialize-Ergebnis, was talkd anbietet:
// Tools (speak) — sonst nichts.
// mcpCapabilities reports in the initialize result what talkd offers:
// tools (speak), nothing else.
type mcpCapabilities struct {
Tools map[string]any `json:"tools"`
}
// mcpInitializeResult ist das Ergebnis von initialize: die (geechote)
// Protokollversion, die Fähigkeiten und die Server-Info — 2025-06-18-Form.
// mcpInitializeResult is the result of initialize: the (echoed) protocol
// version, the capabilities and the server info, in 2025-06-18 form.
type mcpInitializeResult struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities mcpCapabilities `json:"capabilities"`
ServerInfo mcpServerInfo `json:"serverInfo"`
}
// mcpToolsListResult ist das Ergebnis von tools/list: genau ein Tool
// (speak); ohne nextCursor (die Liste passt immer in eine Seite).
// mcpToolsListResult is the result of tools/list: exactly one tool (speak);
// no nextCursor (the list always fits on one page).
type mcpToolsListResult struct {
Tools []mcpTool `json:"tools"`
}
// mcpTool ist ein Tool-Eintrag in tools/list.
// mcpTool is a tool entry in tools/list.
type mcpTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema mcpSchema `json:"inputSchema"`
}
// mcpSchema ist das JSON-Schema-Objekt eines Tool-Inputs.
// mcpSchema is the JSON Schema object of a tool input.
type mcpSchema struct {
Type string `json:"type"`
Properties map[string]mcpProperty `json:"properties"`
Required []string `json:"required"`
}
// mcpProperty ist eine einzelne Schema-Property.
// mcpProperty is a single schema property.
type mcpProperty struct {
Type string `json:"type"`
Description string `json:"description,omitempty"`
Enum []string `json:"enum,omitempty"`
}
// mcpToolCallParams sind die Parameter von tools/call.
// mcpToolCallParams are the parameters of tools/call.
type mcpToolCallParams struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
// mcpContent ist ein Content-Block eines Tool-Ergebnisses (hier: Text).
// mcpContent is a content block of a tool result (here: text).
type mcpContent struct {
Type string `json:"type"`
Text string `json:"text"`
}
// mcpToolResult ist das Ergebnis von tools/call. isError:false (weggelassen)
// auch bei muted/discarded — das ist ein ERGEBNIS der Aufnahme, kein
// Protokollfehler (bewusste Entscheidung, siehe speakToolResult).
// mcpToolResult is the result of tools/call. isError:false (omitted) also
// for muted/discarded: that is a RESULT of admission, not a protocol error
// (deliberate decision, see speakToolResult).
type mcpToolResult struct {
Content []mcpContent `json:"content"`
IsError bool `json:"isError,omitempty"`
}
// serveMCP behandelt POST /mcp: eine JSON-RPC-Nachricht pro Request.
// serveMCP handles POST /mcp: one JSON-RPC message per request.
func (h *handler) serveMCP(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes))
if err != nil {
writeRPCError(w, nil, rpcParseError, "Body lesen: "+err.Error())
writeRPCError(w, nil, rpcParseError, "reading body: "+err.Error())
return
}
if strings.HasPrefix(strings.TrimLeft(string(body), " \t\r\n"), "[") {
writeRPCError(w, nil, rpcInvalidRequest,
"Batch-Requests werden nicht unterstützt (MCP 2025-06-18 hat Batches entfernt) — eine Nachricht pro POST")
"batch requests are not supported (MCP 2025-06-18 removed batches); one message per POST")
return
}
var req jsonRPCRequest
if err := json.Unmarshal(body, &req); err != nil {
writeRPCError(w, nil, rpcParseError, "ungültiges JSON: "+err.Error())
writeRPCError(w, nil, rpcParseError, "invalid JSON: "+err.Error())
return
}
if req.JSONRPC != "2.0" {
writeRPCError(w, nil, rpcInvalidRequest,
`jsonrpc muss "2.0" sein (erhalten: `+strconv.Quote(req.JSONRPC)+`)`)
`jsonrpc must be "2.0" (got `+strconv.Quote(req.JSONRPC)+`)`)
return
}
if isNotification(req.ID) {
// Notifications (notifications/initialized, …) bekommen nie eine
// JSON-RPC-Antwort — nur den 202-Vermerk des Transports. Unbekannte
// Notifications werden still ignoriert (JSON-RPC 2.0).
// Notifications (notifications/initialized, ...) never get a
// JSON-RPC response, only the transport's 202. Unknown
// notifications are silently ignored (JSON-RPC 2.0).
w.WriteHeader(http.StatusAccepted)
return
}
@@ -182,8 +180,8 @@ func (h *handler) serveMCP(w http.ResponseWriter, r *http.Request) {
case "initialize":
h.mcpInitialize(w, req)
case "notifications/initialized":
// Mit ID gesendet: kein regulärer Client — die korrekte Antwort
// ist ein leeres Ergebnis, kein Fehler.
// Sent with an ID: not a regular client; the correct answer is an
// empty result, not an error.
writeRPCResult(w, req.ID, struct{}{})
case "ping":
writeRPCResult(w, req.ID, struct{}{})
@@ -193,26 +191,26 @@ func (h *handler) serveMCP(w http.ResponseWriter, r *http.Request) {
h.mcpToolsCall(w, req)
default:
writeRPCError(w, req.ID, rpcMethodNotFound,
"Methode "+strconv.Quote(req.Method)+" ist nicht implementiert (verfügbar: initialize, notifications/initialized, ping, tools/list, tools/call)")
"method "+strconv.Quote(req.Method)+" is not implemented (available: initialize, notifications/initialized, ping, tools/list, tools/call)")
}
}
// mcpGet behandelt GET /mcp: 405 mit Allow — talkd bietet keine
// server-initiierten Streams an (die dafür vorgesehene Streamable-HTTP-
// Antwort), Tools-Änderungen gibt es nicht.
// mcpGet handles GET /mcp: 405 with Allow. talkd offers no server-initiated
// streams (the Streamable HTTP response for that case); there are no tool
// changes.
func (h *handler) mcpGet(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Allow", "POST, OPTIONS")
w.WriteHeader(http.StatusMethodNotAllowed)
}
// mcpInitialize behandelt initialize: Die angefragte Protokollversion wird
// geechot (akzeptiert) — leer → die eigene (2025-06-18). Capabilities:
// Tools; serverInfo: talkd + Daemon-Version.
// mcpInitialize handles initialize: the requested protocol version is
// echoed (accepted); empty means our own (2025-06-18). Capabilities: tools;
// serverInfo: talkd + daemon version.
func (h *handler) mcpInitialize(w http.ResponseWriter, req jsonRPCRequest) {
var params mcpInitializeParams
if len(req.Params) > 0 {
if err := json.Unmarshal(req.Params, &params); err != nil {
writeRPCError(w, req.ID, rpcInvalidParams, "initialize-params: "+err.Error())
writeRPCError(w, req.ID, rpcInvalidParams, "initialize params: "+err.Error())
return
}
}
@@ -227,18 +225,18 @@ func (h *handler) mcpInitialize(w http.ResponseWriter, req jsonRPCRequest) {
})
}
// mcpToolsCall behandelt tools/call: genau ein Tool (speak), dessen
// Argumente denselben Aufnahme-Pfad wie POST /speak fahren — inklusive
// Mute und Gesetz (Rate-Limit/Ruhezeit).
// mcpToolsCall handles tools/call: exactly one tool (speak) whose arguments
// take the same admission path as POST /speak, including mute and the laws
// (rate limit/quiet hours).
func (h *handler) mcpToolsCall(w http.ResponseWriter, req jsonRPCRequest) {
var params mcpToolCallParams
if err := json.Unmarshal(req.Params, &params); err != nil {
writeRPCError(w, req.ID, rpcInvalidParams, "tools/call-params: "+err.Error())
writeRPCError(w, req.ID, rpcInvalidParams, "tools/call params: "+err.Error())
return
}
if params.Name != "speak" {
writeRPCError(w, req.ID, rpcInvalidParams,
"unbekanntes Tool "+strconv.Quote(params.Name)+" (verfügbar: speak)")
"unknown tool "+strconv.Quote(params.Name)+" (available: speak)")
return
}
@@ -254,8 +252,8 @@ func (h *handler) mcpToolsCall(w http.ResponseWriter, req jsonRPCRequest) {
adm := h.admitSpeak(speakReq)
if adm.status != http.StatusAccepted {
// Validierungs-/Interner-Fehler (HTTP-Äquivalent 400/503): als
// JSON-RPC-Fehler melden — das war kein Aufnahme-Ergebnis.
// Validation/internal error (HTTP equivalent 400/503): report as a
// JSON-RPC error; this was not an admission result.
code := rpcInvalidParams
if adm.status != http.StatusBadRequest {
code = rpcInternalError
@@ -266,38 +264,37 @@ func (h *handler) mcpToolsCall(w http.ResponseWriter, req jsonRPCRequest) {
writeRPCResult(w, req.ID, speakToolResult(adm.resp))
}
// speakTool ist die Tool-Beschreibung von speak: Input-Schema aus dem
// Task-06-Brief (text Pflicht; priority/provider/lang/now optional).
// speakTool is the tool description of speak: input schema with text
// required; priority/provider/lang/now optional.
func speakTool() mcpTool {
return mcpTool{
Name: "speak",
Description: "Spricht eine kurze Ansage (Status, nächster Schritt, " +
"Freigabe-Bedarf — nie Langantworten). Läuft über den Daemon: " +
"Queue, Rate-Limit, Mute und Ruhezeiten gelten.",
Description: "Speaks a short announcement (status, next step, what the user is needed for; " +
"never long answers). Goes through the daemon: queue, rate limit, mute and quiet hours apply.",
InputSchema: mcpSchema{
Type: "object",
Properties: map[string]mcpProperty{
"text": {
Type: "string",
Description: "Der Ansage-Text (1–2 Sätze Etikette)",
Description: "The announcement text (1 to 2 sentences by etiquette)",
},
"priority": {
Type: "string",
Enum: []string{"low", "normal", "high"},
Description: "Abarbeitungs-Priorität (Default normal)",
Description: "Processing priority (default normal)",
},
"provider": {
Type: "string",
Description: "Provider pin (elevenlabs, openai, edge, osbuiltin); leer = Default-Kette",
Description: "Provider pin (elevenlabs, openai, edge, osbuiltin); empty = default chain",
},
"lang": {
Type: "string",
Enum: []string{"de", "en"},
Description: "Sprache pin; leer = automatische Erkennung",
Description: "Language pin; empty = automatic detection",
},
"now": {
Type: "boolean",
Description: "Springt vor die Warteschlange und bricht bewusst aus Rate-Limit und Ruhezeit aus",
Description: "Jumps ahead of the queue and deliberately bypasses rate limit and quiet hours",
},
},
Required: []string{"text"},
@@ -305,27 +302,26 @@ func speakTool() mcpTool {
}
}
// speakToolResult übersetzt eine /speak-Aufnahme-Antwort in das MCP-Tool-
// Ergebnis. Mute- und Gesetz-Verwürfe sind bewusst KEIN isError: Sie sind
// das korrekte dokumentierte Ergebnis der Aufnahme (kein Protokollfehler)
// — der Agent soll den Grund lesen, nicht crashen. Erfolgreiche Aufnahme:
// "queued: <id>".
// speakToolResult translates a /speak admission response into the MCP tool
// result. Mute and law discards are deliberately NOT isError: they are the
// correct, documented result of admission (no protocol error); the agent
// should read the reason, not crash. Successful admission: "queued: <id>".
func speakToolResult(resp SpeakResponse) mcpToolResult {
switch {
case resp.Muted:
return mcpToolResult{Content: []mcpContent{{
Type: "text",
Text: "muted: Ansage verworfen (Mute ist aktiv)",
Text: "muted: announcement discarded (mute is on)",
}}}
case resp.Discarded == queue.DiscardRateLimit:
return mcpToolResult{Content: []mcpContent{{
Type: "text",
Text: "discarded: rate_limit — Ansage verworfen (Priorität low über dem Fenster-Budget)",
Text: "discarded: rate_limit; announcement discarded (priority low over the window budget)",
}}}
case resp.Discarded == queue.DiscardQuietHours:
text := "discarded: quiet_hours — Ruhezeit aktiv, Ansage verworfen"
text := "discarded: quiet_hours; quiet hours active, announcement discarded"
if resp.Toast {
text += " (durch Toast ersetzt, kein Audio)"
text += " (replaced by a toast, no audio)"
}
return mcpToolResult{Content: []mcpContent{{Type: "text", Text: text}}}
case resp.Discarded != "":
@@ -336,26 +332,26 @@ func speakToolResult(resp SpeakResponse) mcpToolResult {
default:
text := "queued: " + resp.ID
if resp.Truncated {
text += " (Text auf max_text gekürzt — Ansagen sind 1–2 Sätze, keine Langantworten)"
text += " (text truncated to max_text; announcements are 1 to 2 sentences, not long answers)"
}
return mcpToolResult{Content: []mcpContent{{Type: "text", Text: text}}}
}
}
// isNotification meldet, ob die ID eine Notification anzeigt (Feld fehlt
// oder explizit null — beides nach JSON-RPC 2.0 ohne Antwort).
// isNotification reports whether the ID marks a notification (field missing
// or explicitly null; both get no response per JSON-RPC 2.0).
func isNotification(id json.RawMessage) bool {
return len(id) == 0 || strings.TrimSpace(string(id)) == "null"
}
// writeRPCResult schreibt eine erfolgreiche JSON-RPC-Antwort (200,
// writeRPCResult writes a successful JSON-RPC response (200,
// application/json).
func writeRPCResult(w http.ResponseWriter, id json.RawMessage, result any) {
writeRPC(w, jsonRPCResponse{JSONRPC: "2.0", ID: id, Result: result})
}
// writeRPCError schreibt eine JSON-RPC-Fehler-Antwort; id null, wenn die
// Anfrage-ID unbekannt ist (Parse-Fehler).
// writeRPCError writes a JSON-RPC error response; id null when the request
// ID is unknown (parse error).
func writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg string) {
writeRPC(w, jsonRPCResponse{
JSONRPC: "2.0",
@@ -364,7 +360,7 @@ func writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg stri
})
}
// writeRPC schreibt die JSON-RPC-Antwort in den Response-Writer.
// writeRPC writes the JSON-RPC response to the response writer.
func writeRPC(w http.ResponseWriter, resp jsonRPCResponse) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
+65 -64
View File
@@ -10,45 +10,45 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/queue"
)
// maxBodyBytes begrenzt Anfrage-Körper (Ansagen sind kurz; Schutz gegen
// versehentliche Riesen-Payloads).
// maxBodyBytes limits request bodies (announcements are short; protection
// against accidental huge payloads).
const maxBodyBytes = 64 << 10
// HandlerConfig bündelt die Abhängigkeiten der HTTP-Oberfläche. Der Handler
// kennt weder Config-Dateien noch Env — alles wird vom Daemon (cmd/talkd)
// injiziert; Prozessgrenzen-Tests fahren genau diese Oberfläche als Black Box.
// HandlerConfig bundles the dependencies of the HTTP surface. The handler
// knows neither config files nor env; the daemon injects everything, and
// process-boundary tests drive exactly this surface as a black box.
type HandlerConfig struct {
// Queue ist die Daemon-Queue (Enqueue/Cancel/Current/Pending).
// Queue is the daemon queue (Enqueue/Cancel/Current/Pending).
Queue *queue.Queue
// Mute ist der persistente Mute-State (Datei mute im Config-Dir).
// Mute is the persistent mute state (file mute in the config dir).
Mute queue.MuteStore
// Version ist die Daemon-Version für GET /status und serverInfo.
// Version is the daemon version for GET /status and serverInfo.
Version string
// KnownProviders sind die gültigen Provider-Namen (Validierung des
// provider-Felds in POST /speak; talkd liefert provider.Names()).
// KnownProviders are the valid provider names (validation of the
// provider field in POST /speak; the daemon passes provider.Names()).
KnownProviders []string
// Provider löst den aktiven Provider für GET /status auf (Daemon-seitig:
// Pin des laufenden Items bzw. erste verfügbare Ketten-Stufe). Optional.
// Provider resolves the active provider for GET /status (daemon side:
// pin of the running item or first available chain tier). Optional.
Provider func() string
// AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (leer =
// keine CORS-Header, der Browser blockt; siehe cors.go).
// AllowedOrigins is the CORS allow list for POST /speak (empty = no CORS
// headers, the browser blocks; see cors.go).
AllowedOrigins []string
// Shutdown fährt den Daemon herunter (POST /shutdown, `talk daemon
// stop`). Muss nicht blockieren und mehrfach aufrufbar sein. Optional:
// nil → der Endpunkt antwortet 501.
// Shutdown shuts the daemon down (POST /shutdown, `talk daemon stop`).
// Must not block and must be callable more than once. Optional: nil
// means the endpoint answers 501.
Shutdown func()
}
// NewHandler liefert die HTTP-Routen des Daemons. Methoden-Bindung per
// ServeMux-Muster: falsche Methode → 405, unbekannter Pfad → 404. Vor dem
// Router steht der originGuard (cors.go): Browser-Anfragen (Origin-Header)
// erreichen nur POST /speak von gelisteten Origins; alles andere ist 403.
// NewHandler returns the daemon's HTTP routes. Method binding via ServeMux
// patterns: wrong method 405, unknown path 404. The originGuard (cors.go)
// sits in front of the router: browser requests (Origin header) only reach
// POST /speak from listed origins; everything else is 403.
func NewHandler(cfg HandlerConfig) http.Handler {
h := &handler{
queue: cfg.Queue,
@@ -64,18 +64,18 @@ func NewHandler(cfg HandlerConfig) http.Handler {
}
mux := http.NewServeMux()
mux.HandleFunc("POST /speak", h.speak)
mux.HandleFunc("OPTIONS /speak", h.speakPreflight) // CORS-Preflight (Task 06)
mux.HandleFunc("OPTIONS /speak", h.speakPreflight) // CORS preflight
mux.HandleFunc("GET /status", h.status)
mux.HandleFunc("POST /cancel", h.cancel)
mux.HandleFunc("POST /mute", h.muteEndpoint(true))
mux.HandleFunc("POST /unmute", h.muteEndpoint(false))
mux.HandleFunc("POST /shutdown", h.shutdownEndpoint)
mux.HandleFunc("POST /mcp", h.serveMCP) // MCP Streamable HTTP (Task 06)
mux.HandleFunc("GET /mcp", h.mcpGet) // 405: keine server-initiierten Streams
mux.HandleFunc("POST /mcp", h.serveMCP) // MCP Streamable HTTP
mux.HandleFunc("GET /mcp", h.mcpGet) // 405: no server-initiated streams
return h.originGuard(mux)
}
// handler ist der interne Zustand der HTTP-Oberfläche.
// handler is the internal state of the HTTP surface.
type handler struct {
queue *queue.Queue
mute queue.MuteStore
@@ -86,18 +86,18 @@ type handler struct {
shutdown func()
}
// speak behandelt POST /speak: validieren, bei Mute verwerfen (202,
// dokumentiertes Verhalten — bewusst kein Fehler, damit Aufrufer keine
// Sonderbehandlung brauchen), sonst enqueue → 202 {id}. Das Gesetz
// (Rate-Limit-Budget, Ruhezeit) kann beim Eintritt ebenfalls verwerfen —
// die Antwort nennt dann den Grund ({"discarded":"…"}). CORS-Header nur
// hier und nur bei gelisteter Origin (siehe cors.go).
// speak handles POST /speak: validate, discard when muted (202, documented
// behaviour; deliberately not an error so callers need no special
// handling), otherwise enqueue and answer 202 {id}. The laws (rate-limit
// budget, quiet hours) may also discard on admission; the response then
// names the reason ({"discarded":"..."}). CORS headers only here and only
// for a listed origin (see cors.go).
func (h *handler) speak(w http.ResponseWriter, r *http.Request) {
h.setSpeakCors(w, r)
var req SpeakRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "ungültiger JSON-Body: "+err.Error())
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
adm := h.admitSpeak(req)
@@ -108,38 +108,38 @@ func (h *handler) speak(w http.ResponseWriter, r *http.Request) {
writeJSON(w, adm.status, adm.resp)
}
// speakAdmission ist das Ergebnis des gemeinsamen Aufnahme-Pfads von
// POST /speak und dem MCP-Tool speak: HTTP-Status-Äquivalent plus Body —
// 202 mit {id}/{muted}/{discarded} als Aufnahme-Ergebnis, 400/503 mit
// errMsg als Fehler (MCP übersetzt beides, siehe mcp.go).
// speakAdmission is the result of the shared admission path of POST /speak
// and the MCP tool speak: an HTTP status equivalent plus body. 202 with
// {id}/{muted}/{discarded} as the admission result, 400/503 with errMsg as
// an error (MCP translates both, see mcp.go).
type speakAdmission struct {
status int
resp SpeakResponse
errMsg string
}
// admitSpeak ist der eine Aufnahme-Pfad hinter POST /speak und dem
// MCP-Tool speak (Task 06): Validierung, Mute-Check, Enqueue inklusive
// Gesetz (Rate-Limit-Budget, Ruhezeit) — identische Semantik für beide
// Türen, kein Contract-Drift.
// admitSpeak is the one admission path behind POST /speak and the MCP tool
// speak: validation, mute check, enqueue including the laws (rate-limit
// budget, quiet hours). Identical semantics for both doors, no contract
// drift.
func (h *handler) admitSpeak(req SpeakRequest) speakAdmission {
if strings.TrimSpace(req.Text) == "" {
return speakAdmission{status: http.StatusBadRequest, errMsg: "text fehlt oder ist leer"}
return speakAdmission{status: http.StatusBadRequest, errMsg: "text is missing or empty"}
}
if req.Priority == "" {
req.Priority = string(queue.PriorityNormal)
}
if !queue.Priority(req.Priority).Valid() {
return speakAdmission{status: http.StatusBadRequest,
errMsg: fmt.Sprintf("priority: ungültiger Wert %q (erlaubt: low, normal, high)", req.Priority)}
errMsg: fmt.Sprintf("priority: invalid value %q (allowed: low, normal, high)", req.Priority)}
}
if req.Provider != "" && !h.known[req.Provider] {
return speakAdmission{status: http.StatusBadRequest,
errMsg: fmt.Sprintf("provider: unbekannter Wert %q", req.Provider)}
errMsg: fmt.Sprintf("provider: unknown value %q", req.Provider)}
}
if req.Lang != "" && req.Lang != "de" && req.Lang != "en" {
return speakAdmission{status: http.StatusBadRequest,
errMsg: fmt.Sprintf("lang: ungültiger Wert %q (erlaubt: de, en)", req.Lang)}
errMsg: fmt.Sprintf("lang: invalid value %q (allowed: de, en)", req.Lang)}
}
if h.mute.Muted() {
@@ -158,18 +158,18 @@ func (h *handler) admitSpeak(req SpeakRequest) speakAdmission {
return speakAdmission{status: http.StatusServiceUnavailable, errMsg: "enqueue: " + err.Error()}
}
if adm.Discarded != "" {
// Gesetz-Verwurf (Rate-Limit-Budget / Ruhezeit): dokumentiertes
// Verhalten wie bei Mute — 202 mit Grund statt Fehler, damit
// Aufrufer keine Sonderbehandlung brauchen.
// Law discard (rate-limit budget / quiet hours): documented
// behaviour like mute, 202 with a reason instead of an error, so
// callers need no special handling.
return speakAdmission{status: http.StatusAccepted,
resp: SpeakResponse{Discarded: adm.Discarded, Toast: adm.Toast}}
}
return speakAdmission{status: http.StatusAccepted, resp: SpeakResponse{ID: adm.ID, Truncated: adm.Truncated}}
}
// status behandelt GET /status: Queue-Länge (+ wartende IDs), aktuelles
// Item, Mute-State, aktiver Provider, Version — und seit Task 05 den
// Gesetz-State (Rate-Limit mit nächstem Slot, Ruhezeit-Fenster).
// status handles GET /status: queue length (+ waiting IDs), current item,
// mute state, active provider, version, and the law state (rate limit with
// next slot, quiet-hours window).
func (h *handler) status(w http.ResponseWriter, r *http.Request) {
pending := h.queue.Pending()
ids := make([]string, len(pending))
@@ -201,17 +201,17 @@ func (h *handler) status(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, st)
}
// corsList kopiert die Erlaubnisliste als JSON-[] statt null: Der Status
// soll "cors": [] zeigen, wenn die Tür zu ist.
// corsList copies the allow list as a JSON [] instead of null: the status
// should show "cors": [] when the door is closed.
func corsList(origins []string) []string {
out := make([]string, len(origins))
copy(out, origins)
return out
}
// lawState überführt die Gesetz-Sicht der Queue in die JSON-Form von
// /status: Zeitstempel nur, wenn sie in der Zukunft liegen (null = Slot
// frei / Ruhezeit inaktiv).
// lawState converts the queue's law view into the JSON form of /status:
// timestamps only when they lie in the future (null = slot free / quiet
// hours inactive).
func lawState(l queue.LawState) (*RateLimitState, *QuietHoursState) {
now := time.Now()
rl := &RateLimitState{
@@ -234,13 +234,14 @@ func lawState(l queue.LawState) (*RateLimitState, *QuietHoursState) {
return rl, qh
}
// cancel behandelt POST /cancel: laufende Ansage abbrechen + Queue leeren.
// cancel handles POST /cancel: cancel the running announcement and clear
// the queue.
func (h *handler) cancel(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, CancelResponse{Cancelled: h.queue.Cancel()})
}
// muteEndpoint behandelt POST /mute bzw. POST /unmute: persistenter
// Mute-State (Datei im Config-Dir, überlebt Daemon-Restarts).
// muteEndpoint handles POST /mute and POST /unmute: persistent mute state
// (file in the config dir, survives daemon restarts).
func (h *handler) muteEndpoint(muted bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := h.mute.Set(muted); err != nil {
@@ -251,26 +252,26 @@ func (h *handler) muteEndpoint(muted bool) http.HandlerFunc {
}
}
// shutdownEndpoint behandelt POST /shutdown (`talk daemon stop`): Antwort
// zuerst, dann der Shutdown-Trigger — der Aufrufer bekommt sein 200, bevor
// der Server die Verbindung schließt.
// shutdownEndpoint handles POST /shutdown (`talk daemon stop`): response
// first, then the shutdown trigger, so the caller gets its 200 before the
// server closes the connection.
func (h *handler) shutdownEndpoint(w http.ResponseWriter, r *http.Request) {
if h.shutdown == nil {
writeError(w, http.StatusNotImplemented, "dieser Daemon kennt keinen Shutdown-Trigger")
writeError(w, http.StatusNotImplemented, "this daemon has no shutdown trigger")
return
}
writeJSON(w, http.StatusOK, ShutdownResponse{OK: true})
h.shutdown()
}
// writeJSON schreibt eine JSON-Antwort mit Status-Code.
// writeJSON writes a JSON response with a status code.
func writeJSON(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(body)
}
// writeError schreibt einen Fehler als {"error": "…"} mit Status-Code.
// writeError writes an error as {"error": "..."} with a status code.
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
+56 -55
View File
@@ -1,53 +1,53 @@
package api
// Request/Response-Typen des Daemon-HTTP-Vertrags (Task-04-Brief, bindende
// Felder). Die CLI und spätere Konsumenten (MCP, Task 06) benutzen dieselben
// Typen — eine Quelle, kein Contract-Drift zwischen Daemon und Client.
// Request/response types of the daemon HTTP contract (binding fields). The
// CLI and other consumers (MCP) use the same types: one source, no contract
// drift between daemon and client.
// SpeakRequest ist der Body von POST /speak.
// SpeakRequest is the body of POST /speak.
type SpeakRequest struct {
// Text ist der Ansage-Text (Pflicht).
// Text is the announcement text (required).
Text string `json:"text"`
// Priority: low | normal | high (Default normal, wenn leer).
// Priority: low | normal | high (default normal when empty).
Priority string `json:"priority,omitempty"`
// Provider pinnt die Provider-Kette (--provider).
// Provider pins the provider chain (--provider).
Provider string `json:"provider,omitempty"`
// Voice pinnt eine Voice-ID (--voice).
// Voice pins a voice ID (--voice).
Voice string `json:"voice,omitempty"`
// Lang pinnt die Sprache de | en (leer = automatische Erkennung).
// Lang pins the language de | en (empty = automatic detection).
Lang string `json:"lang,omitempty"`
// Now lässt die Ansage vor allen wartenden springen (--now).
// Now lets the announcement jump ahead of all waiting ones (--now).
Now bool `json:"now,omitempty"`
}
// SpeakResponse ist die Antwort von POST /speak: 202 {"id":"…"} — bei
// aktivem Mute 202 {"id":"","muted":true} (Ansage verworfen; dokumentiertes
// Verhalten, kein Fehler). Seit Task 05 meldet verworfenen Spam ebenso
// dokumentiert: 202 {"id":"","discarded":"rate_limit"|"quiet_hours"} —
// plus "toast":true, wenn die Ruhezeit-Ansage durch eine Benachrichtigung
// ersetzt wurde (quiet_mode = "toast").
// SpeakResponse is the response of POST /speak: 202 {"id":"..."}; with mute
// active 202 {"id":"","muted":true} (announcement discarded; documented
// behaviour, not an error). Discarded spam is reported just as
// documented: 202 {"id":"","discarded":"rate_limit"|"quiet_hours"}, plus
// "toast":true when the quiet-hours announcement was replaced by a
// notification (quiet_mode = "toast").
type SpeakResponse struct {
ID string `json:"id"`
Muted bool `json:"muted,omitempty"`
// Discarded ist der Verwurfs-Grund ("rate_limit" | "quiet_hours");
// leer, wenn die Ansage aufgenommen wurde.
// Discarded is the discard reason ("rate_limit" | "quiet_hours"); empty
// when the announcement was admitted.
Discarded string `json:"discarded,omitempty"`
// Toast meldet die Ersetzung durch eine Benachrichtigung.
// Toast reports the replacement by a notification.
Toast bool `json:"toast,omitempty"`
// Truncated meldet, dass der Text auf queue.max_text gekürzt und so
// aufgenommen wurde (Gesetz gegen vorgelesene Langantworten).
// Truncated reports that the text was cut to queue.max_text and
// admitted that way (the law against read-aloud long answers).
Truncated bool `json:"truncated,omitempty"`
}
// CurrentItem beschreibt das laufende Item in GET /status.
// CurrentItem describes the running item in GET /status.
type CurrentItem struct {
ID string `json:"id"`
Text string `json:"text"`
@@ -55,12 +55,12 @@ type CurrentItem struct {
Now bool `json:"now"`
}
// StatusResponse ist die Antwort von GET /status: Queue-Länge, aktuelles
// Item (null im Leerlauf), Mute-State, aktiver Provider, Daemon-Version.
// QueueIDs ist die Ergänzung der wartenden IDs in Abarbeitungs-Reihenfolge
// — die CLI braucht sie für ihr --wait-Polling. Task 05 ergänzt additiv
// den Gesetz-State (rate_limit, quiet_hours); Task 06 additiv mcp/cors
// (Compat-Felder: MCP-Endpoint vorhanden, konfigurierte CORS-Origins).
// StatusResponse is the response of GET /status: queue length, current item
// (null when idle), mute state, active provider, daemon version. QueueIDs
// lists the waiting IDs in processing order; the CLI needs them for its
// --wait polling. The law state (rate_limit, quiet_hours) and mcp/cors
// (compat fields: MCP endpoint present, configured CORS origins) are
// additive.
type StatusResponse struct {
Version string `json:"version"`
Queue int `json:"queue"`
@@ -72,59 +72,60 @@ type StatusResponse struct {
QuietHours *QuietHoursState `json:"quiet_hours,omitempty"`
Limits *LimitsState `json:"limits,omitempty"`
// MCP meldet, dass der Daemon den MCP-Endpoint anbietet (immer true;
// Feld existiert für additive Compat-Sicht von Clients/Browsern).
// MCP reports that the daemon offers the MCP endpoint (always true; the
// field exists for an additive compat view of clients/browsers).
MCP bool `json:"mcp"`
// Cors ist die konfigurierte CORS-Erlaubnisliste von POST /speak
// (leeres [] = Tür zu). Immer vorhanden (auch leer), nicht omitempty.
// Cors is the configured CORS allow list of POST /speak (empty [] =
// door closed). Always present (even when empty), not omitempty.
Cors []string `json:"cors"`
}
// RateLimitState ist der Rate-Limit-State in GET /status (Task 05).
// RateLimitState is the rate-limit state in GET /status.
type RateLimitState struct {
// Interval ist der konfigurierte Mindestabstand als Dauer-String
// ("30s"; "0s" = unbegrenzt).
// Interval is the configured minimum spacing as a duration string
// ("30s"; "0s" = unlimited).
Interval string `json:"interval"`
// AllowHighBurst meldet, ob priority=high verstoßen darf.
// AllowHighBurst reports whether priority=high may violate it.
AllowHighBurst bool `json:"allow_high_burst"`
// NextSlot ist der nächste freie Ansage-Slot als RFC3339-Zeitstempel;
// null, wenn sofort gespielt werden dürfte (oder unbegrenzt).
// NextSlot is the next free announcement slot as an RFC3339 timestamp;
// null when playback would be allowed immediately (or unlimited).
NextSlot *string `json:"next_slot"`
}
// LimitsState sind die Längen- und Alters-Gesetze in GET /status.
// LimitsState holds the length and age laws in GET /status.
type LimitsState struct {
// MaxText ist die Obergrenze der Ansage-Länge in Zeichen (0 = unbegrenzt).
// MaxText is the upper bound of the announcement length in characters
// (0 = unlimited).
MaxText int `json:"max_text"`
// MaxAge ist die längste Wartezeit in der Queue als Dauer-String
// ("2m0s"; "0s" = unbegrenzt).
// MaxAge is the longest wait in the queue as a duration string ("2m0s";
// "0s" = unlimited).
MaxAge string `json:"max_age"`
}
// QuietHoursState ist der Ruhezeit-State in GET /status (Task 05).
// QuietHoursState is the quiet-hours state in GET /status.
type QuietHoursState struct {
// Window ist das konfigurierte Fenster ("18:00-08:00"; "" = deaktiviert).
// Window is the configured window ("18:00-08:00"; "" = disabled).
Window string `json:"window"`
// Active meldet, ob die lokale Zeit gerade im Fenster liegt.
// Active reports whether local time is currently inside the window.
Active bool `json:"active"`
// Mode ist der konfigurierte Ruhezeit-Modus ("discard" | "toast").
// Mode is the configured quiet-hours mode ("discard" | "toast").
Mode string `json:"mode"`
// Until ist das Fenster-Ende als RFC3339-Zeitstempel, wenn aktiv;
// sonst null.
// Until is the window end as an RFC3339 timestamp when active;
// otherwise null.
Until *string `json:"until"`
}
// PendingOrCurrent meldet, ob die Ansage mit der ID noch wartet oder
// gerade läuft — Grundlage des --wait-Pollings der CLI. Ist die Ansage
// weder wartend noch laufend, ist sie fertig (oder abgebrochen) — in
// beiden Fällen hat der Aufrufer nichts mehr zu warten.
// PendingOrCurrent reports whether the announcement with the ID is still
// waiting or currently playing; the basis of the CLI's --wait polling. If
// it is neither waiting nor playing, it is done (or cancelled); in both
// cases the caller has nothing left to wait for.
func (s StatusResponse) PendingOrCurrent(id string) bool {
if s.Current != nil && s.Current.ID == id {
return true
@@ -137,17 +138,17 @@ func (s StatusResponse) PendingOrCurrent(id string) bool {
return false
}
// CancelResponse ist die Antwort von POST /cancel.
// CancelResponse is the response of POST /cancel.
type CancelResponse struct {
Cancelled int `json:"cancelled"`
}
// MuteResponse ist die Antwort von POST /mute und POST /unmute.
// MuteResponse is the response of POST /mute and POST /unmute.
type MuteResponse struct {
Muted bool `json:"muted"`
}
// ShutdownResponse ist die Antwort von POST /shutdown.
// ShutdownResponse is the response of POST /shutdown.
type ShutdownResponse struct {
OK bool `json:"ok"`
}
+19 -19
View File
@@ -6,28 +6,28 @@ import (
"io"
)
// Sink-Namen (Teil des öffentlichen Vertrags; Spec: audio.sink = "oto"|"null").
// Sink names (part of the public contract; spec: audio.sink = "oto"|"null").
const (
NameOto = "oto"
NameNull = "null"
)
// Sink gibt Audio-Bytes wieder. Die Bytes stammen aus Provider.Synthesize;
// oto versteht RIFF/WAVE (PCM — das Lieferformat von osbuiltin) und MP3
// (das Lieferformat der Cloud-Provider ElevenLabs/OpenAI/Edge).
// Sink plays audio bytes. The bytes come from Provider.Synthesize; oto
// understands RIFF/WAVE (PCM, the delivery format of osbuiltin) and MP3
// (the delivery format of the cloud providers ElevenLabs/OpenAI/Edge).
type Sink interface {
// Play spielt die Daten blockierend ab (bis zum Ende oder bis ctx
// abgebrochen wird).
// Play plays the data and blocks until the end or until ctx is
// cancelled.
Play(ctx context.Context, data []byte) error
// Name liefert den Sink-Namen (audio.sink-Konfiguration).
// Name returns the sink name (audio.sink configuration).
Name() string
}
// NewSink liefert den Sink zum konfigurierten Namen ("oto" oder "null").
// log ist ein optionaler Log-Output — die CLI übergibt ihr stderr, über den
// der null-Sink seine Verwurf-Zeile schreibt (nil = still verwerfen); der
// oto-Sink braucht kein Log. Unbekannte Namen sind ein klarer Fehler.
// NewSink returns the sink for the configured name ("oto" or "null"). log
// is an optional log output: the CLI passes its stderr so the null sink can
// write its discard line (nil = discard silently); the oto sink needs no
// log. Unknown names are a clear error.
func NewSink(name string, log io.Writer) (Sink, error) {
switch name {
case NameOto:
@@ -35,24 +35,24 @@ func NewSink(name string, log io.Writer) (Sink, error) {
case NameNull:
return NullSink{W: log}, nil
default:
return nil, fmt.Errorf("unbekannter audio sink %q (erlaubt: %q, %q)", name, NameOto, NameNull)
return nil, fmt.Errorf("unknown audio sink %q (allowed: %q, %q)", name, NameOto, NameNull)
}
}
// NullSink verwirft jede Ansage ohne Ton — für CI, Server und Tests. Wurde
// ein W gesetzt (NewSink reicht dafür den Log-Output der CLI durch), loggt
// Play eine Zeile darüber, damit „läuft ohne Ton durch" sichtbar bleibt.
// NullSink discards every announcement without sound; for CI, servers and
// tests. If W is set (NewSink passes the CLI's log output), Play logs one
// line about it so "runs through silently" stays visible.
type NullSink struct {
W io.Writer // optionaler Log-Output; nil = still verwerfen
W io.Writer // optional log output; nil = discard silently
}
// Name implementiert Sink.
// Name implements Sink.
func (NullSink) Name() string { return NameNull }
// Play implementiert Sink: verwirft die Daten.
// Play implements Sink: discards the data.
func (n NullSink) Play(ctx context.Context, data []byte) error {
if n.W != nil {
fmt.Fprintf(n.W, "audio: %d Bytes verworfen (null sink)\n", len(data))
fmt.Fprintf(n.W, "audio: %d bytes discarded (null sink)\n", len(data))
}
return nil
}
+39 -39
View File
@@ -10,9 +10,9 @@ import (
"testing"
)
// buildWAV baut nach RIFF-Spezifikation eine WAV-Datei (unabhängig vom
// Parser implementiert, damit Parser und Test-Fixture nicht denselben
// Fehler teilen).
// buildWAV builds a WAV file per the RIFF specification (implemented
// independently of the parser so parser and test fixture cannot share the
// same bug).
func buildWAV(audioFormat uint16, channels, rate, bits int, samples []byte) []byte {
var buf bytes.Buffer
buf.WriteString("RIFF")
@@ -44,22 +44,22 @@ func TestDecodeWAV(t *testing.T) {
t.Fatalf("decodeWAV: %v", err)
}
if f.SampleRate != 22050 || f.Channels != 1 || f.BitsPerCoded != 16 || f.Float {
t.Errorf("Format = %+v", f)
t.Errorf("format = %+v", f)
}
if string(pcm) != "\x01\x02\x03\x04" {
t.Errorf("PCM = %x", pcm)
}
})
t.Run("8-bit stereo mit Extra-Chunk", func(t *testing.T) {
t.Run("8-bit stereo with extra chunk", func(t *testing.T) {
wav := buildWAV(1, 2, 8000, 8, []byte{7, 8, 9})
// LIST-Chunk (word-aligned, ungerade Länge) zwischen fmt und data einfügen
// insert a LIST chunk (word-aligned, odd length) between fmt and data
withList := append(wav[:36], append([]byte("LIST\x03\x00\x00\x00abc\x00"), wav[36:]...)...)
f, pcm, err := decodeWAV(withList)
if err != nil {
t.Fatalf("decodeWAV: %v", err)
}
if f.BitsPerCoded != 8 || f.Channels != 2 {
t.Errorf("Format = %+v", f)
t.Errorf("format = %+v", f)
}
if string(pcm) != "\x07\x08\x09" {
t.Errorf("PCM = %x", pcm)
@@ -72,42 +72,42 @@ func TestDecodeWAV(t *testing.T) {
t.Fatalf("decodeWAV: %v", err)
}
if !f.Float || f.BitsPerCoded != 32 {
t.Errorf("Format = %+v, want Float32", f)
t.Errorf("format = %+v, want Float32", f)
}
})
t.Run("leerer data-Chunk ist erlaubt", func(t *testing.T) {
t.Run("empty data chunk is allowed", func(t *testing.T) {
wav := buildWAV(1, 1, 22050, 16, nil)
_, pcm, err := decodeWAV(wav)
if err != nil {
t.Fatalf("decodeWAV: %v", err)
}
if len(pcm) != 0 {
t.Errorf("PCM = %x, want leer", pcm)
t.Errorf("PCM = %x, want empty", pcm)
}
})
}
func TestDecodeWAVFehler(t *testing.T) {
func TestDecodeWAVErrors(t *testing.T) {
cases := []struct {
name string
data []byte
want string
}{
{"zu kurz", []byte("RIFF"), "zu kurz"},
{"kein riff", []byte("RIFX" + "\x00\x00\x00\x00" + "WAVE"), "RIFF"},
{"24-bit pcm", buildWAV(1, 1, 44100, 24, []byte{1, 2, 3}), "nicht unterstütztes Format"},
{"3 kanäle", buildWAV(1, 3, 44100, 16, []byte{1, 2, 3, 4, 5, 6}), "Kanäle"},
{"ohne data", func() []byte {
{"too short", []byte("RIFF"), "too short"},
{"no riff", []byte("RIFX" + "\x00\x00\x00\x00" + "WAVE"), "RIFF"},
{"24-bit pcm", buildWAV(1, 1, 44100, 24, []byte{1, 2, 3}), "unsupported format"},
{"3 channels", buildWAV(1, 3, 44100, 16, []byte{1, 2, 3, 4, 5, 6}), "channels"},
{"without data", func() []byte {
wav := buildWAV(1, 1, 22050, 16, []byte{1, 2})
return wav[:36] // nur fmt-Chunk behalten
return wav[:36] // keep only the fmt chunk
}(), "data"},
{"kaputter chunk", append(buildWAV(1, 1, 22050, 16, nil), "data\xff\xff\xff\xff"...), "überragt"},
{"broken chunk", append(buildWAV(1, 1, 22050, 16, nil), "data\xff\xff\xff\xff"...), "exceeds"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, _, err := decodeWAV(tc.data)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("decodeWAV-Fehler = %v, will Meldung mit %q", err, tc.want)
t.Errorf("decodeWAV error = %v, want message containing %q", err, tc.want)
}
})
}
@@ -124,9 +124,9 @@ func TestNewSink(t *testing.T) {
}
}
if _, err := NewSink("mpv", nil); err == nil {
t.Error("NewSink(\"mpv\") soll fehlschlagen")
t.Error("NewSink(\"mpv\") should fail")
}
t.Run("null-Sink loggt über den Factory-Writer", func(t *testing.T) {
t.Run("null sink logs via the factory writer", func(t *testing.T) {
var buf bytes.Buffer
s, err := NewSink(NameNull, &buf)
if err != nil {
@@ -135,49 +135,49 @@ func TestNewSink(t *testing.T) {
if err := s.Play(context.Background(), []byte("audio")); err != nil {
t.Fatalf("Play: %v", err)
}
if !strings.Contains(buf.String(), "verworfen (null sink)") {
t.Errorf("Log = %q, will Verwurf-Zeile über den Factory-Writer", buf.String())
if !strings.Contains(buf.String(), "discarded (null sink)") {
t.Errorf("log = %q, want discard line via the factory writer", buf.String())
}
})
}
func TestNullSinkVerwirftUndLoggt(t *testing.T) {
func TestNullSinkDiscardsAndLogs(t *testing.T) {
var buf bytes.Buffer
sink := NullSink{W: &buf}
if err := sink.Play(context.Background(), []byte("audio")); err != nil {
t.Fatalf("NullSink.Play: %v", err)
}
if !strings.Contains(buf.String(), "verworfen") {
t.Errorf("NullSink-Log = %q, will Verwurf-Zeile", buf.String())
if !strings.Contains(buf.String(), "discarded") {
t.Errorf("NullSink log = %q, want discard line", buf.String())
}
if sink.Name() != NameNull {
t.Errorf("Name() = %q", sink.Name())
}
}
// TestOtoSinkUngueltigeDaten: OtoSink.Play muss an der WAV-Dekodierung
// scheitern, bevor irgend ein Audio-Gerät angefasst wird — das ist der
// CI-/Server-sichere Fehlerpfad des oto-Sinks.
func TestOtoSinkUngueltigeDaten(t *testing.T) {
err := (OtoSink{}).Play(context.Background(), []byte("kein audio"))
// TestOtoSinkInvalidData: OtoSink.Play must fail at WAV decoding before any
// audio device is touched; this is the CI/server-safe error path of the oto
// sink.
func TestOtoSinkInvalidData(t *testing.T) {
err := (OtoSink{}).Play(context.Background(), []byte("not audio"))
if err == nil {
t.Fatal("OtoSink.Play mit Müll: kein Fehler")
t.Fatal("OtoSink.Play with garbage: no error")
}
if !strings.Contains(err.Error(), "wav:") {
t.Errorf("Fehler soll aus dem WAV-Parser kommen: %v", err)
t.Errorf("error should come from the WAV parser: %v", err)
}
}
// TestOtoSinkEchteWiedergabe läuft nur mit TALK_TEST_REAL_AUDIO=1 (erzeugt
// echten Ton); Standard-Testläufe bleiben stumm.
func TestOtoSinkEchteWiedergabe(t *testing.T) {
// TestOtoSinkRealPlayback runs only with TALK_TEST_REAL_AUDIO=1 (produces
// real sound); default test runs stay silent.
func TestOtoSinkRealPlayback(t *testing.T) {
if testing.Short() {
t.Skip("Übersprungen (-short)")
t.Skip("skipped (-short)")
}
if os.Getenv("TALK_TEST_REAL_AUDIO") != "1" {
t.Skip("Übersprungen (hörbar) — mit TALK_TEST_REAL_AUDIO=1 ausführen")
t.Skip("skipped (audible); run with TALK_TEST_REAL_AUDIO=1")
}
// 0,1 s 440-Hz-Sinus, 16 Bit mono 22050 Hz.
// 0.1 s 440 Hz sine, 16 bit mono 22050 Hz.
const rate = 22050
samples := make([]byte, rate/10*2)
for i := 0; i < rate/10; i++ {
+9 -9
View File
@@ -6,11 +6,11 @@ import (
"time"
)
// Check prüft, ob der Sink name einsatzbereit ist, ohne Ton zu erzeugen:
// null ist immer bereit; oto initialisiert den Audio-Kontext (Standard-
// Format 44,1 kHz Stereo 16 Bit) und wartet auf dessen Bereitschaft — so
// fällt „kein Audiogerät" (Server, Container, fehlende PulseAudio/ALSA-
// Bibliothek) in `talk doctor` auf, nicht erst bei der ersten Ansage.
// Check verifies that the sink name is ready without producing sound: null
// is always ready; oto initializes the audio context (default format 44.1
// kHz stereo 16 bit) and waits for it to become ready, so "no audio device"
// (servers, containers, missing PulseAudio/ALSA library) shows up in
// `talk doctor` rather than at the first announcement.
func Check(ctx context.Context, name string) error {
switch name {
case NameNull:
@@ -23,15 +23,15 @@ func Check(ctx context.Context, name string) error {
select {
case <-c.ready:
if err := c.ctx.Err(); err != nil {
return fmt.Errorf("oto: Initialisierung fehlgeschlagen: %w", err)
return fmt.Errorf("oto: initialization failed: %w", err)
}
return nil
case <-ctx.Done():
return fmt.Errorf("oto: Audio-Gerät antwortet nicht (%v)", ctx.Err())
return fmt.Errorf("oto: audio device not responding (%v)", ctx.Err())
case <-time.After(5 * time.Second):
return fmt.Errorf("oto: Audio-Gerät antwortet nicht binnen 5 s")
return fmt.Errorf("oto: audio device not responding within 5 s")
}
default:
return fmt.Errorf("unbekannter audio sink %q (erlaubt: %q, %q)", name, NameOto, NameNull)
return fmt.Errorf("unknown audio sink %q (allowed: %q, %q)", name, NameOto, NameNull)
}
}
+4 -4
View File
@@ -1,5 +1,5 @@
// Package audio implementiert die Audio-Ausgabe von talk über das
// Sink-Interface: Playback embedded via oto (RIFF/WAVE-PCM und MP3, per
// reinem Go-Dekodierer) und ein null-Sink, das verwirft (CI, Server,
// Tests). Auswahl per Config audio.sink = "oto" | "null" (Default oto).
// Package audio implements talk's audio output behind the Sink interface:
// embedded playback via oto (RIFF/WAVE PCM and MP3, via a pure Go decoder)
// and a null sink that discards (CI, servers, tests). Selected via config
// audio.sink = "oto" | "null" (default oto).
package audio
+22 -24
View File
@@ -8,23 +8,21 @@ import (
mpg "github.com/hajimehoshi/go-mp3"
)
// decodeMP3 dekodiert MP3-Daten — das Lieferformat der Cloud-Provider ab
// Task 03 (ElevenLabs, OpenAI TTS, Edge-TTS) — in rohe PCM-Samples für
// oto.
// decodeMP3 decodes MP3 data, the delivery format of the cloud providers
// (ElevenLabs, OpenAI TTS, Edge TTS), into raw PCM samples for oto.
//
// Entscheidung mp3-Dekodierung (Task 03): eingebauter, reiner Go-Dekoder
// github.com/hajimehoshi/go-mp3 v0.3.4 statt WAV/PCM-Anforderung bei den
// APIs. Begründung: (1) Alle drei Cloud-Provider liefern mp3 als ihr
// natives Format; nur OpenAI kennt response_format "wav"/"pcm", ElevenLabs
// und Edge-TTS aber nicht — eine einheitliche mp3-Pipeline hält die Kette
// symmetrisch. (2) Der Dekoder ist nachweislich frei von CGO (kein
// import "C" im Modul), damit bleibt die Cross-Compile-Verpflichtung
// (CGO_ENABLED=0 für alle 6 Targets) unangetastet — verifiziert per
// GOOS/GOARCH-Matrix-Build, siehe Task-03-Report.
// Decision: a built-in pure Go decoder (github.com/hajimehoshi/go-mp3
// v0.3.4) instead of requesting WAV/PCM from the APIs. Reasons: (1) all
// three cloud providers deliver mp3 as their native format; only OpenAI
// knows response_format "wav"/"pcm", ElevenLabs and Edge TTS do not, so a
// single mp3 pipeline keeps the chain symmetric. (2) The decoder is free of
// CGO (no import "C" in the module), which keeps the cross-compile
// commitment (CGO_ENABLED=0 for all 6 targets) intact, verified by a
// GOOS/GOARCH matrix build.
//
// Der Dekoder liefert immer 16-Bit-PCM little-endian mit 2 Kanälen — auch
// für Mono-Quellen (etwa Edge-TTS: 24 kHz mono) wird auf Stereo
// gespiegelt; die Sample-Rate stammt aus dem ersten MP3-Frame.
// The decoder always yields 16-bit little-endian PCM with 2 channels; mono
// sources (such as Edge TTS: 24 kHz mono) are mirrored to stereo. The
// sample rate comes from the first MP3 frame.
func decodeMP3(data []byte) (pcmFormat, []byte, error) {
dec, err := mpg.NewDecoder(bytes.NewReader(data))
if err != nil {
@@ -32,21 +30,21 @@ func decodeMP3(data []byte) (pcmFormat, []byte, error) {
}
pcm, err := io.ReadAll(dec)
if err != nil {
return pcmFormat{}, nil, fmt.Errorf("mp3: dekodieren: %w", err)
return pcmFormat{}, nil, fmt.Errorf("mp3: decoding: %w", err)
}
if len(pcm) == 0 {
return pcmFormat{}, nil, fmt.Errorf("mp3: Dekodierung lieferte keine Samples")
return pcmFormat{}, nil, fmt.Errorf("mp3: decoding produced no samples")
}
return pcmFormat{
SampleRate: dec.SampleRate(),
Channels: 2, // go-mp3 dekodiert grundsätzlich 2 Kanäle (s. doc)
Channels: 2, // go-mp3 always decodes to 2 channels (see its docs)
BitsPerCoded: 16,
}, pcm, nil
}
// looksLikeMP3 meldet, ob data nach MP3 aussieht: ID3v2-Tag (führt mit
// "ID3") oder ein roher Frame-Sync (erstes Byte 0xFF, oberste 3 Bits des
// zweiten Bytes gesetzt).
// looksLikeMP3 reports whether data looks like MP3: an ID3v2 tag (leading
// "ID3") or a raw frame sync (first byte 0xFF, top 3 bits of the second
// byte set).
func looksLikeMP3(data []byte) bool {
if len(data) >= 3 && string(data[0:3]) == "ID3" {
return true
@@ -54,9 +52,9 @@ func looksLikeMP3(data []byte) bool {
return len(data) >= 2 && data[0] == 0xFF && data[1]&0xE0 == 0xE0
}
// decodeAudio ist die Format-Weiche der Sinks: RIFF/WAVE (PCM) nach
// decodeWAV, MP3 (ID3-Tag oder Frame-Sync) nach decodeMP3. Alles andere
// fällt durch zu decodeWAV, das den präzisen „kein RIFF"-Fehler liefert.
// decodeAudio is the format switch of the sinks: RIFF/WAVE (PCM) goes to
// decodeWAV, MP3 (ID3 tag or frame sync) to decodeMP3. Everything else
// falls through to decodeWAV, which yields the precise "no RIFF" error.
func decodeAudio(data []byte) (pcmFormat, []byte, error) {
if len(data) >= 4 && string(data[0:4]) == "RIFF" {
return decodeWAV(data)
+40 -44
View File
@@ -6,16 +6,15 @@ import (
"testing"
)
// Zwei einmalig erzeugte, echte MP3-Fixtures (ffmpeg am Dev-Host, in den
// Test eingebettet — Tests bleiben zur Laufzeit frei von Werkzeug-Abhängig-
// keiten). Beide entsprechen dem Lieferformat der Cloud-Provider aus
// Task 03:
// Two real MP3 fixtures generated once (ffmpeg on the dev host, embedded in
// the test so tests stay free of tool dependencies at runtime). Both match
// the delivery format of the cloud providers:
//
// fixtureMP3Mono: 0,15 s Stille, 24 kHz mono 48 kbit/s — exakt das
// Edge-TTS-Ausgabeformat (audio-24khz-48kbitrate-mono-
// mp3); übt den Mono→Stereo-Upsample-Pfad des Dekoders.
// fixtureMP3Stereo: 0,15 s 440-Hz-Sinus, 44,1 kHz stereo 64 kbit/s — das
// typische ElevenLabs/OpenAI-Format.
// fixtureMP3Mono: 0.15 s silence, 24 kHz mono 48 kbit/s; exactly the
// Edge TTS output format (audio-24khz-48kbitrate-mono-
// mp3); exercises the decoder's mono-to-stereo path.
// fixtureMP3Stereo: 0.15 s 440 Hz sine, 44.1 kHz stereo 64 kbit/s; the
// typical ElevenLabs/OpenAI format.
const fixtureMP3MonoB64 = "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjE2LjEwMQAAAAAAAAAAAAAA//OEwAAAAAAAAAAAAEluZm8AAAAPAAAACQAABdAAOTk5OTk5OTk5OTlSUlJSUlJSUlJSUmtra2tra2tra2trhISEhISEhISEhIScnJycnJycnJycnLW1tbW1tbW1tbW1zs7Ozs7Ozs7Ozs7n5+fn5+fn5+fn5///////////////AAAAAExhdmM2MC4zMQAAAAAAAAAAAAAAACQD8AAAAAAAAAXQx638BQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//NkxAAAAANIAAAAAExBTUVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV"
const fixtureMP3StereoB64 = "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjE2LjEwMQAAAAAAAAAAAAAA//tQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAHAAAGhgA/Pz8/Pz8/Pz8/Pz8/P19fX19fX19fX19fX19ff39/f39/f39/f39/f3+fn5+fn5+fn5+fn5+fn5+/v7+/v7+/v7+/v7+/v9/f39/f39/f39/f39/f//////////////////8AAAAATGF2YzYwLjMxAAAAAAAAAAAAAAAAJANpAAAAAAAABoYxZN5VAAAAAAAAAAAAAAAAAAAAAP/7UGQAAAGPB1PVMCAMAAANIKAAAQtkrUe5tQAAAAA0gwAAAAAAWLlbSAEsSzOM4EAEAaEx+7b9wfPggCAIawf1AgCByXB8P4gBCDju/o8/ynn+U9/QAAB54AwKBwMBwMBQAAFQYGzUQALpOGBjRoAS+wRiXRL4oTPxwxscc7rrbANAE+BRFYRX4CwgQbQbf8QQFYCkKoNv/iJIh8KxCPf/xVIh8PiEej3+oGhKEga/6AAAApR2UEOC4IEAFJQRgMbD4SaWFOYlBOYKASCQCLL/+1JkD4jxxgdL53QADAAADSDgAAEGsCEUDftiQAAANIAAAARAoAmvdKzM8GvTr7RI79DR2VcAU20f7f//oSHMQEjMz03SSMSce03/PSzfLHvMSwKQ4NnM9RTNDwzV1MZFVqS/AHAT+e8a7q9+L/6U/+z6FQAAwGRkDDijFFTKrjabzCXGENGzGA0TxejCIBdNEYs6apxxagJ1trIcXd1/JeZ/X/b/1+i936xuCQSSghxU5gAd/mCwEoZaKkBlfBKGC2BKeoGIwBGAMJiS/APLqv/7UmQuiPG2B8bLXsiQAAANIAAAAQZcHyVMewJQAAA0gAAABHKvo6//9u//q/7Nv1ogDEBcxUkMiRzSZwwMIINMWLQbzFZAgEwK8CEMwXMKCNMZNvIM+GXrLrPfy/0dBBK8xQOM5NTeoQxNRxzhW4hOCMckxOgmjhWUztHMwPjL30xUZXRK8Ac+5n3dP8U+jp/993R/1QAAABhG0CAAXeBgIBQM7kwRQ5jJeegMk8OAwPALy4SxQUY1PL0xYN871ZmKO7X/s/1f///oT4MeHNco//tSZFAN8XoIxYt/0JAAAA0gAAABBrQhFA37YkAAADSAAAAEPXrMRAXg3c72DdEF8MRUH8+VI2Do0rU0WoxhJl9JmCf/1f//////SgAAcCBADChEw0eMWPzNYwwlh3TRv77NE4dMwhQeDIZBxRxGnjoAsl+zwbtQl9P2Vywc//29RAABYoMCCKFka6wsBjBQuObW04mcTGIbCA2hPQBoB2V2wIYzS/1q///6k////+0AAD2/T7fb/b0YCAAACXCJpXpktZdKRqMMiYljiZYRljn/+1JkdA3xqAdI6z7AmAAADSAAAAEFtCEWDXtCQAAANIAAAAS1qIWFQOGBu1BDXyALuvctJN0DKUQSBydaGrcUrx5+AghClCpXselWdBCLUOQJbMQULhJKl5UtMt63+FjfdbQTuGrApeyZav7/e/////+LP9DMeiUThj/Ihg0EmkMQAAHEiVkSKOHEQCE4SASTkRCVR6IniVx4jOxjwVce/EU7vK1MQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVf/7UmSZAPGrB8ZLfsiQAAANIAAAAQXUHTWVwAAwAAA0goAABFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//tSZL2AA+cuVO5nIAQAAA0gwAAABJgm3HxggAAAADSDgAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU="
@@ -24,14 +23,14 @@ func fixture(t *testing.T, b64 string) []byte {
t.Helper()
data, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
t.Fatalf("Fixture dekodieren: %v", err)
t.Fatalf("decoding fixture: %v", err)
}
return data
}
// TestDecodeMP3Mono: das Edge-Format (24 kHz mono) dekodiert zu 16-Bit-PCM
// stereo — der Dekoder spiegelt Mono auf zwei Kanäle, Samples sind 4 Bytes
// (2 Kanäle × 2 Bytes) aligned.
// TestDecodeMP3Mono: the Edge format (24 kHz mono) decodes to 16-bit PCM
// stereo; the decoder mirrors mono onto two channels, samples are aligned
// to 4 bytes (2 channels x 2 bytes).
func TestDecodeMP3Mono(t *testing.T) {
f, pcm, err := decodeMP3(fixture(t, fixtureMP3MonoB64))
if err != nil {
@@ -41,60 +40,57 @@ func TestDecodeMP3Mono(t *testing.T) {
t.Errorf("SampleRate = %d, want 24000", f.SampleRate)
}
if f.Channels != 2 || f.BitsPerCoded != 16 || f.Float {
t.Errorf("Format = %+v, want 2 Kanäle/16 Bit/int", f)
t.Errorf("format = %+v, want 2 channels/16 bit/int", f)
}
if len(pcm) == 0 || len(pcm)%4 != 0 {
t.Errorf("PCM-Länge = %d, want > 0 und durch 4 teilbar (16-Bit-Samples stereo)", len(pcm))
t.Errorf("PCM length = %d, want > 0 and divisible by 4 (16-bit stereo samples)", len(pcm))
}
}
// TestDecodeMP3Stereo: ElevenLabs/OpenAI-typisches Format (44,1 kHz stereo).
// TestDecodeMP3Stereo: the ElevenLabs/OpenAI-typical format (44.1 kHz stereo).
func TestDecodeMP3Stereo(t *testing.T) {
f, pcm, err := decodeMP3(fixture(t, fixtureMP3StereoB64))
if err != nil {
t.Fatalf("decodeMP3 (stereo): %v", err)
}
if f.SampleRate != 44100 || f.Channels != 2 || f.BitsPerCoded != 16 {
t.Errorf("Format = %+v, want 44100 Hz stereo 16 Bit", f)
t.Errorf("format = %+v, want 44100 Hz stereo 16 bit", f)
}
if len(pcm) == 0 || len(pcm)%4 != 0 {
t.Errorf("PCM-Länge = %d, want > 0 und durch 4 teilbar", len(pcm))
t.Errorf("PCM length = %d, want > 0 and divisible by 4", len(pcm))
}
}
// TestDecodeMP3Fehler: Daten, die nach MP3 aussehen, aber keine Frames
// enthalten, sind ein klarer mp3-Fehler (nicht der WAV-Fehlerpfad).
func TestDecodeMP3Fehler(t *testing.T) {
cases := []struct{ name, want string }{
{"Müll mit Frame-Sync", "mp3:"},
{"ID3-Tag ohne Frames", "mp3:"},
// TestDecodeMP3Errors: data that looks like MP3 but contains no frames is a
// clear mp3 error (not the WAV error path).
func TestDecodeMP3Errors(t *testing.T) {
cases := []struct {
name string
data []byte
want string
}{
{"garbage with frame sync", []byte{0xFF, 0xFB, 'n', 'o', 't', ' ', 'm', 'p', '3'}, "mp3:"},
{"ID3 tag without frames", []byte("ID3\x04\x00\x00\x00\x00\x00\x00"), "mp3:"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var data []byte
switch tc.name {
case "Müll mit Frame-Sync":
data = []byte{0xFF, 0xFB, 'k', 'e', 'i', 'n', ' ', 'm', 'p', '3'}
case "ID3-Tag ohne Frames":
data = []byte("ID3\x04\x00\x00\x00\x00\x00\x00")
}
_, _, err := decodeMP3(data)
_, _, err := decodeMP3(tc.data)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("decodeMP3-Fehler = %v, will Meldung mit %q", err, tc.want)
t.Errorf("decodeMP3 error = %v, want message containing %q", err, tc.want)
}
})
}
}
// TestLooksLikeMP3 pinnt die Format-Erkennung.
// TestLooksLikeMP3 pins the format detection.
func TestLooksLikeMP3(t *testing.T) {
yes := [][]byte{
fixture(t, fixtureMP3StereoB64), // ID3-Tag
{0xFF, 0xFB, 0x00, 0x00}, // roher Frame-Sync
fixture(t, fixtureMP3StereoB64), // ID3 tag
{0xFF, 0xFB, 0x00, 0x00}, // raw frame sync
}
no := [][]byte{
[]byte("RIFF....WAVE"),
[]byte("kein audio"),
[]byte("not audio"),
{0xFF},
{},
}
@@ -110,18 +106,18 @@ func TestLooksLikeMP3(t *testing.T) {
}
}
// TestDecodeAudioWeiche: RIFF geht an den WAV-Parser, MP3 an den MP3-
// Dekoder, alles andere fällt mit dem WAV-Fehler durch.
func TestDecodeAudioWeiche(t *testing.T) {
// TestDecodeAudioSwitch: RIFF goes to the WAV parser, MP3 to the MP3
// decoder, everything else falls through with the WAV error.
func TestDecodeAudioSwitch(t *testing.T) {
f, _, err := decodeAudio(buildWAV(1, 1, 22050, 16, []byte{1, 2, 3, 4}))
if err != nil || f.SampleRate != 22050 {
t.Errorf("decodeAudio(WAV) = %+v, %v — soll den WAV-Pfad nehmen", f, err)
t.Errorf("decodeAudio(WAV) = %+v, %v; should take the WAV path", f, err)
}
f, _, err = decodeAudio(fixture(t, fixtureMP3MonoB64))
if err != nil || f.SampleRate != 24000 {
t.Errorf("decodeAudio(MP3) = %+v, %v — soll den MP3-Pfad nehmen", f, err)
t.Errorf("decodeAudio(MP3) = %+v, %v; should take the MP3 path", f, err)
}
if _, _, err = decodeAudio([]byte("kein audio")); err == nil || !strings.Contains(err.Error(), "wav:") {
t.Errorf("decodeAudio(Müll) = %v, will WAV-Fehler", err)
if _, _, err = decodeAudio([]byte("not audio")); err == nil || !strings.Contains(err.Error(), "wav:") {
t.Errorf("decodeAudio(garbage) = %v, want WAV error", err)
}
}
+16 -19
View File
@@ -10,20 +10,18 @@ import (
"github.com/ebitengine/oto/v3"
)
// OtoSink spielt Audio embedded über oto (github.com/ebitengine/oto/v3,
// gepinnt auf v3.5.0) ab — kein externer Player nötig. oto lädt die
// Audiotreiber zur Laufzeit ohne CGO (purego), dadurch bleibt talk
// cross-compilierbar.
// OtoSink plays audio embedded via oto (github.com/ebitengine/oto/v3,
// pinned to v3.5.0); no external player needed. oto loads the audio drivers
// at runtime without CGO (purego), which keeps talk cross-compilable.
type OtoSink struct{}
// Name implementiert Sink.
// Name implements Sink.
func (OtoSink) Name() string { return NameOto }
// contextCache hält je PCM-Format einen oto-Kontext vor: oto unterstützt
// nur EINEN Kontext pro Prozess ("Creating multiple contexts is NOT
// supported"), und ein Kontext ist an genau ein Format gebunden. Die CLI
// spielt pro Prozess einmal (Task 02); der Daemon (Task 04) profitiert vom
// Cache.
// contextCache keeps one oto context per PCM format: oto supports only ONE
// context per process ("Creating multiple contexts is NOT supported"), and
// a context is bound to exactly one format. The CLI plays once per process;
// the daemon benefits from the cache.
var contextCache = struct {
sync.Mutex
byFormat map[pcmFormat]*otoContext
@@ -34,7 +32,7 @@ type otoContext struct {
ready chan struct{}
}
// otoFormat mappt ein PCM-Format auf das oto-Format.
// otoFormat maps a PCM format to the oto format.
func (f pcmFormat) otoFormat() oto.Format {
switch {
case f.Float:
@@ -46,7 +44,7 @@ func (f pcmFormat) otoFormat() oto.Format {
}
}
// contextFor liefert den (gecachten) oto-Kontext für ein Format.
// contextFor returns the (cached) oto context for a format.
func contextFor(f pcmFormat) (*otoContext, error) {
contextCache.Lock()
defer contextCache.Unlock()
@@ -60,17 +58,16 @@ func contextFor(f pcmFormat) (*otoContext, error) {
}
ctx, ready, err := oto.NewContext(op)
if err != nil {
return nil, fmt.Errorf("oto: Kontext (Audio-Gerät?) fehlgeschlagen: %w", err)
return nil, fmt.Errorf("oto: creating context failed (no audio device?): %w", err)
}
c := &otoContext{ctx: ctx, ready: ready}
contextCache.byFormat[f] = c
return c, nil
}
// Play implementiert Sink: dekodiert die Daten (RIFF/WAVE-PCM von osbuiltin
// oder MP3 von den Cloud-Providern, siehe mp3.go) und spielt die Samples
// blockierend ab. Fehler des Audio-Geräts oder des Formats werden klar
// gemeldet.
// Play implements Sink: decodes the data (RIFF/WAVE PCM from osbuiltin or
// MP3 from the cloud providers, see mp3.go) and plays the samples, blocking.
// Audio device and format errors are reported clearly.
func (OtoSink) Play(ctx context.Context, data []byte) error {
f, pcm, err := decodeAudio(data)
if err != nil {
@@ -83,7 +80,7 @@ func (OtoSink) Play(ctx context.Context, data []byte) error {
select {
case <-c.ready:
if err := c.ctx.Err(); err != nil {
return fmt.Errorf("oto: Initialisierung fehlgeschlagen: %w", err)
return fmt.Errorf("oto: initialization failed: %w", err)
}
case <-ctx.Done():
return ctx.Err()
@@ -98,7 +95,7 @@ func (OtoSink) Play(ctx context.Context, data []byte) error {
return err
}
if err := player.Err(); err != nil {
return fmt.Errorf("oto: Wiedergabe fehlgeschlagen: %w", err)
return fmt.Errorf("oto: playback failed: %w", err)
}
time.Sleep(5 * time.Millisecond)
}
+17 -18
View File
@@ -5,28 +5,27 @@ import (
"fmt"
)
// pcmFormat beschreibt rohe PCM-Samples, wie sie oto zur Wiedergabe
// braucht.
// pcmFormat describes raw PCM samples as oto needs them for playback.
type pcmFormat struct {
SampleRate int
Channels int
BitsPerCoded int // 8 (unsigned), 16 (signed LE), 32 (nur IEEE float)
BitsPerCoded int // 8 (unsigned), 16 (signed LE), 32 (IEEE float only)
Float bool
}
// decodeWAV zerlegt RIFF/WAVE-Bytes in Format und PCM-Daten.
// decodeWAV splits RIFF/WAVE bytes into format and PCM data.
//
// Unterstützt: PCM (AudioFormat 1) mit 8/16 Bit sowie IEEE-Float
// (AudioFormat 3) mit 32 Bit; Mono und Stereo. Alles andere — erst recht
// komprimierte Formate — ist ein klarer Fehler; die OS-TTS-Wege von
// osbuiltin liefern ausschließlich 16-Bit-PCM-WAV.
// Supported: PCM (AudioFormat 1) with 8/16 bit and IEEE float (AudioFormat
// 3) with 32 bit; mono and stereo. Everything else, compressed formats in
// particular, is a clear error; the OS TTS paths of osbuiltin deliver
// 16-bit PCM WAV exclusively.
func decodeWAV(data []byte) (pcmFormat, []byte, error) {
var f pcmFormat
if len(data) < 12 {
return f, nil, fmt.Errorf("wav: zu kurz für RIFF-Header (%d Bytes)", len(data))
return f, nil, fmt.Errorf("wav: too short for a RIFF header (%d bytes)", len(data))
}
if string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
return f, nil, fmt.Errorf("wav: kein RIFF/WAVE-Header")
return f, nil, fmt.Errorf("wav: no RIFF/WAVE header")
}
var haveFmt bool
@@ -37,12 +36,12 @@ func decodeWAV(data []byte) (pcmFormat, []byte, error) {
size := int(binary.LittleEndian.Uint32(data[pos+4 : pos+8]))
body := pos + 8
if body+size > len(data) {
return f, nil, fmt.Errorf("wav: Chunk %q überragt die Datei (Size %d)", id, size)
return f, nil, fmt.Errorf("wav: chunk %q exceeds the data (size %d)", id, size)
}
switch id {
case "fmt ":
if size < 16 {
return f, nil, fmt.Errorf("wav: fmt-Chunk zu kurz (%d)", size)
return f, nil, fmt.Errorf("wav: fmt chunk too short (%d)", size)
}
audioFormat := binary.LittleEndian.Uint16(data[body : body+2])
f.Channels = int(binary.LittleEndian.Uint16(data[body+2 : body+4]))
@@ -53,29 +52,29 @@ func decodeWAV(data []byte) (pcmFormat, []byte, error) {
case audioFormat == 3 && f.BitsPerCoded == 32:
f.Float = true
default:
return f, nil, fmt.Errorf("wav: nicht unterstütztes Format (AudioFormat %d, %d Bit) — erlaubt: PCM 8/16 Bit, Float 32 Bit",
return f, nil, fmt.Errorf("wav: unsupported format (AudioFormat %d, %d bit); allowed: PCM 8/16 bit, float 32 bit",
audioFormat, f.BitsPerCoded)
}
if f.Channels < 1 || f.Channels > 2 {
return f, nil, fmt.Errorf("wav: %d Kanäle (erlaubt: 1, 2)", f.Channels)
return f, nil, fmt.Errorf("wav: %d channels (allowed: 1, 2)", f.Channels)
}
if f.SampleRate <= 0 {
return f, nil, fmt.Errorf("wav: SampleRate %d", f.SampleRate)
return f, nil, fmt.Errorf("wav: sample rate %d", f.SampleRate)
}
haveFmt = true
case "data":
payload = data[body : body+size]
}
pos = body + size
if size%2 == 1 { // Chunks sind word-aligned
if size%2 == 1 { // chunks are word-aligned
pos++
}
}
if !haveFmt {
return f, nil, fmt.Errorf("wav: kein fmt-Chunk")
return f, nil, fmt.Errorf("wav: no fmt chunk")
}
if payload == nil {
return f, nil, fmt.Errorf("wav: kein data-Chunk")
return f, nil, fmt.Errorf("wav: no data chunk")
}
return f, payload, nil
}
+116 -116
View File
@@ -14,183 +14,179 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/queue"
)
// Erlaubte Werte für audio.sink (Teil des öffentlichen Vertrags; Spec:
// „Audio-Sink wählbar inkl. null").
// Allowed values for audio.sink (public contract).
const (
SinkOto = "oto"
SinkNull = "null"
)
// DefaultServerPort ist der Default-Port des Daemon-HTTP-Servers auf
// 127.0.0.1 (Task-04-Brief: „Port Default 7800").
// DefaultServerPort is the daemon's default HTTP port on 127.0.0.1.
const DefaultServerPort = 7800
// DefaultRateLimit ist das Daemon-Gesetz gegen Spam (Task-05-Brief):
// Default 1 Ansage / 30 s. „0s" schaltet das Limit ab.
// DefaultRateLimit is the anti-spam law's default: one announcement per
// 30 seconds. "0s" disables the limit.
const DefaultRateLimit = 30 * time.Second
// DefaultMaxText ist die Default-Obergrenze der Ansage-Länge in Zeichen —
// etwa zwei bis drei Sätze, rund 25 Sekunden Sprache. 0 = unbegrenzt.
// DefaultMaxText is the default cap on announcement length in characters:
// two to three sentences, roughly 25 seconds of speech. 0 = unlimited.
const DefaultMaxText = 400
// DefaultMaxAge ist die Default-Höchstwartezeit einer Ansage in der Queue;
// was länger parkt, ist veraltet und wird verworfen. „0s" = unbegrenzt.
// DefaultMaxAge is the default longest wait in the queue; whatever parks
// longer is stale and gets discarded. "0s" = unlimited.
const DefaultMaxAge = 2 * time.Minute
// Config ist der effektiv geladene Konfigurations-Stand von talk. Task 02
// führt audio.sink; Task 03 ergänzt den Provider-Pin, Provider-Endpunkte
// und das Voice-Mapping; Task 04 den Daemon-Port (server.port, TALK_PORT);
// Task 05 die Anti-Spam-Gesetze (queue.rate_limit, queue.allow_high_burst,
// quiet_hours, quiet_mode); spätere Tasks ergänzen weitere Felder, ohne die
// Auflösungs-Reihenfolge zu ändern.
// DefaultLang is the language assumed when detection cannot decide (empty,
// numbers, ties). Also the language of talk's own announcements such as the
// wrap result when the label gives no hint.
const DefaultLang = "en"
// Config is the effective configuration after all layers were applied.
type Config struct {
Audio Audio `toml:"audio"`
// Server steuert den Daemon-HTTP-Server (talkd) auf 127.0.0.1.
// Server configures the daemon's HTTP server on 127.0.0.1.
Server Server `toml:"server"`
// Queue steuert die Anti-Spam-Gesetze des Daemons (Task 05).
// Queue holds the daemon's anti-spam laws.
Queue Queue `toml:"queue"`
// Daemon steuert den Hintergrundprozess (Autostart).
// Daemon configures the background process (autostart).
Daemon Daemon `toml:"daemon"`
// Provider pinnt die Kette auf genau einen Provider (z. B.
// `provider = "elevenlabs"`); leer = komplette Default-Kette
// (elevenlabs → openai → edge → osbuiltin). Das CLI-Flag --provider
// schlägt diesen Wert. Gültige Namen prüft die Provider-Schicht.
// Lang is the fallback language (de | en) when detection cannot decide,
// and the language of talk's own announcements. Default "en"; env
// TALK_LANG.
Lang string `toml:"lang"`
// Provider pins the chain to exactly one provider (for example
// `provider = "elevenlabs"`); empty = the full default chain
// (elevenlabs → openai → edge → osbuiltin). The --provider flag beats
// this value. Valid names are checked by the provider layer.
Provider string `toml:"provider"`
// Providers enthält per Provider-Einstellungen (Endpunkt, Modell) —
// TOML `[providers.elevenlabs] endpoint = "…"`. Key `providers`
// (Mehrzahl), damit der Pin ( singular `provider`) und die
// Einstellungs-Tabelle in TOML nebeneinander existieren können.
// Providers holds per-provider settings (endpoint, model):
// `[providers.elevenlabs] endpoint = "…"`. The key is plural so the pin
// (`provider`) and the settings table can coexist in TOML.
Providers map[string]ProviderSettings `toml:"providers"`
// Voice ist das Voice-Mapping `voice.<provider>.<lang> = id` — z. B.
// `[voice.elevenlabs] de = "21m00Tcm4TlvDq8ikWAM"`. Schlägt die
// eingebauten neutralen Defaults; das CLI-Flag --voice schlägt alles.
// Voice is the voice mapping `voice.<provider>.<lang> = id`, for example
// `[voice.elevenlabs] de = "21m00Tcm4TlvDq8ikWAM"`. It beats the built-in
// neutral defaults; the --voice flag beats everything.
Voice map[string]map[string]string `toml:"voice"`
// Wrap steuert die Befehlsummwicklung `talk wrap` (Task 07).
// Wrap configures `talk wrap`.
Wrap Wrap `toml:"wrap"`
// QuietHours ist das Ruhezeit-Fenster "HH:MM-HH:MM" in lokaler Zeit
// (überspannt Mitternacht, z. B. "18:00-08:00"); leer = deaktiviert.
// Bewusst kein Default-Fenster: Ein aktives Gesetz, das nach Uhrzeit
// verwirft, würde jeden Cron/Test in den Abendstunden stilllegen —
// Ruhezeiten sind Opt-in wie Mute.
// QuietHours is the quiet window "HH:MM-HH:MM" in local time (may span
// midnight, for example "18:00-08:00"); empty = disabled. Deliberately
// no default window: a law that discards by time of day would silence
// every evening cron job; quiet hours are opt-in like mute.
QuietHours string `toml:"quiet_hours"`
// QuietMode bestimmt das Verhalten in der Ruhezeit: "discard" (Default,
// still verwerfen) oder "toast" (Benachrichtigung statt Audio).
// QuietMode is the behaviour during quiet hours: "discard" (default,
// drop silently) or "toast" (desktop notification instead of audio).
QuietMode string `toml:"quiet_mode"`
}
// Audio steuert die Audio-Ausgabe.
// Audio configures playback.
type Audio struct {
// Sink wählt die Wiedergabe: "oto" (embedded Playback, Default) oder
// "null" (verwirft — CI, Server, Tests).
// Sink selects playback: "oto" (embedded playback, default) or "null"
// (discard; CI, servers, tests).
Sink string `toml:"sink"`
}
// Server sind die Netz-Einstellungen des Daemons (nur Loopback; Binding
// auf 127.0.0.1 macht cmd/talkd, die Config liefert nur den Port).
// Server holds the daemon's network settings (loopback only; the daemon
// binds 127.0.0.1, the config only supplies the port).
type Server struct {
// Port des Daemon-HTTP-Servers auf 127.0.0.1 — config
// `[server] port = 7800`, Env-Override TALK_PORT, Default 7800.
// Port of the daemon's HTTP server on 127.0.0.1: `[server] port = 7800`,
// env override TALK_PORT, default 7800.
Port int `toml:"port"`
// AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (Task 06):
// exakte Origins wie "https://chat.az-gruppe.com". Der Eintrag "*"
// erlaubt jede Origin ausdrücklich. Bewusster Default: LEER — ohne
// Einträge sendet der Daemon gar keine CORS-Header und der Browser
// blockt Cross-Origin-Anfragen; die Tür ist zu, bis sie konfiguriert
// wird (Env-Override TALK_ALLOWED_ORIGINS, kommagetrennt; "off" leert).
// AllowedOrigins is the CORS allow-list for POST /speak: exact origins
// such as "https://chat.example.com". The entry "*" allows every origin
// explicitly. Deliberate default: EMPTY, meaning no CORS headers at all
// and the server-side origin guard rejects browser requests; the door
// stays shut until configured (env TALK_ALLOWED_ORIGINS, comma-separated;
// "off" clears it).
AllowedOrigins []string `toml:"allowed_origins"`
}
// Daemon sind die Einstellungen des Hintergrundprozesses (TOML `[daemon]`).
// Daemon holds the background process settings (TOML `[daemon]`).
type Daemon struct {
// Autostart lässt talk den Daemon beim ersten Sprechbefehl selbst im
// Hintergrund starten (Default true; Env TALK_AUTOSTART). false ist für
// Setups, die den Daemon selbst verwalten (systemd, launchd, Task
// Scheduler): say spricht dann ohne Daemon direkt, mit Hinweis.
// Autostart lets talk start the daemon itself in the background on the
// first speak command (default true; env TALK_AUTOSTART). false is for
// setups that manage the daemon themselves (systemd, launchd, Task
// Scheduler): without a daemon, say then speaks directly, with a note.
Autostart bool `toml:"autostart"`
}
// Wrap sind die Einstellungen von `talk wrap` (TOML `[wrap]`).
// Wrap holds the `talk wrap` settings (TOML `[wrap]`).
type Wrap struct {
// Label ersetzt den Basisnamen des Befehls in der Ansage — z. B.
// `label = "Deployment"` für „Deployment erfolgreich. Dauer: …"
// statt „terraform erfolgreich. Dauer: …" (Env-Override
// TALK_WRAP_LABEL schlägt die Datei).
// Label replaces the command's base name in the announcement, for
// example `label = "Deployment"` to hear "Deployment succeeded" instead
// of "terraform succeeded" (env override TALK_WRAP_LABEL).
Label string `toml:"label"`
}
// Queue sind die Rate-Limit-Gesetze des Daemons (TOML `[queue]`).
// Queue holds the daemon's anti-spam laws (TOML `[queue]`).
type Queue struct {
// RateLimit ist der Mindestabstand zwischen zwei Ansage-Starten als
// Dauer-String (z. B. "30s"; "0s" = unbegrenzt). Default: 30 s.
// RateLimit is the minimum gap between two announcement starts, as a
// duration string ("30s"; "0s" = unlimited). Default 30 s.
RateLimit time.Duration `toml:"rate_limit"`
// AllowHighBurst erlaubt priority=high, das Rate-Limit bewusst zu
// verstoßen (Default true). Die Ruhezeit gilt davon unberührt.
// AllowHighBurst lets priority=high deliberately break the rate limit
// (default true). Quiet hours still apply.
AllowHighBurst bool `toml:"allow_high_burst"`
// MaxText ist die Obergrenze der Ansage-Länge in Zeichen; längere
// Texte werden an einer Wortgrenze gekürzt (Default 400; 0 =
// unbegrenzt; Env TALK_MAX_TEXT).
// MaxText caps announcement length in characters; longer texts are cut
// at a word boundary (default 400; 0 = unlimited; env TALK_MAX_TEXT).
MaxText int `toml:"max_text"`
// MaxAge ist die längste Wartezeit einer Ansage in der Queue; ältere
// werden beim Fälligwerden verworfen (Default "2m"; "0s" = unbegrenzt;
// Env TALK_MAX_AGE).
// MaxAge is the longest an announcement may wait in the queue; older
// ones are discarded when they come due (default "2m"; "0s" =
// unlimited; env TALK_MAX_AGE).
MaxAge time.Duration `toml:"max_age"`
}
// ProviderSettings sind die per Provider konfigurierbaren Endpunkte und
// Modelle (TOML `[providers.<name>]`). Leer = eingebaute Defaults; die
// Env-Overrides TALK_ELEVENLABS_ENDPOINT / TALK_OPENAI_ENDPOINT /
// TALK_EDGE_ENDPOINT (ausgewertet in der Provider-Schicht) schlagen die
// Datei — Keys laufen ausschließlich über TALK_ELEVENLABS_KEY bzw.
// TALK_OPENAI_KEY und werden nie in die Datei geschrieben.
// ProviderSettings are the per-provider endpoint and model (TOML
// `[providers.<name>]`). Empty = built-in defaults; the env overrides
// TALK_ELEVENLABS_ENDPOINT / TALK_OPENAI_ENDPOINT / TALK_EDGE_ENDPOINT
// (read by the provider layer) beat the file. Keys come exclusively from
// TALK_ELEVENLABS_KEY / TALK_OPENAI_KEY and are never written to the file.
type ProviderSettings struct {
Endpoint string `toml:"endpoint"`
Model string `toml:"model"` // nur openai (Default "tts-1")
Model string `toml:"model"` // openai only (default "tts-1")
}
// Defaults liefert die eingebaute Default-Konfiguration (unterste Schicht
// der Auflösung).
// Defaults returns the built-in default configuration (the lowest layer).
func Defaults() Config {
return Config{
Audio: Audio{Sink: SinkOto},
Server: Server{Port: DefaultServerPort},
Queue: Queue{RateLimit: DefaultRateLimit, AllowHighBurst: true, MaxText: DefaultMaxText, MaxAge: DefaultMaxAge},
Daemon: Daemon{Autostart: true},
Lang: DefaultLang,
QuietMode: queue.QuietModeDiscard,
}
}
// Options steuert Load. Alle Felder sind injizierbar, damit die Unit-Tests
// ohne echte HOME-/Env-Umgebung und ohne echte Dateien auskommen.
// Options controls Load. Every field is injectable so unit tests run without
// a real HOME, environment or files.
type Options struct {
// File überschreibt den platform-nativen Pfad der config.toml (Tests
// injizieren hier eine Temp-Datei). Leer: Dir() + "config.toml".
// File overrides the platform-native path of config.toml (tests inject
// a temp file). Empty: Dir() + "config.toml".
File string
// Getenv liefert Umgebungsvariablen (Default: os.Getenv). Über TALK_*
// laufen die Env-Overrides sowie die Pfadauflösung (HOME,
// XDG_CONFIG_HOME, APPDATA).
// Getenv supplies environment variables (default os.Getenv): the TALK_*
// overrides and the path resolution (HOME, XDG_CONFIG_HOME, APPDATA).
Getenv func(string) string
}
// Load löst die Konfiguration schichtweise auf: Defaults → config.toml
// (falls vorhanden) → Env-Overrides (TALK_* schlägt die Datei). Env- und
// Datei-Validierungsfehler werden als klarer Fehler gemeldet, nie still
// ignoriert. Fehlt das Config-Verzeichnis (z. B. keine HOME-Variable),
// läuft talk mit Defaults + Env weiter — die Datei ist optional, kaputte
// Umgebungen dürfen talk nicht unbrauchbar machen.
// Load resolves the configuration in layers: defaults → config.toml (if
// present) → env overrides (TALK_* beats the file). Env and file validation
// errors are reported clearly, never ignored. If the config directory
// cannot be determined (no HOME), talk runs with defaults + env: the file is
// optional, a broken environment must not make talk unusable.
func Load(opts Options) (Config, error) {
if opts.Getenv == nil {
opts.Getenv = os.Getenv
@@ -201,7 +197,6 @@ func Load(opts Options) (Config, error) {
if path == "" {
dir, err := Dir(opts.Getenv)
if err != nil {
// Kein auffindbares Config-Verzeichnis: optionale Datei entfällt.
return applyEnv(cfg, opts.Getenv)
}
path = filepath.Join(dir, fileName)
@@ -215,7 +210,7 @@ func Load(opts Options) (Config, error) {
return applyEnv(cfg, opts.Getenv)
}
// applyEnv wendet die Env-Overrides an und validiert das Ergebnis.
// applyEnv applies the env overrides and validates the result.
func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
if v := getenv("TALK_AUDIO_SINK"); v != "" {
cfg.Audio.Sink = v
@@ -223,10 +218,13 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
if v := getenv("TALK_PROVIDER"); v != "" {
cfg.Provider = v
}
if v := getenv("TALK_LANG"); v != "" {
cfg.Lang = v
}
if v := getenv("TALK_PORT"); v != "" {
port, err := strconv.Atoi(v)
if err != nil || port < 1 || port > 65535 {
return cfg, fmt.Errorf("TALK_PORT: ungültiger Wert %q (1–65535)", v)
return cfg, fmt.Errorf("TALK_PORT: invalid value %q (1–65535)", v)
}
cfg.Server.Port = port
}
@@ -244,21 +242,21 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
if v := getenv("TALK_MAX_TEXT"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
return cfg, fmt.Errorf("TALK_MAX_TEXT: ungültiger Wert %q (Zeichen; 0 = unbegrenzt)", v)
return cfg, fmt.Errorf("TALK_MAX_TEXT: invalid value %q (characters; 0 = unlimited)", v)
}
cfg.Queue.MaxText = n
}
if v := getenv("TALK_MAX_AGE"); v != "" {
d, err := time.ParseDuration(v)
if err != nil || d < 0 {
return cfg, fmt.Errorf("TALK_MAX_AGE: ungültiger Wert %q (Dauer, z. B. \"2m\"; \"0s\" = unbegrenzt)", v)
return cfg, fmt.Errorf("TALK_MAX_AGE: invalid value %q (duration such as \"2m\"; \"0s\" = unlimited)", v)
}
cfg.Queue.MaxAge = d
}
if v := getenv("TALK_AUTOSTART"); v != "" {
b, err := strconv.ParseBool(v)
if err != nil {
return cfg, fmt.Errorf("TALK_AUTOSTART: ungültiger Wert %q (true/false)", v)
return cfg, fmt.Errorf("TALK_AUTOSTART: invalid value %q (true/false)", v)
}
cfg.Daemon.Autostart = b
}
@@ -268,14 +266,14 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
if v := getenv("TALK_RATE_LIMIT"); v != "" {
d, err := time.ParseDuration(v)
if err != nil || d < 0 {
return cfg, fmt.Errorf("TALK_RATE_LIMIT: ungültiger Wert %q (Dauer, z. B. \"30s\"; \"0s\" = unbegrenzt)", v)
return cfg, fmt.Errorf("TALK_RATE_LIMIT: invalid value %q (duration such as \"30s\"; \"0s\" = unlimited)", v)
}
cfg.Queue.RateLimit = d
}
if v := getenv("TALK_ALLOW_HIGH_BURST"); v != "" {
b, err := strconv.ParseBool(v)
if err != nil {
return cfg, fmt.Errorf("TALK_ALLOW_HIGH_BURST: ungültiger Wert %q (true/false)", v)
return cfg, fmt.Errorf("TALK_ALLOW_HIGH_BURST: invalid value %q (true/false)", v)
}
cfg.Queue.AllowHighBurst = b
}
@@ -284,37 +282,39 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
cfg.QuietHours = ""
} else {
if _, err := queue.ParseQuietHours(v); err != nil {
return cfg, fmt.Errorf("TALK_QUIET_HOURS: ungültiges Fenster %q (HH:MM-HH:MM, \"off\" = deaktiviert)", v)
return cfg, fmt.Errorf("TALK_QUIET_HOURS: invalid window %q (HH:MM-HH:MM, \"off\" = disabled)", v)
}
cfg.QuietHours = v
}
}
if v := getenv("TALK_QUIET_MODE"); v != "" {
if v != queue.QuietModeDiscard && v != queue.QuietModeToast {
return cfg, fmt.Errorf("TALK_QUIET_MODE: ungültiger Wert %q (erlaubt: %q, %q)",
return cfg, fmt.Errorf("TALK_QUIET_MODE: invalid value %q (allowed: %q, %q)",
v, queue.QuietModeDiscard, queue.QuietModeToast)
}
cfg.QuietMode = v
}
if cfg.Queue.MaxText < 0 || cfg.Queue.MaxAge < 0 || cfg.Queue.RateLimit < 0 {
return cfg, fmt.Errorf("queue: max_text, max_age und rate_limit dürfen nicht negativ sein (0 = unbegrenzt)")
return cfg, fmt.Errorf("queue: max_text, max_age and rate_limit must not be negative (0 = unlimited)")
}
switch cfg.Audio.Sink {
case SinkOto, SinkNull:
default:
return cfg, fmt.Errorf("audio.sink: ungültiger Wert %q (erlaubt: %q, %q)",
return cfg, fmt.Errorf("audio.sink: invalid value %q (allowed: %q, %q)",
cfg.Audio.Sink, SinkOto, SinkNull)
}
// allowed_origins aus der Datei prüfen (Env wurde oben geparst): Eine
// leere Liste ist gültig (Tür zu), einzelne Einträge müssen aber
// substanziell sein — klarer Fehler statt stiller Ignoranz.
if cfg.Lang != "de" && cfg.Lang != "en" {
return cfg, fmt.Errorf("lang: invalid value %q (allowed: de, en)", cfg.Lang)
}
// Validate allowed_origins from the file (env was parsed above): an empty
// list is fine (door shut), but entries must have substance.
for _, o := range cfg.Server.AllowedOrigins {
if strings.TrimSpace(o) == "" {
return cfg, fmt.Errorf("server.allowed_origins: leerer Eintrag in %v", cfg.Server.AllowedOrigins)
return cfg, fmt.Errorf("server.allowed_origins: empty entry in %v", cfg.Server.AllowedOrigins)
}
}
// quiet_hours aus der Datei validieren (Env wurde oben geprüft) und
// quiet_mode leer (Datei ohne Wert) auf den Default heben.
// Validate quiet_hours from the file (env was checked above) and lift an
// empty quiet_mode to the default.
if cfg.QuietHours != "" {
if _, err := queue.ParseQuietHours(cfg.QuietHours); err != nil {
return cfg, err
@@ -326,16 +326,16 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
return cfg, nil
}
// splitOrigins zerlegt eine kommagetrennte Origin-Liste (Env-Form von
// server.allowed_origins, z. B. "https://a.example, https://b.example").
// Whitespace um Einträge toleriert, leere Einträge sind ein klarer Fehler.
// splitOrigins splits a comma-separated origin list (the env form of
// server.allowed_origins, for example "https://a.example, https://b.example").
// Whitespace around entries is tolerated; empty entries are an error.
func splitOrigins(v string) ([]string, error) {
parts := strings.Split(v, ",")
origins := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
return nil, fmt.Errorf("leerer Eintrag in %q (komma-getrennte Origins oder \"off\")", v)
return nil, fmt.Errorf("empty entry in %q (comma-separated origins, or \"off\")", v)
}
origins = append(origins, p)
}
+191 -348
View File
@@ -14,7 +14,23 @@ func env(kv map[string]string) func(string) string {
return func(k string) string { return kv[k] }
}
func TestDirForPlattformpfade(t *testing.T) {
// writeTOML writes content to a temp config.toml and returns its path.
func writeTOML(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
// missingFile returns a path that does not exist (defaults + env only).
func missingFile(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "does-not-exist.toml")
}
func TestDirForPlatformPaths(t *testing.T) {
cases := []struct {
name string
goos string
@@ -22,66 +38,26 @@ func TestDirForPlattformpfade(t *testing.T) {
want string
errMsg string
}{
{
name: "linux mit HOME",
goos: "linux",
env: map[string]string{"HOME": "/home/u"},
want: "/home/u/.config/talk",
},
{
name: "linux XDG hat Vorrang",
goos: "linux",
env: map[string]string{"HOME": "/home/u", "XDG_CONFIG_HOME": "/xdg"},
want: "/xdg/talk",
},
{
name: "linux relativer XDG wird ignoriert",
goos: "linux",
env: map[string]string{"HOME": "/home/u", "XDG_CONFIG_HOME": "relativ"},
want: "/home/u/.config/talk",
},
{
name: "linux ohne HOME ist Fehler",
goos: "linux",
env: map[string]string{},
errMsg: ErrNoHome.Error(),
},
{
name: "macos",
goos: "darwin",
env: map[string]string{"HOME": "/Users/u"},
want: "/Users/u/Library/Application Support/talk",
},
{
name: "macos ohne HOME ist Fehler",
goos: "darwin",
env: map[string]string{},
errMsg: ErrNoHome.Error(),
},
{
name: "windows",
goos: "windows",
env: map[string]string{"APPDATA": `C:\Users\u\AppData\Roaming`},
want: filepath.Join(`C:\Users\u\AppData\Roaming`, "talk"),
},
{
name: "windows ohne APPDATA ist Fehler",
goos: "windows",
env: map[string]string{},
errMsg: ErrNoHome.Error(),
},
{"linux with HOME", "linux", map[string]string{"HOME": "/home/u"}, "/home/u/.config/talk", ""},
{"linux XDG wins", "linux", map[string]string{"HOME": "/home/u", "XDG_CONFIG_HOME": "/xdg"}, "/xdg/talk", ""},
{"linux relative XDG is ignored", "linux", map[string]string{"HOME": "/home/u", "XDG_CONFIG_HOME": "relative"}, "/home/u/.config/talk", ""},
{"linux without HOME is an error", "linux", map[string]string{}, "", ErrNoHome.Error()},
{"macos", "darwin", map[string]string{"HOME": "/Users/u"}, "/Users/u/Library/Application Support/talk", ""},
{"macos without HOME is an error", "darwin", map[string]string{}, "", ErrNoHome.Error()},
{"windows", "windows", map[string]string{"APPDATA": `C:\Users\u\AppData\Roaming`}, filepath.Join(`C:\Users\u\AppData\Roaming`, "talk"), ""},
{"windows without APPDATA is an error", "windows", map[string]string{}, "", ErrNoHome.Error()},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := dirFor(tc.goos, env(tc.env))
if tc.errMsg != "" {
if err == nil || err.Error() != tc.errMsg {
t.Fatalf("dirFor(%q) Fehler = %v, want %q", tc.goos, err, tc.errMsg)
t.Fatalf("dirFor(%q) error = %v, want %q", tc.goos, err, tc.errMsg)
}
return
}
if err != nil {
t.Fatalf("dirFor(%q): unerwarteter Fehler %v", tc.goos, err)
t.Fatalf("dirFor(%q): unexpected error %v", tc.goos, err)
}
if got != tc.want {
t.Errorf("dirFor(%q) = %q, want %q", tc.goos, got, tc.want)
@@ -90,58 +66,39 @@ func TestDirForPlattformpfade(t *testing.T) {
}
}
// TestLoadMerge ist tischgesteuert für die Schichtenfolge
// Defaults → TOML-Datei → Env-Override.
// TestLoadMerge pins the layering defaults → TOML file → env override.
func TestLoadMerge(t *testing.T) {
writeTOML := func(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
t.Run("nur Defaults", func(t *testing.T) {
cfg, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(nil),
})
t.Run("defaults only", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Audio.Sink != SinkOto {
t.Errorf("Audio.Sink = %q, want Default %q", cfg.Audio.Sink, SinkOto)
t.Errorf("Audio.Sink = %q, want default %q", cfg.Audio.Sink, SinkOto)
}
})
t.Run("TOML überschreibt Default", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[audio]\nsink = \"null\"\n"),
Getenv: env(nil),
})
t.Run("TOML overrides default", func(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, "[audio]\nsink = \"null\"\n"), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Audio.Sink != SinkNull {
t.Errorf("Audio.Sink = %q, want %q aus TOML", cfg.Audio.Sink, SinkNull)
t.Errorf("Audio.Sink = %q, want %q from TOML", cfg.Audio.Sink, SinkNull)
}
})
t.Run("leere TOML-Sektion lässt Default stehen", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "# nur ein Kommentar\n[audio]\n"),
Getenv: env(nil),
})
t.Run("empty TOML section keeps default", func(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, "# just a comment\n[audio]\n"), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Audio.Sink != SinkOto {
t.Errorf("Audio.Sink = %q, want Default %q", cfg.Audio.Sink, SinkOto)
t.Errorf("Audio.Sink = %q, want default %q", cfg.Audio.Sink, SinkOto)
}
})
t.Run("Env schlägt Datei", func(t *testing.T) {
t.Run("env beats file", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[audio]\nsink = \"oto\"\n"),
Getenv: env(map[string]string{"TALK_AUDIO_SINK": "null"}),
@@ -150,15 +107,12 @@ func TestLoadMerge(t *testing.T) {
t.Fatalf("Load: %v", err)
}
if cfg.Audio.Sink != SinkNull {
t.Errorf("Audio.Sink = %q, want Env-Override %q", cfg.Audio.Sink, SinkNull)
t.Errorf("Audio.Sink = %q, want env override %q", cfg.Audio.Sink, SinkNull)
}
})
t.Run("Env allein ohne Datei", func(t *testing.T) {
cfg, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(map[string]string{"TALK_AUDIO_SINK": "null"}),
})
t.Run("env alone without file", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_AUDIO_SINK": "null"})})
if err != nil {
t.Fatalf("Load: %v", err)
}
@@ -167,59 +121,40 @@ func TestLoadMerge(t *testing.T) {
}
})
t.Run("ungültiger Sink-Wert in Datei ist klarer Fehler", func(t *testing.T) {
_, err := Load(Options{
File: writeTOML(t, "[audio]\nsink = \"mpv\"\n"),
Getenv: env(nil),
})
t.Run("invalid sink in file is a clear error", func(t *testing.T) {
_, err := Load(Options{File: writeTOML(t, "[audio]\nsink = \"mpv\"\n"), Getenv: env(nil)})
if err == nil || !strings.Contains(err.Error(), "audio.sink") {
t.Fatalf("Load-Fehler = %v, will klaren audio.sink-Fehler", err)
t.Fatalf("Load error = %v, want a clear audio.sink error", err)
}
})
t.Run("ungültiger Env-Wert ist klarer Fehler", func(t *testing.T) {
_, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(map[string]string{"TALK_AUDIO_SINK": "bogus"}),
})
if err == nil || !strings.Contains(err.Error(), "TALK") && !strings.Contains(err.Error(), "audio.sink") {
t.Fatalf("Load-Fehler = %v, will klaren audio.sink-Fehler", err)
t.Run("invalid env value is a clear error", func(t *testing.T) {
_, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_AUDIO_SINK": "bogus"})})
if err == nil || !strings.Contains(err.Error(), "audio.sink") {
t.Fatalf("Load error = %v, want a clear audio.sink error", err)
}
})
t.Run("kaputte TOML-Syntax ist klarer Fehler", func(t *testing.T) {
_, err := Load(Options{
File: writeTOML(t, "[audio\nsink = \n"),
Getenv: env(nil),
})
if err == nil {
t.Fatal("Load bei kaputter TOML: kein Fehler")
t.Run("broken TOML syntax is a clear error", func(t *testing.T) {
if _, err := Load(Options{File: writeTOML(t, "[audio\nsink = \n"), Getenv: env(nil)}); err == nil {
t.Fatal("Load with broken TOML: no error")
}
})
t.Run("ohne Home läuft Load mit Defaults weiter", func(t *testing.T) {
t.Run("without HOME Load continues with defaults", func(t *testing.T) {
cfg, err := Load(Options{Getenv: env(nil)})
if err != nil {
t.Fatalf("Load ohne HOME: %v", err)
t.Fatalf("Load without HOME: %v", err)
}
if cfg.Audio.Sink != SinkOto {
t.Errorf("Audio.Sink = %q, want Default %q", cfg.Audio.Sink, SinkOto)
t.Errorf("Audio.Sink = %q, want default %q", cfg.Audio.Sink, SinkOto)
}
})
}
// TestLoadProviderKette deckt die Task-03-Felder ab: Provider-Pin per
// Datei/Env, Provider-Endpunkte/-Modelle und das Voice-Mapping.
func TestLoadProviderKette(t *testing.T) {
writeTOML := func(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
// TestLoadProviderChain covers the provider pin (file/env), per-provider
// endpoints/models and the voice mapping.
func TestLoadProviderChain(t *testing.T) {
toml := `provider = "openai"
[providers.elevenlabs]
@@ -233,14 +168,14 @@ model = "gpt-4o-mini-tts"
endpoint = "wss://edge.example.com/synth"
[voice.elevenlabs]
de = "MeineStimme"
de = "MyGermanVoice"
en = "MyVoice"
[voice.edge]
de = "de-DE-KatjaNeural"
`
t.Run("TOML liefert Pin, Endpunkte und Voice-Mapping", func(t *testing.T) {
t.Run("TOML yields pin, endpoints and voice mapping", func(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, toml), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
@@ -257,7 +192,7 @@ de = "de-DE-KatjaNeural"
if got := cfg.Providers["edge"].Endpoint; got != "wss://edge.example.com/synth" {
t.Errorf("Providers[edge].Endpoint = %q", got)
}
if got := cfg.Voice["elevenlabs"]["de"]; got != "MeineStimme" {
if got := cfg.Voice["elevenlabs"]["de"]; got != "MyGermanVoice" {
t.Errorf("Voice[elevenlabs][de] = %q", got)
}
if got := cfg.Voice["elevenlabs"]["en"]; got != "MyVoice" {
@@ -265,74 +200,51 @@ de = "de-DE-KatjaNeural"
}
})
t.Run("TALK_PROVIDER schlägt Datei-Pin", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, toml),
Getenv: env(map[string]string{"TALK_PROVIDER": "edge"}),
})
t.Run("TALK_PROVIDER beats the file pin", func(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, toml), Getenv: env(map[string]string{"TALK_PROVIDER": "edge"})})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Provider != "edge" {
t.Errorf("Provider = %q, want Env-Override %q", cfg.Provider, "edge")
t.Errorf("Provider = %q, want env override %q", cfg.Provider, "edge")
}
})
t.Run("ohne alles: keine Pins, keine Endpunkte", func(t *testing.T) {
cfg, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(nil),
})
t.Run("nothing configured: no pin, no endpoints", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Provider != "" || cfg.Providers != nil || cfg.Voice != nil {
t.Errorf("Provider-Konfiguration = %+v, will leer", cfg)
t.Errorf("provider configuration = %+v, want empty", cfg)
}
})
}
// TestLoadServerPort deckt die Task-04-Felder ab: Daemon-Port per Defaults
// (7800), TOML [server] port, Env-Override TALK_PORT samt Validierung.
// TestLoadWrapLabel: das Ansage-Label von `talk wrap` (Task 07) —
// Defaults → TOML → Env nach dem üblichen Schichten-Muster.
// TestLoadWrapLabel: the `talk wrap` announcement label, defaults → TOML →
// env in the usual layering.
func TestLoadWrapLabel(t *testing.T) {
writeTOML := func(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
t.Run("Default leer (Basisname des Befehls)", func(t *testing.T) {
cfg, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(nil),
})
t.Run("default empty (command base name)", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Wrap.Label != "" {
t.Errorf("Wrap.Label = %q, want leer (Default)", cfg.Wrap.Label)
t.Errorf("Wrap.Label = %q, want empty (default)", cfg.Wrap.Label)
}
})
t.Run("TOML überschreibt Default", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[wrap]\nlabel = \"Deployment\"\n"),
Getenv: env(nil),
})
t.Run("TOML overrides default", func(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, "[wrap]\nlabel = \"Deployment\"\n"), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Wrap.Label != "Deployment" {
t.Errorf("Wrap.Label = %q, want %q aus TOML", cfg.Wrap.Label, "Deployment")
t.Errorf("Wrap.Label = %q, want %q from TOML", cfg.Wrap.Label, "Deployment")
}
})
t.Run("TALK_WRAP_LABEL schlägt Datei", func(t *testing.T) {
t.Run("TALK_WRAP_LABEL beats the file", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[wrap]\nlabel = \"Deployment\"\n"),
Getenv: env(map[string]string{"TALK_WRAP_LABEL": "Nightly Build"}),
@@ -341,48 +253,35 @@ func TestLoadWrapLabel(t *testing.T) {
t.Fatalf("Load: %v", err)
}
if cfg.Wrap.Label != "Nightly Build" {
t.Errorf("Wrap.Label = %q, want Env-Override %q", cfg.Wrap.Label, "Nightly Build")
t.Errorf("Wrap.Label = %q, want env override %q", cfg.Wrap.Label, "Nightly Build")
}
})
}
// TestLoadServerPort: daemon port by default (7800), TOML [server] port and
// env override TALK_PORT including validation.
func TestLoadServerPort(t *testing.T) {
writeTOML := func(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
t.Run("Default 7800", func(t *testing.T) {
cfg, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(nil),
})
t.Run("default 7800", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Port != DefaultServerPort {
t.Errorf("Server.Port = %d, want Default %d", cfg.Server.Port, DefaultServerPort)
t.Errorf("Server.Port = %d, want default %d", cfg.Server.Port, DefaultServerPort)
}
})
t.Run("TOML überschreibt Default", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[server]\nport = 7999\n"),
Getenv: env(nil),
})
t.Run("TOML overrides default", func(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, "[server]\nport = 7999\n"), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Port != 7999 {
t.Errorf("Server.Port = %d, want 7999 aus TOML", cfg.Server.Port)
t.Errorf("Server.Port = %d, want 7999 from TOML", cfg.Server.Port)
}
})
t.Run("TALK_PORT schlägt Datei", func(t *testing.T) {
t.Run("TALK_PORT beats the file", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[server]\nport = 7999\n"),
Getenv: env(map[string]string{"TALK_PORT": "7912"}),
@@ -391,92 +290,73 @@ func TestLoadServerPort(t *testing.T) {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Port != 7912 {
t.Errorf("Server.Port = %d, want Env-Override 7912", cfg.Server.Port)
t.Errorf("Server.Port = %d, want env override 7912", cfg.Server.Port)
}
})
t.Run("ungültiger TALK_PORT-Wert ist klarer Fehler", func(t *testing.T) {
t.Run("invalid TALK_PORT is a clear error", func(t *testing.T) {
for _, bad := range []string{"null", "0", "-1", "70000"} {
_, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(map[string]string{"TALK_PORT": bad}),
})
_, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_PORT": bad})})
if err == nil || !strings.Contains(err.Error(), "TALK_PORT") {
t.Fatalf("TALK_PORT=%q: Fehler = %v, will klaren TALK_PORT-Fehler", bad, err)
t.Fatalf("TALK_PORT=%q: error = %v, want a clear TALK_PORT error", bad, err)
}
}
})
}
func TestLoadAllowedOrigins(t *testing.T) {
writeTOML := func(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
t.Run("Default: leere Liste (kein CORS)", func(t *testing.T) {
cfg, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(nil),
})
t.Run("default: empty list (no CORS)", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.Server.AllowedOrigins) != 0 {
t.Errorf("AllowedOrigins = %v, want leer (Tür zu)", cfg.Server.AllowedOrigins)
t.Errorf("AllowedOrigins = %v, want empty (door shut)", cfg.Server.AllowedOrigins)
}
})
t.Run("TOML-Liste", func(t *testing.T) {
t.Run("TOML list", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[server]\nallowed_origins = [\"https://chat.az-gruppe.com\"]\n"),
File: writeTOML(t, "[server]\nallowed_origins = [\"https://chat.example.com\"]\n"),
Getenv: env(nil),
})
if err != nil {
t.Fatalf("Load: %v", err)
}
want := []string{"https://chat.az-gruppe.com"}
if strings.Join(cfg.Server.AllowedOrigins, "|") != strings.Join(want, "|") {
t.Errorf("AllowedOrigins = %v, want %v", cfg.Server.AllowedOrigins, want)
if strings.Join(cfg.Server.AllowedOrigins, "|") != "https://chat.example.com" {
t.Errorf("AllowedOrigins = %v", cfg.Server.AllowedOrigins)
}
})
t.Run("TALK_ALLOWED_ORIGINS schlägt Datei (mehrere, mit Leerzeichen)", func(t *testing.T) {
t.Run("TALK_ALLOWED_ORIGINS beats the file (several, with spaces)", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[server]\nallowed_origins = [\"https://datei.example\"]\n"),
Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "https://chat.az-gruppe.com, https://grid.ag"}),
File: writeTOML(t, "[server]\nallowed_origins = [\"https://file.example\"]\n"),
Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "https://chat.example.com, https://grid.example"}),
})
if err != nil {
t.Fatalf("Load: %v", err)
}
want := []string{"https://chat.az-gruppe.com", "https://grid.ag"}
if strings.Join(cfg.Server.AllowedOrigins, "|") != strings.Join(want, "|") {
want := "https://chat.example.com|https://grid.example"
if strings.Join(cfg.Server.AllowedOrigins, "|") != want {
t.Errorf("AllowedOrigins = %v, want %v", cfg.Server.AllowedOrigins, want)
}
})
t.Run("TALK_ALLOWED_ORIGINS=off leert die Datei-Liste", func(t *testing.T) {
t.Run("TALK_ALLOWED_ORIGINS=off clears the file list", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[server]\nallowed_origins = [\"https://datei.example\"]\n"),
File: writeTOML(t, "[server]\nallowed_origins = [\"https://file.example\"]\n"),
Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "off"}),
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.Server.AllowedOrigins) != 0 {
t.Errorf("AllowedOrigins = %v, want leer (off)", cfg.Server.AllowedOrigins)
t.Errorf("AllowedOrigins = %v, want empty (off)", cfg.Server.AllowedOrigins)
}
})
t.Run("Wildcard ist erlaubter Wert", func(t *testing.T) {
cfg, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "*"}),
})
t.Run("wildcard is an allowed value", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "*"})})
if err != nil {
t.Fatalf("Load: %v", err)
}
@@ -485,69 +365,53 @@ func TestLoadAllowedOrigins(t *testing.T) {
}
})
t.Run("leere Einträge sind klare Fehler", func(t *testing.T) {
t.Run("empty entries are clear errors", func(t *testing.T) {
for _, tc := range []struct {
name string
file string
envv string
}{
{"env", "", "https://a.example,,https://b.example"},
{"datei", "[server]\nallowed_origins = [\"https://a.example\", \"\"]\n", ""},
{"file", "[server]\nallowed_origins = [\"https://a.example\", \"\"]\n", ""},
} {
t.Run(tc.name, func(t *testing.T) {
file := filepath.Join(t.TempDir(), "gibt-es-nicht.toml")
file := missingFile(t)
if tc.file != "" {
file = writeTOML(t, tc.file)
}
_, err := Load(Options{
File: file,
Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": tc.envv}),
})
_, err := Load(Options{File: file, Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": tc.envv})})
if err == nil || !strings.Contains(err.Error(), "allowed_origins") && !strings.Contains(err.Error(), "ALLOWED_ORIGINS") {
t.Fatalf("Fehler = %v, will klaren allowed_origins-Fehler", err)
t.Fatalf("error = %v, want a clear allowed_origins error", err)
}
})
}
})
}
// TestLoadGesetze deckt die Task-05-Felder ab: Rate-Limit (Default 30 s,
// "0s" = unbegrenzt), High-Burst (Default an), Ruhezeiten (Default aus;
// Format validiert) samt Env-Overrides und klaren Fehlern.
func TestLoadGesetze(t *testing.T) {
writeTOML := func(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
fehlendeDatei := func(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "gibt-es-nicht.toml")
}
t.Run("Defaults: 30 s, Burst an, Ruhezeit aus, Modus discard", func(t *testing.T) {
cfg, err := Load(Options{File: fehlendeDatei(t), Getenv: env(nil)})
// TestLoadLaws covers rate limit (default 30 s, "0s" = unlimited), high
// burst (default on) and quiet hours (default off; format validated) with
// env overrides and clear errors.
func TestLoadLaws(t *testing.T) {
t.Run("defaults: 30 s, burst on, quiet hours off, mode discard", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Queue.RateLimit != DefaultRateLimit {
t.Errorf("Queue.RateLimit = %v, want Default %v", cfg.Queue.RateLimit, DefaultRateLimit)
t.Errorf("Queue.RateLimit = %v, want default %v", cfg.Queue.RateLimit, DefaultRateLimit)
}
if !cfg.Queue.AllowHighBurst {
t.Error("Queue.AllowHighBurst = false, want Default true")
t.Error("Queue.AllowHighBurst = false, want default true")
}
if cfg.QuietHours != "" {
t.Errorf("QuietHours = %q, want Default leer (aus)", cfg.QuietHours)
t.Errorf("QuietHours = %q, want default empty (off)", cfg.QuietHours)
}
if cfg.QuietMode != queue.QuietModeDiscard {
t.Errorf("QuietMode = %q, want Default %q", cfg.QuietMode, queue.QuietModeDiscard)
t.Errorf("QuietMode = %q, want default %q", cfg.QuietMode, queue.QuietModeDiscard)
}
})
t.Run("TOML setzt alle Gesetz-Felder", func(t *testing.T) {
t.Run("TOML sets all law fields", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, `
quiet_hours = "18:00-08:00"
@@ -566,36 +430,33 @@ allow_high_burst = false
t.Errorf("Queue.RateLimit = %v, want 10s", cfg.Queue.RateLimit)
}
if cfg.Queue.AllowHighBurst {
t.Error("Queue.AllowHighBurst = true, want false aus TOML")
t.Error("Queue.AllowHighBurst = true, want false from TOML")
}
if cfg.QuietHours != "18:00-08:00" {
t.Errorf("QuietHours = %q, want Fenster aus TOML", cfg.QuietHours)
t.Errorf("QuietHours = %q, want window from TOML", cfg.QuietHours)
}
if cfg.QuietMode != queue.QuietModeToast {
t.Errorf("QuietMode = %q, want toast", cfg.QuietMode)
}
})
t.Run("partielle [queue]-Tabelle behält Defaults", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[queue]\nrate_limit = \"0s\"\n"),
Getenv: env(nil),
})
t.Run("partial [queue] table keeps defaults", func(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, "[queue]\nrate_limit = \"0s\"\n"), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Queue.RateLimit != 0 {
t.Errorf("Queue.RateLimit = %v, want 0 (unbegrenzt)", cfg.Queue.RateLimit)
t.Errorf("Queue.RateLimit = %v, want 0 (unlimited)", cfg.Queue.RateLimit)
}
if !cfg.Queue.AllowHighBurst {
t.Error("Queue.AllowHighBurst = false, want unveränderten Default true")
t.Error("Queue.AllowHighBurst = false, want unchanged default true")
}
if cfg.QuietMode != queue.QuietModeDiscard {
t.Errorf("QuietMode = %q, want unveränderten Default discard", cfg.QuietMode)
t.Errorf("QuietMode = %q, want unchanged default discard", cfg.QuietMode)
}
})
t.Run("Env überschreibt Datei (inkl. off)", func(t *testing.T) {
t.Run("env overrides file (including off)", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, `
quiet_hours = "18:00-08:00"
@@ -616,96 +477,58 @@ allow_high_burst = false
t.Fatalf("Load: %v", err)
}
if cfg.Queue.RateLimit != time.Second {
t.Errorf("Queue.RateLimit = %v, want 1s aus Env", cfg.Queue.RateLimit)
t.Errorf("Queue.RateLimit = %v, want 1s from env", cfg.Queue.RateLimit)
}
if !cfg.Queue.AllowHighBurst {
t.Error("Queue.AllowHighBurst = false, want true aus Env")
t.Error("Queue.AllowHighBurst = false, want true from env")
}
if cfg.QuietHours != "" {
t.Errorf("QuietHours = %q, want leer (off aus Env)", cfg.QuietHours)
t.Errorf("QuietHours = %q, want empty (off from env)", cfg.QuietHours)
}
if cfg.QuietMode != queue.QuietModeToast {
t.Errorf("QuietMode = %q, want toast aus Env", cfg.QuietMode)
t.Errorf("QuietMode = %q, want toast from env", cfg.QuietMode)
}
})
t.Run("ungültige Gesetz-Werte sind klare Fehler", func(t *testing.T) {
t.Run("invalid law values are clear errors", func(t *testing.T) {
cases := []struct {
name string
toml string
envs map[string]string
want string
}{
{
name: "TALK_RATE_LIMIT Müll",
envs: map[string]string{"TALK_RATE_LIMIT": "schnell"},
want: "TALK_RATE_LIMIT",
},
{
name: "TALK_RATE_LIMIT negativ",
envs: map[string]string{"TALK_RATE_LIMIT": "-5s"},
want: "TALK_RATE_LIMIT",
},
{
name: "rate_limit Müll in Datei",
toml: "[queue]\nrate_limit = \"müll\"\n",
want: "rate_limit",
},
{
name: "TALK_ALLOW_HIGH_BURST Müll",
envs: map[string]string{"TALK_ALLOW_HIGH_BURST": "vielleicht"},
want: "TALK_ALLOW_HIGH_BURST",
},
{
name: "quiet_hours Müll in Datei",
toml: "quiet_hours = \"abends\"\n",
want: "quiet_hours",
},
{
name: "TALK_QUIET_HOURS Müll",
envs: map[string]string{"TALK_QUIET_HOURS": "25:00-26:00"},
want: "TALK_QUIET_HOURS",
},
{
name: "TALK_QUIET_MODE Müll",
envs: map[string]string{"TALK_QUIET_MODE": "vibrieren"},
want: "TALK_QUIET_MODE",
},
{"TALK_RATE_LIMIT garbage", "", map[string]string{"TALK_RATE_LIMIT": "fast"}, "TALK_RATE_LIMIT"},
{"TALK_RATE_LIMIT negative", "", map[string]string{"TALK_RATE_LIMIT": "-5s"}, "TALK_RATE_LIMIT"},
{"rate_limit garbage in file", "[queue]\nrate_limit = \"garbage\"\n", nil, "rate_limit"},
{"TALK_ALLOW_HIGH_BURST garbage", "", map[string]string{"TALK_ALLOW_HIGH_BURST": "maybe"}, "TALK_ALLOW_HIGH_BURST"},
{"quiet_hours garbage in file", "quiet_hours = \"evenings\"\n", nil, "quiet_hours"},
{"TALK_QUIET_HOURS garbage", "", map[string]string{"TALK_QUIET_HOURS": "25:00-26:00"}, "TALK_QUIET_HOURS"},
{"TALK_QUIET_MODE garbage", "", map[string]string{"TALK_QUIET_MODE": "vibrate"}, "TALK_QUIET_MODE"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
file := fehlendeDatei(t)
file := missingFile(t)
if tc.toml != "" {
file = writeTOML(t, tc.toml)
}
_, err := Load(Options{File: file, Getenv: env(tc.envs)})
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Fehler = %v, will klaren Fehler mit %q", err, tc.want)
t.Fatalf("error = %v, want a clear error mentioning %q", err, tc.want)
}
})
}
})
}
// TestLoadDaemonAutostart deckt [daemon] autostart ab: Default an, TOML
// kann es ausschalten, Env TALK_AUTOSTART schlägt die Datei, Unsinn ist ein
// klarer Fehler.
// TestLoadDaemonAutostart covers [daemon] autostart: default on, TOML can
// switch it off, env TALK_AUTOSTART beats the file, garbage is an error.
func TestLoadDaemonAutostart(t *testing.T) {
fehlendeDatei := filepath.Join(t.TempDir(), "gibt-es-nicht.toml")
writeTOML := func(t *testing.T, content string) string {
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
cfg, err := Load(Options{File: fehlendeDatei, Getenv: env(nil)})
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.Daemon.Autostart {
t.Error("Daemon.Autostart = false, want Default true")
t.Error("Daemon.Autostart = false, want default true")
}
cfg, err = Load(Options{File: writeTOML(t, "[daemon]\nautostart = false\n"), Getenv: env(nil)})
@@ -713,7 +536,7 @@ func TestLoadDaemonAutostart(t *testing.T) {
t.Fatalf("Load: %v", err)
}
if cfg.Daemon.Autostart {
t.Error("Daemon.Autostart = true, want false aus TOML")
t.Error("Daemon.Autostart = true, want false from TOML")
}
cfg, err = Load(Options{
@@ -724,33 +547,24 @@ func TestLoadDaemonAutostart(t *testing.T) {
t.Fatalf("Load: %v", err)
}
if !cfg.Daemon.Autostart {
t.Error("Daemon.Autostart = false, want true aus Env (Env schlägt Datei)")
t.Error("Daemon.Autostart = false, want true from env (env beats file)")
}
if _, err := Load(Options{File: fehlendeDatei, Getenv: env(map[string]string{"TALK_AUTOSTART": "vielleicht"})}); err == nil {
t.Error("TALK_AUTOSTART=vielleicht: kein Fehler, want klarer Fehler")
if _, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_AUTOSTART": "maybe"})}); err == nil {
t.Error("TALK_AUTOSTART=maybe: no error, want a clear error")
}
}
// TestLoadLimits deckt queue.max_text / queue.max_age ab: Defaults 400 / 2m,
// TOML und Env (Env schlägt Datei), 0 = unbegrenzt, Unsinn und negative
// Werte sind klare Fehler.
// TestLoadLimits covers queue.max_text / queue.max_age: defaults 400 / 2m,
// TOML and env (env beats file), 0 = unlimited, garbage and negative values
// are clear errors.
func TestLoadLimits(t *testing.T) {
fehlendeDatei := filepath.Join(t.TempDir(), "gibt-es-nicht.toml")
writeTOML := func(t *testing.T, content string) string {
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
cfg, err := Load(Options{File: fehlendeDatei, Getenv: env(nil)})
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Queue.MaxText != DefaultMaxText || cfg.Queue.MaxAge != DefaultMaxAge {
t.Errorf("Defaults = %d/%v, want %d/%v", cfg.Queue.MaxText, cfg.Queue.MaxAge, DefaultMaxText, DefaultMaxAge)
t.Errorf("defaults = %d/%v, want %d/%v", cfg.Queue.MaxText, cfg.Queue.MaxAge, DefaultMaxText, DefaultMaxAge)
}
cfg, err = Load(Options{File: writeTOML(t, "[queue]\nmax_text = 0\nmax_age = \"45s\"\n"), Getenv: env(nil)})
@@ -769,20 +583,49 @@ func TestLoadLimits(t *testing.T) {
t.Fatalf("Load: %v", err)
}
if cfg.Queue.MaxText != 250 || cfg.Queue.MaxAge != 0 {
t.Errorf("Env = %d/%v, want 250/0 (Env schlägt Datei)", cfg.Queue.MaxText, cfg.Queue.MaxAge)
t.Errorf("env = %d/%v, want 250/0 (env beats file)", cfg.Queue.MaxText, cfg.Queue.MaxAge)
}
for name, kv := range map[string]map[string]string{
"TALK_MAX_TEXT=abc": {"TALK_MAX_TEXT": "abc"},
"TALK_MAX_TEXT=-1": {"TALK_MAX_TEXT": "-1"},
"TALK_MAX_AGE=bald": {"TALK_MAX_AGE": "bald"},
"TALK_MAX_AGE=soon": {"TALK_MAX_AGE": "soon"},
"TALK_MAX_AGE=-1s": {"TALK_MAX_AGE": "-1s"},
} {
if _, err := Load(Options{File: fehlendeDatei, Getenv: env(kv)}); err == nil {
t.Errorf("%s: kein Fehler, want klarer Fehler", name)
if _, err := Load(Options{File: missingFile(t), Getenv: env(kv)}); err == nil {
t.Errorf("%s: no error, want a clear error", name)
}
}
if _, err := Load(Options{File: writeTOML(t, "[queue]\nmax_text = -5\n"), Getenv: env(nil)}); err == nil {
t.Error("max_text = -5 in TOML: kein Fehler, want klarer Fehler")
t.Error("max_text = -5 in TOML: no error, want a clear error")
}
}
// TestLoadLang covers the fallback language: default en, TOML, env
// TALK_LANG beats the file, anything but de/en is an error.
func TestLoadLang(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Lang != "en" {
t.Errorf("Lang = %q, want default en", cfg.Lang)
}
cfg, err = Load(Options{File: writeTOML(t, "lang = \"de\"\n"), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Lang != "de" {
t.Errorf("Lang = %q, want de from TOML", cfg.Lang)
}
cfg, err = Load(Options{File: writeTOML(t, "lang = \"de\"\n"), Getenv: env(map[string]string{"TALK_LANG": "en"})})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Lang != "en" {
t.Errorf("Lang = %q, want en from env", cfg.Lang)
}
if _, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_LANG": "fr"})}); err == nil || !strings.Contains(err.Error(), "lang") {
t.Errorf("TALK_LANG=fr: error = %v, want a clear lang error", err)
}
}
+4 -6
View File
@@ -1,7 +1,5 @@
// Package config implementiert die Config-Auflösung von talk: Defaults +
// optionale config.toml im platform-nativen Verzeichnis (~/.config/talk,
// ~/Library/Application Support/talk, %APPDATA%\talk) + Env-Overrides
// (TALK_*); Env schlägt Datei. Pfade und Env sind injizierbar (Tests).
// Voice-Mapping voice.<provider>.<lang> und Provider-Endpunkte folgen mit
// Task 03.
// Package config resolves talk's configuration in layers: built-in defaults,
// then the optional config.toml in the platform-native config directory,
// then TALK_* environment variables (which win). It validates the result and
// reports problems as clear errors instead of silently falling back.
package config
+14 -15
View File
@@ -6,26 +6,25 @@ import (
"runtime"
)
// fileName ist der Name der Config-Datei im Config-Verzeichnis.
// fileName is the config file's name inside the config directory.
const fileName = "config.toml"
// ErrNoHome meldet, dass das Heimatverzeichnis nicht bestimmt werden kann
// (keine HOME- bzw. APPDATA-Umgebungsvariable).
var ErrNoHome = errors.New("Config-Verzeichnis nicht bestimmbar (HOME bzw. APPDATA fehlt)")
// ErrNoHome reports that the home directory cannot be determined (no HOME or
// APPDATA environment variable).
var ErrNoHome = errors.New("config directory unknown (HOME or APPDATA not set)")
// Dir liefert das platform-native Config-Verzeichnis von talk auf dem
// aktuellen System: Linux ~/.config/talk (respektive $XDG_CONFIG_HOME/talk),
// macOS ~/Library/Application Support/talk, Windows %APPDATA%\talk.
// Dir returns talk's platform-native config directory: ~/.config/talk on
// Linux (or $XDG_CONFIG_HOME/talk), ~/Library/Application Support/talk on
// macOS, %APPDATA%\talk on Windows.
func Dir(getenv func(string) string) (string, error) {
return dirFor(runtime.GOOS, getenv)
}
// dirFor ist die testbare Plattformlogik hinter Dir: goos wird als
// Parameter übergeben, damit die Pfade für alle Betriebssysteme vom
// Dev-Host aus tischgesteuert geprüft werden können.
// dirFor is the testable platform logic behind Dir: goos is a parameter so
// all platforms can be table-tested from one host.
//
// XDG_CONFIG_HOME wird nur berücksichtigt, wenn es einen absoluten Pfad
// enthält (XDG-Spezifikation); relative Werte werden ignoriert.
// XDG_CONFIG_HOME is honoured only when it holds an absolute path (XDG
// spec); relative values are ignored.
func dirFor(goos string, getenv func(string) string) (string, error) {
if getenv == nil {
return "", ErrNoHome
@@ -43,7 +42,7 @@ func dirFor(goos string, getenv func(string) string) (string, error) {
return "", ErrNoHome
}
return filepath.Join(home, "Library", "Application Support", "talk"), nil
default: // linux und sonstige unixartige Systeme
default: // linux and other unix-likes
if xdg := getenv("XDG_CONFIG_HOME"); xdg != "" && filepath.IsAbs(xdg) {
return filepath.Join(xdg, "talk"), nil
}
@@ -55,8 +54,8 @@ func dirFor(goos string, getenv func(string) string) (string, error) {
}
}
// Path liefert den vollständigen Pfad der config.toml im platform-nativen
// Config-Verzeichnis (Diagnose, Setup-Wizard).
// Path returns the full path of config.toml in the config directory
// (diagnostics, setup).
func Path(getenv func(string) string) (string, error) {
dir, err := Dir(getenv)
if err != nil {
+51 -49
View File
@@ -1,9 +1,9 @@
// Package daemon ist der Hintergrundprozess von talk: HTTP-API auf
// 127.0.0.1, serialisierte Ansage-Queue mit Prioritäten und now-Sprung,
// persistenter Mute-State, die Anti-Spam-Gesetze (Rate-Limit, Ruhezeiten),
// CORS-Tür und MCP-Endpoint. Es gibt genau ein Binary: `talk daemon run`
// betreibt den Daemon im Vordergrund, die CLI startet ihn mit Spawn bei
// Bedarf selbst im Hintergrund (daemon.autostart).
// Package daemon is talk's background process: HTTP API on 127.0.0.1, a
// serialized announcement queue with priorities and the now jump,
// persistent mute state, the anti-spam laws (rate limit, quiet hours), the
// CORS door and the MCP endpoint. There is exactly one binary: `talk daemon
// run` runs the daemon in the foreground, and the CLI starts it in the
// background itself via Spawn when needed (daemon.autostart).
package daemon
import (
@@ -28,37 +28,38 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/speak"
)
// Dateien des Daemons im Config-Verzeichnis.
// Files of the daemon in the config directory.
const (
// LogFileName ist das Protokoll eines im Hintergrund gestarteten
// Daemons (Spawn leitet stdout/stderr dorthin; pro Start neu).
// LogFileName is the log of a daemon started in the background (Spawn
// redirects stdout/stderr there; fresh per start).
LogFileName = "daemon.log"
// PIDFileName hält die Prozess-ID des laufenden Daemons (für
// Diagnose und als Notbremse, wenn die HTTP-API nicht antwortet).
// PIDFileName holds the process ID of the running daemon (for
// diagnostics and as an emergency brake when the HTTP API does not
// answer).
PIDFileName = "daemon.pid"
)
// Options sind die Abhängigkeiten von Run.
// Options are the dependencies of Run.
type Options struct {
// Config ist die geladene Konfiguration (Port, Gesetze, Sink, …).
// Config is the loaded configuration (port, laws, sink, ...).
Config config.Config
// Getenv liefert Keys und Endpunkt-Overrides (nil = os.Getenv).
// Getenv supplies keys and endpoint overrides (nil = os.Getenv).
Getenv func(string) string
// Version ist die Daemon-Version für /status und MCP serverInfo.
// Version is the daemon version for /status and MCP serverInfo.
Version string
// Log empfängt das Protokoll (Vordergrund: stderr; Hintergrund: die
// Log-Datei). nil = os.Stderr.
// Log receives the log output (foreground: stderr; background: the log
// file). nil = os.Stderr.
Log io.Writer
}
// Run betreibt den Daemon, bis ctx endet (Signal), POST /shutdown kommt
// oder der HTTP-Server scheitert. Eine laufende Ansage wird beim
// Herunterfahren abgebrochen — der Daemon soll prompt reagieren. Ein
// belegter Port ist ein klarer Fehler („läuft bereits ein Daemon?").
// Run runs the daemon until ctx ends (signal), POST /shutdown arrives or
// the HTTP server fails. A playing announcement is cancelled on shutdown;
// the daemon should react promptly. A port in use is a clear error ("is
// another daemon running?").
func Run(ctx context.Context, opts Options) error {
getenv := opts.Getenv
if getenv == nil {
@@ -77,8 +78,8 @@ func Run(ctx context.Context, opts Options) error {
return err
}
// Anti-Spam-Gesetz: config.Load hat quiet_hours validiert — ein Fehler
// hier ist praktisch unmöglich, darf aber nie still werden.
// Anti-spam law: config.Load has validated quiet_hours; an error here
// is practically impossible but must never be silent.
quiet, err := queue.ParseQuietHours(cfg.QuietHours)
if err != nil {
return err
@@ -98,15 +99,15 @@ func Run(ctx context.Context, opts Options) error {
Voice: item.Voice,
})
if err == nil && !out.Discarded {
logger.Printf("ansage %s über %s (synthese %s, wiedergabe %s)", item.ID, out.Provider,
logger.Printf("announcement %s via %s (synthesis %s, playback %s)", item.ID, out.Provider,
out.Synthesis.Round(time.Millisecond), out.Playback.Round(time.Millisecond))
}
return err
}, lw)
// Ruhezeit-Toast: Desktop-Benachrichtigung statt Audio (quiet_mode =
// "toast"); best effort, Fehler nur im Protokoll. Läuft asynchron, damit
// ein hängender Notification-Daemon nie die Queue blockiert.
// Quiet-hours toast: desktop notification instead of audio (quiet_mode
// = "toast"); best effort, errors only in the log. Runs asynchronously
// so a hanging notification daemon never blocks the queue.
if cfg.QuietMode == queue.QuietModeToast {
if notify.Available() {
q.OnToast(func(item queue.Item) {
@@ -117,18 +118,19 @@ func Run(ctx context.Context, opts Options) error {
}()
})
} else {
logger.Printf("warnung: quiet_mode = toast, aber %s — Ruhezeit-Ansagen werden nur protokolliert", notify.Describe())
logger.Printf("warning: quiet_mode = toast, but %s; quiet-hours announcements are only logged", notify.Describe())
}
}
// Persistenter Mute-State und PID-Datei im Config-Dir. Ohne Config-Dir
// läuft der Daemon ohne beides weiter (Warnung) statt zu scheitern.
// Persistent mute state and PID file in the config dir. Without a
// config dir the daemon keeps running without both (warning) instead
// of failing.
var mutePath, pidPath string
if dir, err := config.Dir(getenv); err == nil {
mutePath = filepath.Join(dir, queue.MuteFileName)
pidPath = filepath.Join(dir, PIDFileName)
} else {
logger.Printf("warnung: Config-Dir nicht bestimmbar — Mute-State und PID-Datei deaktiviert (%v)", err)
logger.Printf("warning: config directory unknown; mute state and PID file disabled (%v)", err)
}
mute := queue.MuteStore{Path: mutePath}
@@ -156,12 +158,12 @@ func Run(ctx context.Context, opts Options) error {
ln, err := net.Listen("tcp", addr)
if err != nil {
q.Close()
return fmt.Errorf("Port belegen (%s): %w — läuft bereits ein talk daemon?", addr, err)
return fmt.Errorf("bind %s: %w; is another talk daemon running?", addr, err)
}
if pidPath != "" {
writePID(pidPath, logger)
// Die PID-Datei verschwindet als Letztes — `talk daemon stop`
// wartet darauf und weiß dann: Prozess ist fertig, nicht nur HTTP.
// The PID file disappears last: `talk daemon stop` waits for that
// and then knows the process is done, not just HTTP.
defer os.Remove(pidPath)
}
@@ -169,11 +171,11 @@ func Run(ctx context.Context, opts Options) error {
errCh := make(chan error, 1)
go func() { errCh <- srv.Serve(ln) }()
corsLog := "zu (kein CORS)"
corsLog := "closed (no CORS)"
if len(cfg.Server.AllowedOrigins) > 0 {
corsLog = fmt.Sprintf("%v", cfg.Server.AllowedOrigins)
}
logger.Printf("listening on http://%s (version %s, pid %d, audio sink %s, mute=%v, rate-limit %s, high-burst=%v, max-text %d, max-age %s, ruhezeit=%q modus %s, cors=%s, mcp=/mcp)",
logger.Printf("listening on http://%s (version %s, pid %d, audio sink %s, mute=%v, rate-limit %s, high-burst=%v, max-text %d, max-age %s, quiet-hours=%q mode %s, cors=%s, mcp=/mcp)",
addr, opts.Version, os.Getpid(), engine.SinkName(), mute.Muted(),
cfg.Queue.RateLimit, cfg.Queue.AllowHighBurst, cfg.Queue.MaxText, cfg.Queue.MaxAge, cfg.QuietHours, cfg.QuietMode, corsLog)
@@ -182,14 +184,14 @@ func Run(ctx context.Context, opts Options) error {
q.Close()
return fmt.Errorf("http server: %w", err)
case <-ctx.Done():
logger.Printf("Signal empfangen — fahre herunter (laufende Ansage wird abgebrochen)")
logger.Printf("signal received; shutting down (the playing announcement is cancelled)")
case <-shutdown:
logger.Printf("Shutdown angefordert (talk daemon stop) — fahre herunter")
logger.Printf("shutdown requested (talk daemon stop); shutting down")
}
// Reihenfolge: erst HTTP dicht (keine neuen Anfragen), dann die Queue
// schließen (laufende Ansage abbrechen, Worker beenden und darauf
// warten). So läuft nie eine Ansage nach dem Daemon-Ende weiter.
// Order: close HTTP first (no new requests), then close the queue
// (cancel the playing announcement, stop the worker and wait for it).
// That way no announcement ever keeps playing after the daemon ends.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
@@ -199,19 +201,19 @@ func Run(ctx context.Context, opts Options) error {
return nil
}
// writePID schreibt die eigene Prozess-ID (best effort; ein Fehler ist nur
// eine Protokollzeile — der Daemon funktioniert auch ohne PID-Datei).
// writePID writes our own process ID (best effort; an error is only a log
// line, the daemon works without a PID file too).
func writePID(path string, logger *log.Logger) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
logger.Printf("warnung: PID-Datei: %v", err)
logger.Printf("warning: PID file: %v", err)
return
}
if err := os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil {
logger.Printf("warnung: PID-Datei: %v", err)
logger.Printf("warning: PID file: %v", err)
}
}
// ReadPID liest die PID-Datei; 0, wenn keine (lesbare) Datei existiert.
// ReadPID reads the PID file; 0 if no (readable) file exists.
func ReadPID(dir string) int {
b, err := os.ReadFile(filepath.Join(dir, PIDFileName))
if err != nil {
@@ -224,9 +226,9 @@ func ReadPID(dir string) int {
return pid
}
// lineWriter reicht Zeilen an den Logger weiter (Zeitstempel + Präfix),
// statt sie roh zu schreiben: Provider-Kette, Queue und null-Sink schreiben
// zeilenweise, so entsteht ein einheitliches Protokoll.
// lineWriter forwards lines to the logger (timestamp + prefix) instead of
// writing them raw: provider chain, queue and null sink write line by line,
// which yields one uniform log.
type lineWriter struct{ l *log.Logger }
func (w lineWriter) Write(p []byte) (int, error) {
+12 -11
View File
@@ -6,15 +6,16 @@ import (
"os/exec"
)
// Spawn startet `exe daemon run` als eigenständigen Hintergrundprozess
// (eigene Session bzw. eigene Prozessgruppe, losgelöst vom Terminal), mit
// stdout/stderr in die Log-Datei logPath (pro Start neu angelegt; leer =
// verwerfen). env ist die Umgebung des Kindes (Keys, TALK_*-Overrides).
// Spawn starts `exe daemon run` as an independent background process (own
// session or own process group, detached from the terminal), with
// stdout/stderr going to the log file logPath (created fresh per start;
// empty = discard). env is the child's environment (keys, TALK_*
// overrides).
//
// Rückgabe ist ein Kanal, der genau einmal das Wait-Ergebnis liefert, wenn
// das Kind endet — der Aufrufer erkennt daran einen fehlgeschlagenen Start
// (z. B. Port belegt), ohne auf einen Timeout warten zu müssen. Endet der
// Aufrufer zuerst, läuft der Daemon weiter (das ist der Zweck).
// The return value is a channel that delivers the Wait result exactly once
// when the child exits; the caller uses it to detect a failed start (e.g.
// port in use) without waiting for a timeout. If the caller exits first,
// the daemon keeps running (that is the point).
func Spawn(exe, logPath string, env []string) (<-chan error, error) {
var logFile *os.File
if logPath == "" {
@@ -22,9 +23,9 @@ func Spawn(exe, logPath string, env []string) (<-chan error, error) {
}
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return nil, fmt.Errorf("Daemon-Log %s: %w", logPath, err)
return nil, fmt.Errorf("daemon log %s: %w", logPath, err)
}
defer logFile.Close() // das Kind hält sein eigenes Handle
defer logFile.Close() // the child holds its own handle
cmd := exec.Command(exe, "daemon", "run")
cmd.Env = env
@@ -33,7 +34,7 @@ func Spawn(exe, logPath string, env []string) (<-chan error, error) {
cmd.Stderr = logFile
detach(cmd)
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("Daemon starten (%s daemon run): %w", exe, err)
return nil, fmt.Errorf("starting daemon (%s daemon run): %w", exe, err)
}
exited := make(chan error, 1)
go func() { exited <- cmd.Wait() }()
+1 -1
View File
@@ -4,5 +4,5 @@ package daemon
import "os/exec"
// detach ist auf dieser Plattform ein No-op (kein Setsid, keine Konsole).
// detach is a no-op on this platform (no Setsid, no console).
func detach(*exec.Cmd) {}
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"syscall"
)
// detach löst das Kind vom Terminal: eigene Session (kein SIGHUP beim
// Schließen des Terminals, kein Ctrl-C der Shell).
// detach detaches the child from the terminal: own session (no SIGHUP when
// the terminal closes, no Ctrl-C from the shell).
func detach(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
}
+4 -4
View File
@@ -7,12 +7,12 @@ import (
"syscall"
)
// detachedProcess ist das Win32-Flag DETACHED_PROCESS: kein Konsolenfenster,
// keine Bindung an die Konsole des Aufrufers.
// detachedProcess is the Win32 flag DETACHED_PROCESS: no console window, no
// binding to the caller's console.
const detachedProcess = 0x00000008
// detach löst das Kind von der Konsole: eigene Prozessgruppe (kein Ctrl-C
// der Shell) und keine Konsole (kein aufblitzendes Fenster).
// detach detaches the child from the console: own process group (no Ctrl-C
// from the shell) and no console (no flashing window).
func detach(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | detachedProcess,
+19 -14
View File
@@ -1,3 +1,7 @@
// Package langdetect guesses whether an announcement is German or English.
// It is a pure function over two small word lists, deliberately without any
// ML: announcements are one or two sentences, and the only question is which
// voice should read them.
package langdetect
import (
@@ -5,14 +9,12 @@ import (
"unicode"
)
// Detect erkennt die Sprache eines Ansage-Texts und liefert "de" oder "en".
// Default (auch bei leerem oder unentscheidbarem Text) ist "de".
//
// Heuristik: Common-Word-Listen deutscher und englischer Wörter plus
// Umlaute/ß als starke Deutsch-Signale. Englisch gewinnt nur bei strikt
// mehr englischen Signalen als deutschen; bei Gleichstand entscheidet der
// Default "de". Die Funktion ist rein (kein globaler Zustand, keine IO).
func Detect(text string) string {
// Detect returns "de" or "en" for text. Umlauts and ß count as strong German
// signals; otherwise common-word hits decide. English wins only with strictly
// more English than German hits; ties and undecidable text (empty, numbers,
// punctuation) return fallback, which callers take from the configured
// default language.
func Detect(text, fallback string) string {
var de, en int
for _, r := range text {
switch r {
@@ -33,14 +35,18 @@ func Detect(text string) string {
}
}
if en > de {
switch {
case en > de:
return "en"
case de > en:
return "de"
default:
return fallback
}
return "de"
}
// germanWords sind häufige deutsche Wörter, die im Englischen nicht
// vorkommen (Mehrdeutigkeiten wie „was" und „in" wurden weggelassen).
// germanWords are common German words that do not occur in English
// (ambiguous ones such as "was" and "in" are left out on purpose).
var germanWords = map[string]struct{}{
"der": {}, "die": {}, "das": {}, "und": {}, "nicht": {}, "ist": {},
"ein": {}, "eine": {}, "einen": {}, "einem": {}, "einer": {},
@@ -58,8 +64,7 @@ var germanWords = map[string]struct{}{
"wurden": {}, "haben": {}, "geht": {}, "machen": {},
}
// englishWords sind häufige englische Wörter, die im Deutschen nicht
// vorkommen.
// englishWords are common English words that do not occur in German.
var englishWords = map[string]struct{}{
"the": {}, "is": {}, "not": {}, "and": {}, "with": {}, "for": {},
"on": {}, "of": {}, "to": {}, "from": {}, "by": {}, "at": {},
+26 -24
View File
@@ -2,36 +2,38 @@ package langdetect
import "testing"
// TestDetect ist tischgesteuert: Eingabetext → erwartete Sprache.
// Der Default "de" gilt für leeren, unentscheidbaren und gemischten
// Text ohne englische Mehrheit.
// TestDetect is table-driven: input text, expected language. The fallback
// applies to empty, undecidable and tied text; it is passed explicitly so
// the test pins both directions.
func TestDetect(t *testing.T) {
cases := []struct {
name string
text string
want string
name string
text string
fallback string
want string
}{
{"leer", "", "de"},
{"nur whitespace", " \n\t ", "de"},
{"nur interpunktion", "!!! ??? ...", "de"},
{"deutsch satz", "Der Build wurde erfolgreich abgeschlossen und das Deployment ist fertig.", "de"},
{"deutsch frage mit was", "Was macht er?", "de"},
{"deutsch umlaute", "Größe früh Ärzteübung — schön daß es klappt", "de"},
{"ß alleine", "weiß", "de"},
{"englisch satz", "The build has finished and the deployment is done.", "en"},
{"englisch imperativ", "Please wait until the tests are green.", "en"},
{"englisch kurz", "done", "en"},
{"gemischt englisch dominiert", "the build is done and ready for you", "en"},
{"gemischt deutsch dominiert", "Der build ist done und fertig für dich", "de"},
{"gemischt gleichstand", "der the", "de"},
{"unklare sprache", "Build 2026-10-18 14:22 UTC", "de"},
{"agenten-typisch en", "Task completed. Please review the changes and approve.", "en"},
{"agenten-typisch de", "Aufgabe abgeschlossen. Bitte prüfen und freigeben.", "de"},
{"empty → fallback de", "", "de", "de"},
{"empty → fallback en", "", "en", "en"},
{"whitespace only", " \n\t ", "en", "en"},
{"punctuation only", "!!! ??? ...", "de", "de"},
{"german sentence", "Der Build wurde erfolgreich abgeschlossen und das Deployment ist fertig.", "en", "de"},
{"undecidable german question → fallback", "Was macht er?", "en", "en"},
{"german umlauts", "Größe früh Ärzteübung — schön daß es klappt", "en", "de"},
{"ß alone", "weiß", "en", "de"},
{"english sentence", "The build has finished and the deployment is done.", "de", "en"},
{"english imperative", "Please wait until the tests are green.", "de", "en"},
{"english single word", "done", "de", "en"},
{"mixed, english dominates", "the build is done and ready for you", "de", "en"},
{"mixed, german dominates", "Der build ist done und fertig für dich", "en", "de"},
{"tie → fallback", "der the", "en", "en"},
{"undecidable → fallback", "Build 2026-10-18 14:22 UTC", "de", "de"},
{"agent-style en", "Task completed. Please review the changes and approve.", "de", "en"},
{"agent-style de", "Aufgabe abgeschlossen. Bitte prüfen und freigeben.", "en", "de"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := Detect(tc.text); got != tc.want {
t.Errorf("Detect(%q) = %q, want %q", tc.text, got, tc.want)
if got := Detect(tc.text, tc.fallback); got != tc.want {
t.Errorf("Detect(%q, %q) = %q, want %q", tc.text, tc.fallback, got, tc.want)
}
})
}
-4
View File
@@ -1,4 +0,0 @@
// Package langdetect erkennt die Sprache eines Ansage-Texts als reine
// Funktion Detect(text) → "de" | "en" (Default "de"; Heuristik über
// Common-Word-Listen und Umlaute/ß).
package langdetect
+21 -21
View File
@@ -1,13 +1,13 @@
// Package notify zeigt Desktop-Benachrichtigungen — best effort, über die
// Bordmittel des Betriebssystems, ohne Bibliotheks-Abhängigkeit und ohne
// CGO (dieselbe Linie wie provider/osbuiltin):
// Package notify shows desktop notifications: best effort, through the
// operating system's own tools, without a library dependency and without
// CGO (the same line as provider/osbuiltin):
//
// Linux: notify-send (libnotify; braucht einen Notification-Daemon)
// Linux: notify-send (libnotify; needs a notification daemon)
// macOS: osascript `display notification`
// Windows: PowerShell Toast (Windows.UI.Notifications)
// Windows: PowerShell toast (Windows.UI.Notifications)
//
// Fehlende Werkzeuge sind ein klarer Fehler für den Aufrufer (Protokoll),
// nie ein Abbruch: Eine Benachrichtigung ist Zusatz, kein Vertrag.
// Missing tools are a clear error for the caller (log), never an abort: a
// notification is an extra, not a contract.
package notify
import (
@@ -23,30 +23,30 @@ import (
"unicode/utf16"
)
// Title ist der Titel aller talk-Benachrichtigungen.
// Title is the title of all talk notifications.
const Title = "talk"
// timeout begrenzt den Aufruf des OS-Werkzeugs.
// timeout bounds the call of the OS tool.
const timeout = 10 * time.Second
// Available meldet, ob auf diesem System ein Benachrichtigungs-Weg
// existiert (Binary vorhanden; ob ein Daemon lauscht, zeigt erst Show).
// Available reports whether a notification path exists on this system
// (binary present; whether a daemon listens only Show reveals).
func Available() bool {
_, err := exec.LookPath(tool())
return err == nil
}
// Describe nennt den Weg für `talk doctor`.
// Describe names the path for `talk doctor`.
func Describe() string {
t := tool()
if _, err := exec.LookPath(t); err != nil {
return t + " nicht gefunden"
return t + " not found"
}
switch runtime.GOOS {
case "darwin":
return "osascript (Notification Center)"
case "windows":
return "powershell (Windows Toast)"
return "powershell (Windows toast)"
default:
return "notify-send (libnotify)"
}
@@ -63,8 +63,8 @@ func tool() string {
}
}
// Show zeigt eine Benachrichtigung mit Title und text und wartet auf das
// Ende des OS-Aufrufs (nicht auf das Schließen der Benachrichtigung).
// Show shows a notification with Title and text and waits for the OS call
// to finish (not for the notification to be closed).
func Show(ctx context.Context, text string) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
@@ -79,18 +79,18 @@ func Show(ctx context.Context, text string) error {
cmd = exec.CommandContext(ctx, "notify-send", "--app-name="+Title, "--urgency=normal", Title, text)
}
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("notify: %s fehlgeschlagen: %w: %s", tool(), err, bytes.TrimSpace(out))
return fmt.Errorf("notify: %s failed: %w: %s", tool(), err, bytes.TrimSpace(out))
}
return nil
}
// appleString macht aus s ein AppleScript-Stringliteral.
// appleString turns s into an AppleScript string literal.
func appleString(s string) string {
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
}
// toastScript ist der PowerShell-Toast (Windows 10/11); der Text wird
// base64 übergeben, damit es kein Quoting-Problem gibt.
// toastScript is the PowerShell toast (Windows 10/11); the text is passed
// base64-encoded so there is no quoting problem.
func toastScript(text string) string {
return fmt.Sprintf(`$ErrorActionPreference = 'Stop'
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
@@ -103,7 +103,7 @@ $nodes.Item(1).AppendChild($xml.CreateTextNode($msg)) | Out-Null
`, base64.StdEncoding.EncodeToString([]byte(text)), Title, Title)
}
// psEncode kodiert ein Skript für -EncodedCommand (UTF-16LE + Base64).
// psEncode encodes a script for -EncodedCommand (UTF-16LE + base64).
func psEncode(script string) string {
runes := utf16.Encode([]rune(script))
buf := make([]byte, len(runes)*2)
+15 -15
View File
@@ -9,12 +9,12 @@ import (
"testing"
)
// TestShowRuftNotifySend: Auf Linux geht Show über notify-send — hier ein
// Fake im PATH, der seine Argumente protokolliert (echter Prozess, kein
// Mock-Framework). Titel und Text kommen unverändert an.
func TestShowRuftNotifySend(t *testing.T) {
// TestShowCallsNotifySend: on Linux, Show goes through notify-send; here a
// fake in PATH that records its arguments (a real process, no mock
// framework). Title and text arrive unchanged.
func TestShowCallsNotifySend(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("notify-send-Weg gibt es nur auf Linux")
t.Skip("the notify-send path exists only on Linux")
}
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
@@ -25,9 +25,9 @@ func TestShowRuftNotifySend(t *testing.T) {
t.Setenv("PATH", dir)
if !Available() {
t.Fatal("Available = false mit Fake im PATH")
t.Fatal("Available = false with fake in PATH")
}
if err := Show(context.Background(), "Ruhezeit: Build fertig"); err != nil {
if err := Show(context.Background(), "quiet hours: build done"); err != nil {
t.Fatalf("Show: %v", err)
}
b, err := os.ReadFile(seen)
@@ -35,27 +35,27 @@ func TestShowRuftNotifySend(t *testing.T) {
t.Fatal(err)
}
args := strings.Split(strings.TrimSpace(string(b)), "\n")
if args[len(args)-1] != "Ruhezeit: Build fertig" || args[len(args)-2] != Title {
t.Errorf("notify-send-Argumente = %q, want … %q %q", args, Title, "Ruhezeit: Build fertig")
if args[len(args)-1] != "quiet hours: build done" || args[len(args)-2] != Title {
t.Errorf("notify-send arguments = %q, want ... %q %q", args, Title, "quiet hours: build done")
}
}
// TestShowOhneWerkzeug: fehlt das Werkzeug, ist das ein klarer Fehler.
func TestShowOhneWerkzeug(t *testing.T) {
// TestShowWithoutTool: a missing tool is a clear error.
func TestShowWithoutTool(t *testing.T) {
t.Setenv("PATH", t.TempDir())
if Available() {
t.Fatal("Available = true ohne Werkzeug")
t.Fatal("Available = true without tool")
}
if err := Show(context.Background(), "x"); err == nil {
t.Error("Show ohne Werkzeug: kein Fehler")
t.Error("Show without tool: no error")
}
if !strings.Contains(Describe(), "nicht gefunden") {
if !strings.Contains(Describe(), "not found") {
t.Errorf("Describe = %q", Describe())
}
}
func TestAppleString(t *testing.T) {
if got := appleString(`sag "hallo" \ tschüss`); got != `"sag \"hallo\" \\ tschüss"` {
if got := appleString(`say "hello" \ bye`); got != `"say \"hello\" \\ bye"` {
t.Errorf("appleString = %s", got)
}
}
+14 -14
View File
@@ -2,10 +2,10 @@ package provider
import "sort"
// Voice ist ein Eintrag des Stimmen-Katalogs: eine bekannte Stimme eines
// Providers für eine Sprache, mit lesbarem Namen. Der Katalog ist kuratiert
// (die gängigen Standardstimmen), nicht vollständig — jede Voice-ID des
// Providers funktioniert auch ohne Katalog-Eintrag (--voice, config).
// Voice is an entry of the voice catalog: a known voice of a provider for a
// language, with a readable name. The catalog is curated (the common
// standard voices), not complete; any voice ID of the provider also works
// without a catalog entry (--voice, config).
type Voice struct {
Provider string
Lang string // de | en
@@ -13,10 +13,10 @@ type Voice struct {
Name string
}
// catalog sind die bekannten Stimmen je Provider. ElevenLabs: öffentliche
// Premade-Stimmen (multilingual, daher für de UND en gelistet). OpenAI: die
// Stimmen der TTS-API (sprachunabhängig). Edge: Neural-Stimmen der
// jeweiligen Locale. osbuiltin kennt keine IDs (das OS wählt).
// catalog lists the known voices per provider. ElevenLabs: public premade
// voices (multilingual, hence listed for both de AND en). OpenAI: the voices
// of the TTS API (language independent). Edge: neural voices of the
// respective locale. osbuiltin has no IDs (the OS chooses).
var catalog = []Voice{
{"elevenlabs", "de", "21m00Tcm4TlvDq8ikWAM", "Rachel"},
{"elevenlabs", "de", "pNInz6obpgDQGcFmaJgB", "Adam"},
@@ -54,8 +54,8 @@ var catalog = []Voice{
{"edge", "en", "en-GB-RyanNeural", "Ryan (GB)"},
}
// Catalog liefert den Stimmen-Katalog in stabiler Reihenfolge
// (Ketten-Reihenfolge der Provider, dann de vor en, dann Katalog-Reihe).
// Catalog returns the voice catalog in stable order (chain order of the
// providers, then de before en, then catalog order).
func Catalog() []Voice {
out := make([]Voice, len(catalog))
copy(out, catalog)
@@ -72,7 +72,7 @@ func Catalog() []Voice {
return out
}
// VoiceName liefert den Katalog-Namen einer Voice-ID; leer, wenn unbekannt.
// VoiceName returns the catalog name of a voice ID; empty if unknown.
func VoiceName(provider, id string) string {
for _, v := range catalog {
if v.Provider == provider && v.ID == id {
@@ -82,9 +82,9 @@ func VoiceName(provider, id string) string {
return ""
}
// DefaultVoice liefert die effektive Default-Stimme eines Providers für
// eine Sprache: config-Mapping voice.<provider>.<lang> vor dem Repo-Default
// (ohne --voice-Pin). Leer bei osbuiltin (das OS wählt).
// DefaultVoice returns the effective default voice of a provider for a
// language: config mapping voice.<provider>.<lang> before the repo default
// (without a --voice pin). Empty for osbuiltin (the OS chooses).
func DefaultVoice(provider, lang string, cfgVoices map[string]map[string]string) string {
return resolveVoice(provider, lang, "", cfgVoices[provider])
}
+105 -108
View File
@@ -9,69 +9,68 @@ import (
"time"
)
// DefaultOrder ist die bindende Default-Fallback-Reihenfolge der Kette
// (Spec: ElevenLabs → OpenAI TTS → Edge-TTS → OS built-in).
// DefaultOrder is the binding default fallback order of the chain
// (spec: ElevenLabs → OpenAI TTS → Edge TTS → OS built-in).
var DefaultOrder = []string{"elevenlabs", "openai", "edge", "osbuiltin"}
// NullPlayerName ist der dokumentierte Name des verwerfenden Sinks
// (Spec: audio.sink = "oto" | "null"). Die Kette braucht ihn für die
// Entscheidung, ob eine Direktansage (DirectSpeaker) verworfen werden
// muss — der null-Sink darf niemals Ton erzeugen.
// NullPlayerName is the documented name of the discarding sink
// (spec: audio.sink = "oto" | "null"). The chain needs it to decide
// whether a direct announcement (DirectSpeaker) must be discarded; the
// null sink must never produce sound.
const NullPlayerName = "null"
// Player spielt Audio-Bytes ab, die die Kette geliefert hat. Der Vertrag
// ist absichtlich strukturell identisch zu audio.Sink (Play + Name): CLI
// und Daemon (Task 04) übergeben das Ergebnis von audio.NewSink(name, log)
// unverändert — dadurch bleibt die Kette ohne Import des audio-Pakets
// testbar und wiederverwendbar.
// Player plays audio bytes the chain has produced. The contract is
// deliberately structurally identical to audio.Sink (Play + Name): CLI and
// daemon pass the result of audio.NewSink(name, log) unchanged, which keeps
// the chain testable and reusable without importing the audio package.
type Player interface {
Play(ctx context.Context, data []byte) error
Name() string
}
// ChainConfig ist alles, was die Kette zum Bau braucht. CLI (Task 03) und
// Daemon (Task 04) füllen sie aus Config + Flags; die Kette selbst liest
// keine Dateien und keine Prozess-Env — Getenv ist die injizierbare Seam
// (Tests und Daemon kommen so ohne echte Keys aus).
// ChainConfig is everything the chain needs for construction. CLI and
// daemon fill it from config + flags; the chain itself reads no files and
// no process environment. Getenv is the injectable seam (tests and daemon
// thus work without real keys).
type ChainConfig struct {
// Pin reduziert die Kette auf genau einen Provider (CLI-Flag --provider
// schlägt config `provider = "…"`). Leer = DefaultOrder.
// Pin reduces the chain to exactly one provider (CLI flag --provider
// beats config `provider = "…"`). Empty = DefaultOrder.
Pin string
// Voice ist eine explizite Voice-ID (CLI-Flag --voice); sie wird über
// VoicePinner an alle Stufen gepinnt und schlägt Mapping + Defaults.
// Voice is an explicit voice ID (CLI flag --voice); it is pinned on all
// tiers via VoicePinner and beats mapping + defaults.
Voice string
// Voices ist das voice.<provider>.<lang>-Mapping aus der config.toml.
// Voices is the voice.<provider>.<lang> mapping from config.toml.
Voices map[string]map[string]string
// Endpoints sind die Datei-Endpunkte [providers.<name>].endpoint;
// TALK_<NAME>_ENDPOINT (via Getenv) schlägt sie, sie schlägt die
// eingebauten Defaults (siehe endpointFrom).
// Endpoints are the file endpoints [providers.<name>].endpoint;
// TALK_<NAME>_ENDPOINT (via Getenv) beats them, they beat the built-in
// defaults (see endpointFrom).
Endpoints map[string]string
// Model ist [providers.openai].model (leer = "tts-1").
// Model is [providers.openai].model (empty = "tts-1").
Model string
// Getenv liefert Umgebungsvariablen (Keys, Endpunkt-Overrides). Nil
// wird beim Bau durch eine Funktion ersetzt, die immer "" liefert.
// Getenv returns environment variables (keys, endpoint overrides). Nil
// is replaced at construction by a function that always returns "".
Getenv func(string) string
// Player gibt erfolgreiche Synthesen aus; nil = die Kette liefert nur
// Bytes (Synthesize) und Speak meldet einen klaren Fehler.
// Player plays successful syntheses; nil = the chain only returns bytes
// (Synthesize) and Speak reports a clear error.
Player Player
// Log empfängt die Fallback-Protokoll-Zeilen (CLI: stderr). Fallbacks
// sind nie still: Jede übersprungene oder fehlgeschlagene Stufe wird
// hier gemeldet — auch wenn am Ende noch eine Stufe Erfolg hat.
// Log receives the fallback log lines (CLI: stderr). Fallbacks are never
// silent: every skipped or failed tier is reported here, even when a
// later tier succeeds.
Log io.Writer
}
// endpointFrom löst einen Provider-Endpunkt auf:
// TALK_<NAME>_ENDPOINT (Env) > [providers.<name>].endpoint (Datei) >
// eingebauter Default. Env-Overrides in der Provider-Schicht zu lesen hält
// das config-Paket frei von Provider-Namen und erlaubt Prozessgrenzen-
// Tests ohne Config-Datei.
// endpointFrom resolves a provider endpoint:
// TALK_<NAME>_ENDPOINT (env) > [providers.<name>].endpoint (file) >
// built-in default. Reading env overrides in the provider layer keeps the
// config package free of provider names and allows process-boundary tests
// without a config file.
func endpointFrom(cfg ChainConfig, name, envName, def string) string {
if cfg.Getenv != nil {
if v := cfg.Getenv(envName); v != "" {
@@ -84,25 +83,25 @@ func endpointFrom(cfg ChainConfig, name, envName, def string) string {
return def
}
// Chain ist die geordnete Fallback-Kette über Provider. Sie implementiert
// selbst Provider (Synthesize liefert die Bytes der ersten erfolgreichen
// Stufe) UND DirectSpeaker (letzter Stufen-Ausweg für Plattformen ohne
// Byte-fähiges TTS) — damit ist sie für CLI und Daemon (Task 04)
// austauschbar gegen jeden einzelnen Provider.
// Chain is the ordered fallback chain over providers. It implements
// Provider itself (Synthesize returns the bytes of the first successful
// tier) AND DirectSpeaker (last-resort tier for platforms without
// byte-capable TTS), so CLI and daemon can swap it for any single provider.
//
// Speak ist die komplette Ansage-Orchestrierung: Bytes-Synthese mit
// Fallback, Wiedergabe über den Player, und als letzte Stufe die
// Direktansage (osbuiltin über spd-say). Bei einem null-Player wird die
// Direktansage tonlos verworfen (CI/Server) statt Ton zu erzeugen.
// Speak is the complete announcement orchestration: byte synthesis with
// fallback, playback via the Player, and as the last tier the direct
// announcement (osbuiltin via spd-say). With a null player the direct
// announcement is discarded silently (CI/server) instead of producing
// sound.
type Chain struct {
cfg ChainConfig
tiers []Provider
}
// NewChain baut die Kette: gepinnt (Pin) oder in DefaultOrder. Unbekannte
// Namen sind ein klarer Fehler, der die verfügbaren Namen nennt. Das
// --voice-Pin wird auf alle Stufen angewandt, die VoicePinner implementieren
// (Stufen ohne Voice-Begriff bekommen einen protokollierten Hinweis).
// NewChain builds the chain: pinned (Pin) or in DefaultOrder. Unknown names
// are a clear error that lists the available names. The --voice pin is
// applied to all tiers implementing VoicePinner (tiers without a notion of
// voice get a logged note).
func NewChain(cfg ChainConfig) (*Chain, error) {
if cfg.Getenv == nil {
cfg.Getenv = func(string) string { return "" }
@@ -121,7 +120,7 @@ func NewChain(cfg ChainConfig) (*Chain, error) {
if vp, ok := p.(VoicePinner); ok {
vp.PinVoice(cfg.Voice)
} else {
chainLogf(cfg, "kette: --voice für Stufe %s ignoriert (kennt keine Voice-IDs)\n", name)
chainLogf(cfg, "chain: --voice ignored for tier %s (it has no voice IDs)\n", name)
}
}
tiers = append(tiers, p)
@@ -129,13 +128,13 @@ func NewChain(cfg ChainConfig) (*Chain, error) {
return &Chain{cfg: cfg, tiers: tiers}, nil
}
// Name implementiert Provider.
// Name implements Provider.
func (*Chain) Name() string { return "chain" }
// Tiers liefert die Stufen in Ketten-Reihenfolge (Diagnose/doctor).
// Tiers returns the tiers in chain order (diagnosis/doctor).
func (c *Chain) Tiers() []Provider { return c.tiers }
// Available implementiert Provider: true, sobald eine Stunde verfügbar ist.
// Available implements Provider: true as soon as one tier is available.
func (c *Chain) Available(ctx context.Context) bool {
for _, p := range c.tiers {
if p.Available(ctx) {
@@ -145,55 +144,54 @@ func (c *Chain) Available(ctx context.Context) bool {
return false
}
// walkResult ist das interne Ergebnis des Ketten-Durchlaufs.
// walkResult is the internal result of a chain walk.
type walkResult struct {
data []byte // Bytes der ersten erfolgreichen Stufe (nil = keine)
used Provider // die erfolgreiche Stufe
direct DirectSpeaker // erste Stufe, die nur direkt sprechen kann
data []byte // bytes of the first successful tier (nil = none)
used Provider // the successful tier
direct DirectSpeaker // first tier that can only speak directly
directName string
failed []string // "name: fehler" je fehlgeschlagener Stufe
skipped []string // Namen nicht verfügbarer Stufen
failed []string // "name: error" per failed tier
skipped []string // names of unavailable tiers
}
// walk läuft die Kette in Ordnung ab: nicht verfügbare Stufen werden
// übersprungen (protokolliert), fehlgeschlagene protokolliert und
// überwunden. Eine Stufe mit ErrNoByteOutput, die DirectSpeaker
// implementiert, wird als letzter Ausweg gemerkt (der Durchlauf geht
// weiter — eine spätere Stufe könnte noch Bytes liefern).
// walk runs the chain in order: unavailable tiers are skipped (logged),
// failed ones are logged and passed over. A tier returning ErrNoByteOutput
// that implements DirectSpeaker is remembered as the last resort (the walk
// continues; a later tier might still deliver bytes).
func (c *Chain) walk(ctx context.Context, text, lang string) walkResult {
var r walkResult
for _, p := range c.tiers {
if !p.Available(ctx) {
r.skipped = append(r.skipped, p.Name())
chainLogf(c.cfg, "kette: Stufe %s übersprungen (nicht verfügbar)\n", p.Name())
chainLogf(c.cfg, "chain: tier %s skipped (not available)\n", p.Name())
continue
}
data, err := p.Synthesize(ctx, text, lang)
switch {
case err == nil:
r.data, r.used = data, p
chainLogf(c.cfg, "kette: Ansage über %s\n", p.Name())
chainLogf(c.cfg, "chain: spoken via %s\n", p.Name())
return r
case errors.Is(err, ErrNoByteOutput) && r.direct == nil:
if ds, ok := p.(DirectSpeaker); ok {
r.direct, r.directName = ds, p.Name()
chainLogf(c.cfg, "kette: Stufe %s liefert keine Bytes, kann aber direkt sprechen (als letzte Stufe gemerkt)\n", p.Name())
chainLogf(c.cfg, "chain: tier %s yields no bytes but can speak directly (kept as last resort)\n", p.Name())
continue
}
r.failed = append(r.failed, p.Name()+": "+err.Error())
chainLogf(c.cfg, "kette: Stufe %s fehlgeschlagen: %v — Fallback auf nächste Stufe\n", p.Name(), err)
chainLogf(c.cfg, "chain: tier %s failed: %v; falling back to the next tier\n", p.Name(), err)
default:
r.failed = append(r.failed, p.Name()+": "+err.Error())
chainLogf(c.cfg, "kette: Stufe %s fehlgeschlagen: %v — Fallback auf nächste Stufe\n", p.Name(), err)
chainLogf(c.cfg, "chain: tier %s failed: %v; falling back to the next tier\n", p.Name(), err)
}
}
return r
}
// Synthesize implementiert Provider: Bytes der ersten erfolgreichen Stufe.
// Kann nur eine Direkt-Stufe sprechen, ist der Fehler ErrNoByteOutput (die
// Kette erfüllt damit den DirectSpeaker-Vertrag am Aufrufer). Scheitert
// alles, nennt der Fehler die letzte Stufen-Fehler — nie still.
// Synthesize implements Provider: bytes of the first successful tier. If
// only a direct tier can speak, the error is ErrNoByteOutput (the chain thus
// honours the DirectSpeaker contract towards the caller). If everything
// fails, the error names the tier errors; never silent.
func (c *Chain) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
r := c.walk(ctx, text, lang)
if r.data != nil {
@@ -201,50 +199,49 @@ func (c *Chain) Synthesize(ctx context.Context, text, lang string) ([]byte, erro
}
if r.direct != nil {
if len(r.failed) > 0 {
return nil, fmt.Errorf("%w (Stufe %s spricht nur direkt; daneben fehlgeschlagen: %s)",
return nil, fmt.Errorf("%w (tier %s speaks directly only; also failed: %s)",
ErrNoByteOutput, r.directName, strings.Join(r.failed, "; "))
}
return nil, fmt.Errorf("%w (Stufe %s spricht nur direkt)", ErrNoByteOutput, r.directName)
return nil, fmt.Errorf("%w (tier %s speaks directly only)", ErrNoByteOutput, r.directName)
}
return nil, r.noTierErr()
}
// SpeakDirect implementiert DirectSpeaker: Direktansage über die Stufe,
// die nur direkt sprechen kann (typischerweise osbuiltin/spd-say auf Linux
// ohne espeak). Gibt es eine Byte-fähige Stufe, ist SpeakDirect ein klarer
// Fehler (der Aufrufer soll Synthesize + Player nutzen).
// SpeakDirect implements DirectSpeaker: direct announcement via the tier
// that can only speak directly (typically osbuiltin/spd-say on Linux
// without espeak). If a byte-capable tier exists, SpeakDirect is a clear
// error (the caller should use Synthesize + Player).
func (c *Chain) SpeakDirect(ctx context.Context, text, lang string) error {
r := c.walk(ctx, text, lang)
if r.direct != nil {
return r.direct.SpeakDirect(ctx, text, lang)
}
if r.data != nil {
return fmt.Errorf("kette: Stufe %s liefert Bytes — über Synthesize und den Player abspielen, nicht direkt", r.used.Name())
return fmt.Errorf("chain: tier %s yields bytes; play them via Synthesize and the player, not directly", r.used.Name())
}
return fmt.Errorf("kette: keine Stufe kann direkt sprechen (nicht verfügbar: %s; fehlgeschlagen: %s)",
joinOr(r.skipped, "keine"), joinOr(r.failed, "keine"))
return fmt.Errorf("chain: no tier can speak directly (not available: %s; failed: %s)",
joinOr(r.skipped, "none"), joinOr(r.failed, "none"))
}
// Speak ist die komplette Ansage in EINEM Ketten-Durchlauf: Synthese mit
// Fallback → Wiedergabe über den Player; fällt die Kette auf die Direkt-
// Stufe zurück, entscheidet der Player-Name über Ton (alles außer „null")
// oder tonloses Verwerfen (null: CI/Server bleiben fehler- und tonfrei,
// der Verwurf wird protokolliert). Der Durchlauf findet bewusst genau
// einmal statt: fehlgeschlagene Cloud-Stufen werden nicht ein zweites Mal
// angefragt (keine doppelten POSTs auf bezahlten APIs, keine doppelten
// Protokoll-Zeilen), und Bytes aus diesem Durchlauf werden abgespielt,
// statt sie wegzuwerfen und neu zu laufen. Nur ein Fehler aller Stufen
// oder eine fehlgeschlagene Wiedergabe sind Fehler.
// Speak is the complete announcement in ONE chain walk: synthesis with
// fallback → playback via the Player; if the chain falls back to the direct
// tier, the player name decides between sound (anything but "null") and
// silent discarding (null: CI/server stay error- and sound-free, the
// discard is logged). The walk deliberately happens exactly once: failed
// cloud tiers are not requested a second time (no duplicate POSTs to paid
// APIs, no duplicate log lines), and bytes from this walk are played
// instead of being thrown away and re-run. Only a failure of all tiers or a
// failed playback are errors.
func (c *Chain) Speak(ctx context.Context, text, lang string) error {
_, err := c.SpeakOutcome(ctx, text, lang)
return err
}
// Outcome beschreibt eine gelungene Ansage: welche Stufe gesprochen hat,
// wie lange Synthese (Ketten-Durchlauf bis zu den Bytes) und Wiedergabe
// gedauert haben, und ob es eine Direktansage war (keine Bytes, Provider
// spielt selbst — dann ist Synthesis die Gesamtzeit und Playback 0).
// Discarded meldet die tonlose Verwerfung einer Direktansage am null-Sink.
// Outcome describes a successful announcement: which tier spoke, how long
// synthesis (chain walk up to the bytes) and playback took, and whether it
// was a direct announcement (no bytes, the provider plays itself; then
// Synthesis is the total time and Playback is 0). Discarded reports the
// silent discarding of a direct announcement on the null sink.
type Outcome struct {
Provider string
Direct bool
@@ -254,7 +251,7 @@ type Outcome struct {
Playback time.Duration
}
// SpeakOutcome ist Speak mit Bericht (Diagnose: `talk test`, Daemon-Log).
// SpeakOutcome is Speak with a report (diagnosis: `talk test`, daemon log).
func (c *Chain) SpeakOutcome(ctx context.Context, text, lang string) (Outcome, error) {
start := time.Now()
r := c.walk(ctx, text, lang)
@@ -262,16 +259,16 @@ func (c *Chain) SpeakOutcome(ctx context.Context, text, lang string) (Outcome, e
switch {
case r.data != nil:
if c.cfg.Player == nil {
return Outcome{}, fmt.Errorf("kette: kein Player konfiguriert — Bytes können nicht abgespielt werden")
return Outcome{}, fmt.Errorf("chain: no player configured; bytes cannot be played")
}
playStart := time.Now()
if err := c.cfg.Player.Play(ctx, r.data); err != nil {
return Outcome{}, fmt.Errorf("kette: Wiedergabe über %s fehlgeschlagen: %w", c.cfg.Player.Name(), err)
return Outcome{}, fmt.Errorf("chain: playback via %s failed: %w", c.cfg.Player.Name(), err)
}
return Outcome{Provider: r.used.Name(), Bytes: len(r.data), Synthesis: synth, Playback: time.Since(playStart)}, nil
case r.direct != nil:
if c.cfg.Player != nil && c.cfg.Player.Name() == NullPlayerName {
chainLogf(c.cfg, "kette: Direktansage verworfen (audio.sink=null)\n")
chainLogf(c.cfg, "chain: direct speech discarded (audio.sink=null)\n")
return Outcome{Provider: r.directName, Direct: true, Discarded: true, Synthesis: synth}, nil
}
if err := r.direct.SpeakDirect(ctx, text, lang); err != nil {
@@ -283,22 +280,22 @@ func (c *Chain) SpeakOutcome(ctx context.Context, text, lang string) (Outcome, e
}
}
// noTierErr bündelt den Ende-ohne-Ergebnis-Fehler eines Durchlaufs: keine
// Stufe lieferte Bytes oder Direktansage — übersprungene (nicht verfügbar)
// und fehlgeschlagene Stufen werden genannt, nie still.
// noTierErr bundles the no-result error of a walk: no tier delivered bytes
// or a direct announcement; skipped (unavailable) and failed tiers are
// named, never silent.
func (r walkResult) noTierErr() error {
return fmt.Errorf("kette: keine Stufe konnte sprechen (nicht verfügbar: %s; fehlgeschlagen: %s)",
joinOr(r.skipped, "keine"), joinOr(r.failed, "keine"))
return fmt.Errorf("chain: no tier could speak (not available: %s; failed: %s)",
joinOr(r.skipped, "none"), joinOr(r.failed, "none"))
}
// chainLogf schreibt eine Protokoll-Zeile, wenn ein Log gesetzt ist.
// chainLogf writes a log line if a log is set.
func chainLogf(cfg ChainConfig, format string, args ...any) {
if cfg.Log != nil {
fmt.Fprintf(cfg.Log, format, args...)
}
}
// joinOr verbindet Einträge mit sep; ist die Liste leer, kommt fallback.
// joinOr joins items with "; "; if the list is empty, fallback is returned.
func joinOr(items []string, fallback string) string {
if len(items) == 0 {
return fallback
+104 -105
View File
@@ -13,8 +13,8 @@ import (
"testing"
)
// fakePlayer zeichnet Play-Aufrufe auf (Name steuert die null-Entscheidung
// der Kette).
// fakePlayer records Play calls (the name drives the chain's null
// decision).
type fakePlayer struct {
name string
played [][]byte
@@ -26,8 +26,8 @@ func (f *fakePlayer) Play(_ context.Context, data []byte) error {
}
func (f *fakePlayer) Name() string { return f.name }
// countingTTSServer antwortet mit status + body und zählt die Requests
// (atomic, der Server kann mehrere Stufen-Requests sehen).
// countingTTSServer responds with status + body and counts the requests
// (atomic; the server may see requests from several tiers).
func countingTTSServer(t *testing.T, status int, body []byte) (url string, hits *atomic.Int32) {
t.Helper()
var n atomic.Int32
@@ -40,13 +40,13 @@ func countingTTSServer(t *testing.T, status int, body []byte) (url string, hits
return ts.URL, &n
}
// chainEnv baut die Getenv-Seam für Tests mit gefakten Cloud-Endpunkten.
// chainEnv builds the Getenv seam for tests with faked cloud endpoints.
func chainEnv(kv map[string]string) func(string) string {
return func(k string) string { return kv[k] }
}
// fakeProcBin schreibt ein ausführbares Shell-Skript (gleicher Mechanismus
// wie in osbuiltin_test.go — echter Prozess, PATH-Manipulation).
// fakeProcBin writes an executable shell script (same mechanism as in
// osbuiltin_test.go: real process, PATH manipulation).
func fakeProcBin(t *testing.T, dir, name, body string) {
t.Helper()
script := "#!/bin/sh\n" + body + "\n"
@@ -55,9 +55,9 @@ func fakeProcBin(t *testing.T, dir, name, body string) {
}
}
// cloudEnv liefert die Env-Map für eine Kette, deren elevenlabs-Stufe auf
// elevenURL und deren openai-Stufe auf openaiURL zeigt (edge tot, damit
// kein Test den echten Microsoft-Endpunkt anfasst).
// cloudEnv returns the env map for a chain whose elevenlabs tier points at
// elevenURL and whose openai tier points at openaiURL (edge is dead so that
// no test touches the real Microsoft endpoint).
func cloudEnv(elevenURL, openaiURL string, extra map[string]string) map[string]string {
kv := map[string]string{
EnvElevenLabsKey: "k",
@@ -72,11 +72,10 @@ func cloudEnv(elevenURL, openaiURL string, extra map[string]string) map[string]s
return kv
}
// TestChainFallbackErster500ZweiterTreffer (Abnahmekriterium aus dem
// Task-Brief): Stufe 1 (elevenlabs) antwortet 500 → die Kette fällt auf
// Stufe 2 (openai) durch, deren Bytes kommen zurück; der Fallback ist im
// Log sichtbar, nie still.
func TestChainFallbackErster500ZweiterTreffer(t *testing.T) {
// TestChainFallbackFirst500SecondHit (acceptance criterion): tier 1
// (elevenlabs) answers 500 → the chain falls through to tier 2 (openai),
// whose bytes come back; the fallback is visible in the log, never silent.
func TestChainFallbackFirst500SecondHit(t *testing.T) {
eleven, elHits := countingTTSServer(t, 500, []byte("boom"))
openai, oaHits := countingTTSServer(t, 200, fakeMP3)
@@ -89,30 +88,30 @@ func TestChainFallbackErster500ZweiterTreffer(t *testing.T) {
t.Fatalf("NewChain: %v", err)
}
data, err := ch.Synthesize(context.Background(), "Hallo Welt", "de")
data, err := ch.Synthesize(context.Background(), "Hello world", "de")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if string(data) != string(fakeMP3) {
t.Errorf("Synthesize = %q, want Bytes der openai-Stufe", data)
t.Errorf("Synthesize = %q, want bytes of the openai tier", data)
}
if elHits.Load() != 1 || oaHits.Load() != 1 {
t.Errorf("Hits: elevenlabs = %d, openai = %d — want je 1 (500 → Fallback)", elHits.Load(), oaHits.Load())
t.Errorf("hits: elevenlabs = %d, openai = %d; want 1 each (500 → fallback)", elHits.Load(), oaHits.Load())
}
for _, want := range []string{"elevenlabs", "500", "Fallback", "Ansage über openai"} {
for _, want := range []string{"elevenlabs", "500", "falling back", "spoken via openai"} {
if !strings.Contains(log.String(), want) {
t.Errorf("Log = %q, will %q", log.String(), want)
t.Errorf("log = %q, want %q", log.String(), want)
}
}
}
// TestChainAllesFehlt: beide Cloud-Stufen 500, edge tot, osbuiltin nicht
// verfügbar → Fehler nennt die fehlgeschlagenen Stufen; Log zeigt jeden.
func TestChainAllesFehlt(t *testing.T) {
// TestChainEverythingFails: both cloud tiers 500, edge dead, osbuiltin not
// available → the error names the failed tiers; the log shows each one.
func TestChainEverythingFails(t *testing.T) {
eleven, _ := countingTTSServer(t, 500, []byte("el-boom"))
openai, _ := countingTTSServer(t, 500, []byte("oa-boom"))
// PATH leer: osbuiltin nicht verfügbar.
// Empty PATH: osbuiltin not available.
t.Setenv("PATH", t.TempDir())
var log bytes.Buffer
@@ -124,21 +123,21 @@ func TestChainAllesFehlt(t *testing.T) {
t.Fatalf("NewChain: %v", err)
}
_, err = ch.Synthesize(context.Background(), "Hallo", "de")
_, err = ch.Synthesize(context.Background(), "Hello", "de")
if err == nil {
t.Fatal("Synthesize mit allen Stufen kaputt: kein Fehler")
t.Fatal("Synthesize with all tiers broken: no error")
}
for _, want := range []string{"elevenlabs", "openai", "osbuiltin", "500"} {
if !strings.Contains(err.Error(), want) && !strings.Contains(log.String(), want) {
t.Errorf("Weder Fehler (%v) noch Log nennen %q", err, want)
t.Errorf("neither error (%v) nor log mention %q", err, want)
}
}
}
// TestChainOhneKeysEdgeOsbuiltin (Abnahmekriterium): ohne jeden Key und
// mit totem edge wählt die Kette osbuiltin — mit gefaktem espeak kommen
// dessen Bytes zurück.
func TestChainOhneKeysEdgeOsbuiltin(t *testing.T) {
// TestChainWithoutKeysEdgeOsbuiltin (acceptance criterion): without any
// key and with a dead edge, the chain picks osbuiltin; with a faked espeak
// its bytes come back.
func TestChainWithoutKeysEdgeOsbuiltin(t *testing.T) {
dir := t.TempDir()
fakeProcBin(t, dir, "espeak", `printf 'RIFFxxxxWAVEfmt '`)
t.Setenv("PATH", dir)
@@ -151,20 +150,20 @@ func TestChainOhneKeysEdgeOsbuiltin(t *testing.T) {
if err != nil {
t.Fatalf("NewChain: %v", err)
}
data, err := ch.Synthesize(context.Background(), "Hallo", "de")
data, err := ch.Synthesize(context.Background(), "Hello", "de")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if !bytes.HasPrefix(data, []byte("RIFF")) {
t.Errorf("Synthesize = %q, want WAV-Bytes des fake espeak", data)
t.Errorf("Synthesize = %q, want WAV bytes of the fake espeak", data)
}
if !strings.Contains(log.String(), "übersprungen (nicht verfügbar)") {
t.Errorf("Log = %q, will Skip-Hinweise für die Key-Stufen", log.String())
if !strings.Contains(log.String(), "skipped (not available)") {
t.Errorf("log = %q, want skip notes for the key tiers", log.String())
}
}
// TestChainPin: --provider/config pinnen die Kette auf genau eine Stufe —
// andere Stufen werden nicht einmal angefragt.
// TestChainPin: --provider/config pin the chain to exactly one tier; other
// tiers are not even requested.
func TestChainPin(t *testing.T) {
eleven, elHits := countingTTSServer(t, 200, fakeMP3)
openai, oaHits := countingTTSServer(t, 500, []byte("boom"))
@@ -176,24 +175,24 @@ func TestChainPin(t *testing.T) {
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if _, err := ch.Synthesize(context.Background(), "Hallo", "de"); err == nil {
t.Fatal("Gepinnte kaputte Stufe soll fehlschlagen (kein Fallback bei Pin)")
if _, err := ch.Synthesize(context.Background(), "Hello", "de"); err == nil {
t.Fatal("pinned broken tier should fail (no fallback when pinned)")
}
if elHits.Load() != 0 || oaHits.Load() != 1 {
t.Errorf("Hits: elevenlabs = %d (want 0), openai = %d (want 1)", elHits.Load(), oaHits.Load())
t.Errorf("hits: elevenlabs = %d (want 0), openai = %d (want 1)", elHits.Load(), oaHits.Load())
}
}
// TestChainUnbekannterPin ist ein klarer Fehler mit Namensliste.
func TestChainUnbekannterPin(t *testing.T) {
if _, err := NewChain(ChainConfig{Pin: "gibtsnicht"}); err == nil || !strings.Contains(err.Error(), "elevenlabs") {
t.Errorf("NewChain-Fehler = %v, will verfügbare Namen nennen", err)
// TestChainUnknownPin is a clear error listing the names.
func TestChainUnknownPin(t *testing.T) {
if _, err := NewChain(ChainConfig{Pin: "doesnotexist"}); err == nil || !strings.Contains(err.Error(), "elevenlabs") {
t.Errorf("NewChain error = %v, want the available names", err)
}
}
// TestChainSpeakBytesUeberPlayer: erfolgreiche Synthese wird über den
// Player abgespielt.
func TestChainSpeakBytesUeberPlayer(t *testing.T) {
// TestChainSpeakBytesViaPlayer: a successful synthesis is played via the
// Player.
func TestChainSpeakBytesViaPlayer(t *testing.T) {
eleven, _ := countingTTSServer(t, 200, fakeMP3)
player := &fakePlayer{name: "oto"}
ch, err := NewChain(ChainConfig{
@@ -203,22 +202,22 @@ func TestChainSpeakBytesUeberPlayer(t *testing.T) {
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hallo", "de"); err != nil {
if err := ch.Speak(context.Background(), "Hello", "de"); err != nil {
t.Fatalf("Speak: %v", err)
}
if len(player.played) != 1 || string(player.played[0]) != string(fakeMP3) {
t.Errorf("Player.Play = %x, want die Synthese-Bytes", player.played)
t.Errorf("Player.Play = %x, want the synthesis bytes", player.played)
}
}
// TestChainDirektMitNullPlayer: fällt die Kette auf die Direkt-Stufe
// (osbuiltin ohne espeak), verwirft ein null-Player die Ansage tonlos und
// SUCCESS — spd-say wird nicht gerufen.
func TestChainDirektMitNullPlayer(t *testing.T) {
// TestChainDirectWithNullPlayer: when the chain falls back to the direct
// tier (osbuiltin without espeak), a null player discards the announcement
// silently and SUCCEEDS; spd-say is not called.
func TestChainDirectWithNullPlayer(t *testing.T) {
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
t.Setenv("PATH", dir) // kein espeak → keine Bytes, aber spd-say da
t.Setenv("PATH", dir) // no espeak → no bytes, but spd-say present
var log bytes.Buffer
ch, err := NewChain(ChainConfig{
@@ -230,20 +229,21 @@ func TestChainDirektMitNullPlayer(t *testing.T) {
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hallo Welt", "de"); err != nil {
t.Fatalf("Speak (null-Player): %v", err)
if err := ch.Speak(context.Background(), "Hello world", "de"); err != nil {
t.Fatalf("Speak (null player): %v", err)
}
if _, err := os.Stat(seen); err == nil {
t.Error("spd-say wurde gerufen — null-Player hätte verwerfen müssen")
t.Error("spd-say was called; the null player should have discarded")
}
if !strings.Contains(log.String(), "verworfen") {
t.Errorf("Log = %q, will Verwurf-Zeile", log.String())
if !strings.Contains(log.String(), "discarded") {
t.Errorf("log = %q, want discard line", log.String())
}
}
// TestChainDirektMitTonPlayer: mit echtem Player (Name ≠ null) läuft die
// Direktansage über SpeakDirect — blockierend (-w) mit Sprache und Text.
func TestChainDirektMitTonPlayer(t *testing.T) {
// TestChainDirectWithSoundPlayer: with a real player (name != null) the
// direct announcement runs via SpeakDirect, blocking (-w) with language and
// text.
func TestChainDirectWithSoundPlayer(t *testing.T) {
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
@@ -258,24 +258,24 @@ func TestChainDirektMitTonPlayer(t *testing.T) {
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hallo Welt", "de"); err != nil {
t.Fatalf("Speak (Direkt): %v", err)
if err := ch.Speak(context.Background(), "Hello world", "de"); err != nil {
t.Fatalf("Speak (direct): %v", err)
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("fake spd-say nicht gerufen: %v", err)
t.Fatalf("fake spd-say not called: %v", err)
}
if got := strings.TrimSpace(string(args)); got != "-w -l de Hallo Welt" {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt")
if got := strings.TrimSpace(string(args)); got != "-w -l de Hello world" {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hello world")
}
if len(player.played) != 0 {
t.Errorf("Player.Play wurde gerufen (%x) — Direktansage geht am Player vorbei", player.played)
t.Errorf("Player.Play was called (%x); a direct announcement bypasses the player", player.played)
}
}
// TestChainSynthesizeErrNoByteOutput: Die Kette als Provider meldet
// ErrNoByteOutput, wenn nur eine Direkt-Stufe übrig bleibt — der
// DirectSpeaker-Vertrag bleibt am Aufrufer erhalten.
// TestChainSynthesizeErrNoByteOutput: the chain as a Provider reports
// ErrNoByteOutput when only a direct tier remains; the DirectSpeaker
// contract is preserved towards the caller.
func TestChainSynthesizeErrNoByteOutput(t *testing.T) {
dir := t.TempDir()
fakeProcBin(t, dir, "spd-say", "true")
@@ -285,27 +285,27 @@ func TestChainSynthesizeErrNoByteOutput(t *testing.T) {
if err != nil {
t.Fatalf("NewChain: %v", err)
}
_, err = ch.Synthesize(context.Background(), "Hallo", "de")
_, err = ch.Synthesize(context.Background(), "Hello", "de")
if !errors.Is(err, ErrNoByteOutput) {
t.Fatalf("Synthesize-Fehler = %v, want ErrNoByteOutput", err)
t.Fatalf("Synthesize error = %v, want ErrNoByteOutput", err)
}
// Chain selbst ist DirectSpeaker.
// Chain itself is a DirectSpeaker.
if _, ok := any(ch).(DirectSpeaker); !ok {
t.Error("Chain implementiert DirectSpeaker nicht")
t.Error("Chain does not implement DirectSpeaker")
}
}
// TestChainAvailableUndNamen: Available true sobald eine Stufe verfügbar;
// Default-Reihenfolge ist die bindende aus der Spec.
func TestChainAvailableUndNamen(t *testing.T) {
// TestChainAvailableAndNames: Available is true as soon as one tier is
// available; the default order is the binding one from the spec.
func TestChainAvailableAndNames(t *testing.T) {
ch, err := NewChain(ChainConfig{Getenv: chainEnv(map[string]string{
EnvEdgeEndpoint: "wss://edge.example.com", // edge: immer verfügbar mit Endpunkt
EnvEdgeEndpoint: "wss://edge.example.com", // edge: always available with an endpoint
})})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if !ch.Available(context.Background()) {
t.Error("Available = false trotz verfügbarer edge-Stufe")
t.Error("Available = false despite an available edge tier")
}
want := []string{"elevenlabs", "openai", "edge", "osbuiltin"}
if len(ch.Tiers()) != len(want) {
@@ -318,9 +318,9 @@ func TestChainAvailableUndNamen(t *testing.T) {
}
}
// TestChainVoicePinDurchgereicht: ChainConfig.Voice wird via VoicePinner an
// die Stufen gepinnt — beobachtbar am Request-Pfad des ElevenLabs-Fakes.
func TestChainVoicePinDurchgereicht(t *testing.T) {
// TestChainVoicePinForwarded: ChainConfig.Voice is pinned on the tiers via
// VoicePinner; observable in the request path of the ElevenLabs fake.
func TestChainVoicePinForwarded(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
ch, err := NewChain(ChainConfig{
Pin: "elevenlabs",
@@ -333,24 +333,23 @@ func TestChainVoicePinDurchgereicht(t *testing.T) {
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if _, err := ch.Synthesize(context.Background(), "Hallo", "de"); err != nil {
if _, err := ch.Synthesize(context.Background(), "Hello", "de"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/ChainVoice"; rec.path != want {
t.Errorf("Pfad = %q, want %q", rec.path, want)
t.Errorf("path = %q, want %q", rec.path, want)
}
}
// TestChainDirektEndgameEinDurchlauf (Review-Fix): Im DirectSpeaker-
// Endgame läuft die Kette genau EINMAL — die fehlgeschlagene Cloud-Stufe
// wird nur einmal angefragt (keine doppelten POSTs auf bezahlten APIs,
// keine verdoppelte Latenz), jede Stufe protokolliert ihren Fehlschlag
// genau einmal, und die Direktansage kommt zustande.
func TestChainDirektEndgameEinDurchlauf(t *testing.T) {
// TestChainDirectEndgameSingleWalk (review fix): in the DirectSpeaker
// endgame the chain walks exactly ONCE; the failed cloud tier is requested
// only once (no duplicate POSTs to paid APIs, no doubled latency), every
// tier logs its failure exactly once, and the direct announcement happens.
func TestChainDirectEndgameSingleWalk(t *testing.T) {
eleven, elHits := countingTTSServer(t, 500, []byte("boom"))
// elevenlabs: Key + Fake (500). openai: Endpunkt, aber KEIN Key →
// übersprungen. edge: toter Loopback-Port. osbuiltin: PATH nur mit
// fake spd-say (kein espeak) → keine Bytes, Direktansage.
// elevenlabs: key + fake (500). openai: endpoint but NO key → skipped.
// edge: dead loopback port. osbuiltin: PATH with only a fake spd-say (no
// espeak) → no bytes, direct announcement.
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
@@ -361,7 +360,7 @@ func TestChainDirektEndgameEinDurchlauf(t *testing.T) {
Getenv: chainEnv(map[string]string{
EnvElevenLabsKey: "k",
EnvElevenLabsEndpoint: eleven,
EnvOpenAIEndpoint: eleven, // ohne Key ohnehin übersprungen
EnvOpenAIEndpoint: eleven, // skipped anyway without a key
EnvEdgeEndpoint: "ws://127.0.0.1:1/",
}),
Player: &fakePlayer{name: "oto"},
@@ -371,25 +370,25 @@ func TestChainDirektEndgameEinDurchlauf(t *testing.T) {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hallo Welt", "de"); err != nil {
t.Fatalf("Speak (Direkt-Endgame): %v", err)
if err := ch.Speak(context.Background(), "Hello world", "de"); err != nil {
t.Fatalf("Speak (direct endgame): %v", err)
}
if got := elHits.Load(); got != 1 {
t.Errorf("elevenlabs wurde %d× angefragt, want genau 1 (kein zweiter Durchlauf)", got)
t.Errorf("elevenlabs was requested %d times, want exactly 1 (no second walk)", got)
}
for _, tier := range []string{"elevenlabs", "edge"} {
if got := strings.Count(log.String(), "Stufe "+tier+" fehlgeschlagen"); got != 1 {
t.Errorf("„Stufe %s fehlgeschlagen“ steht %d× im Log, want genau 1:\n%s", tier, got, log.String())
if got := strings.Count(log.String(), "tier "+tier+" failed"); got != 1 {
t.Errorf("\"tier %s failed\" appears %d times in the log, want exactly 1:\n%s", tier, got, log.String())
}
}
if got := strings.Count(log.String(), "Stufe openai übersprungen"); got != 1 {
t.Errorf("openai-Skip steht %d× im Log, want genau 1", got)
if got := strings.Count(log.String(), "tier openai skipped"); got != 1 {
t.Errorf("openai skip appears %d times in the log, want exactly 1", got)
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("fake spd-say nicht gerufen: %v", err)
t.Fatalf("fake spd-say not called: %v", err)
}
if got := strings.TrimSpace(string(args)); got != "-w -l de Hallo Welt" {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt")
if got := strings.TrimSpace(string(args)); got != "-w -l de Hello world" {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hello world")
}
}
+10 -11
View File
@@ -1,12 +1,11 @@
// Package provider implementiert die TTS-Provider von talk und ihre
// Fallback-Kette. Der Vertrag (Name/Available/Synthesize) trägt die
// Vier-Stufen-Kette aus Task 03: ElevenLabs → OpenAI TTS → Edge-TTS →
// OS built-in (überschreibbar per --provider/config provider). Die
// Cloud-Provider liefern MP3 (Keys ausschließlich über TALK_ELEVENLABS_KEY
// bzw. TALK_OPENAI_KEY, Endpunkte konfigurierbar und damit fake-bar);
// osbuiltin liefert RIFF/WAVE-PCM. Die dokumentierte optionale Erweiterung
// DirectSpeaker deckt Plattformen ab, deren OS-TTS keine Audio-Bytes
// liefern kann (Linux speech-dispatcher) — Chain.Speak behandelt diesen
// Fall inklusive tonlosem Verwerfen am null-Sink, damit CLI (Task 03) und
// Daemon (Task 04) dieselbe Kette unverändert nutzen.
// Package provider implements talk's TTS providers and their fallback
// chain. The contract (Name/Available/Synthesize) carries the four-tier
// chain: ElevenLabs → OpenAI TTS → Edge TTS → OS built-in (overridable via
// --provider/config provider). The cloud providers return MP3 (keys come
// exclusively from TALK_ELEVENLABS_KEY and TALK_OPENAI_KEY, endpoints are
// configurable and therefore fakeable); osbuiltin returns RIFF/WAVE PCM.
// The documented optional extension DirectSpeaker covers platforms whose
// OS TTS cannot provide audio bytes (Linux speech-dispatcher); Chain.Speak
// handles that case, including silent discarding on the null sink, so that
// the CLI and the daemon use the same chain unchanged.
package provider
+77 -78
View File
@@ -16,20 +16,19 @@ import (
"github.com/gorilla/websocket"
)
// Edge-TTS-Konstanten. Das Protokoll ist das bekannte, von edge-tts
// reverse-engineerte „Read Aloud"-Protokoll von Microsoft Edge — Stand der
// Implementierung (2026): DRM-Token Sec-MS-GEC v2 (CS64 + verschleierte
// SHA-256-Variante, siehe edgedrm.go) + MUID-Cookie, im Übrigen das
// bekannte WebSocket-Nachrichtenformat (speech.config + SSML, Binär-Rahmen
// mit 2-Byte-Header-Länge, turn.end). Ändert Microsoft es, liefern die
// Synthese-Pfade klare Fehler mit Hinweis auf Protokoll-Änderung statt
// stiller Fehlfunktion.
// Edge TTS constants. The protocol is the well-known "Read Aloud" protocol
// of Microsoft Edge, reverse-engineered by edge-tts. State of the
// implementation (2026): DRM token Sec-MS-GEC v2 (CS64 + obfuscated SHA-256
// variant, see edgedrm.go) + MUID cookie; otherwise the known WebSocket
// message format (speech.config + SSML, binary frames with a 2-byte header
// length, turn.end). If Microsoft changes it, the synthesis paths return
// clear errors hinting at a protocol change instead of failing silently.
const (
// DefaultEndpointEdge ist der produktive WebSocket-Endpunkt (Pfad ohne
// Query — Token/ConnectionId/Sec-MS-GEC werden in requestURL ergänzt).
// DefaultEndpointEdge is the production WebSocket endpoint (path without
// query; token/ConnectionId/Sec-MS-GEC are added in requestURL).
DefaultEndpointEdge = "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1"
// EnvEdgeEndpoint überschreibt den Endpunkt (Tests/Firmenserver).
// EnvEdgeEndpoint overrides the endpoint (tests/corporate servers).
EnvEdgeEndpoint = "TALK_EDGE_ENDPOINT"
edgeTrustedClientToken = "6A5AA1D4EAFF4E9FB37E23D68491D6F4"
@@ -39,36 +38,36 @@ const (
edgeOutputFormat = "audio-24khz-48kbitrate-mono-mp3" // mp3!
)
// edgeIOTimeout begrenzt einzelne Lese-/Schreiboperationen sowie (wenn der
// Aufrufer keine Deadline mitgibt) die gesamte Synthese.
// edgeIOTimeout bounds individual read/write operations and (if the caller
// provides no deadline) the whole synthesis.
const edgeIOTimeout = 20 * time.Second
// edgeClockSkew korrigiert lokale Uhrenabweichung: Bei 403 liest die Kette
// das Date-Header-Feld der Antwort und justiert die Tick-Berechnung (ein
// Wiederholungsversuch, wie die edge-tts-Referenz).
// edgeClockSkew corrects local clock drift: on 403 the provider reads the
// Date header of the response and adjusts the tick calculation (one retry,
// like the edge-tts reference).
var edgeClockSkew atomic.Int64
// Edge ist die kostenlose dritte Stufe der Kette: Microsofts „Read Aloud"-
// Endpoint, kein Key nötig. Synthesize öffnet eine WebSocket-Verbindung,
// sendet speech.config + SSML-Anfrage und sammelt die Binär-Audio-Frames
// bis turn.end. Rückgabeformat: MP3 (24 kHz mono — der audio-Sink
// dekodiert das Format).
// Edge is the free third tier of the chain: Microsoft's "Read Aloud"
// endpoint, no key needed. Synthesize opens a WebSocket connection, sends
// speech.config + the SSML request and collects the binary audio frames
// until turn.end. Return format: MP3 (24 kHz mono; the audio sink decodes
// the format).
type Edge struct {
// Endpoint ist die WebSocket-URL (ws:// oder wss://), leer = Default.
// Endpoint is the WebSocket URL (ws:// or wss://), empty = default.
Endpoint string
// Voices ist das config-Mapping voice.edge.<lang> = id; leer = neutrale
// Repo-Defaults (de-DE-KatjaNeural / en-US-AriaNeural).
// Voices is the config mapping voice.edge.<lang> = id; empty = neutral
// repo defaults (de-DE-KatjaNeural / en-US-AriaNeural).
Voices map[string]string
// Dialer ist der WebSocket-Dialer; nil = websocket.DefaultDialer.
// Dialer is the WebSocket dialer; nil = websocket.DefaultDialer.
Dialer *websocket.Dialer
pinned string // --voice-Pin (VoicePinner)
pinned string // --voice pin (VoicePinner)
}
// NewEdge baut den Provider aus der Ketten-Konfiguration: Endpunkt aus
// TALK_EDGE_ENDPOINT > [providers.edge].endpoint > Default.
// NewEdge builds the provider from the chain configuration: endpoint from
// TALK_EDGE_ENDPOINT > [providers.edge].endpoint > default.
func NewEdge(cfg ChainConfig) *Edge {
return &Edge{
Endpoint: endpointFrom(cfg, "edge", EnvEdgeEndpoint, DefaultEndpointEdge),
@@ -76,25 +75,25 @@ func NewEdge(cfg ChainConfig) *Edge {
}
}
// Name implementiert Provider.
// Name implements Provider.
func (*Edge) Name() string { return "edge" }
// PinVoice implementiert VoicePinner (CLI-Flag --voice).
// PinVoice implements VoicePinner (CLI flag --voice).
func (p *Edge) PinVoice(id string) { p.pinned = id }
// Available implementiert Provider: Edge braucht nur einen Endpunkt —
// bewusst ohne Netz-Call (Erreichbarkeit stellt Synthesize fest, an deren
// Fehler fällt die Kette weiter).
// Available implements Provider: Edge only needs an endpoint, deliberately
// without a network call (reachability is determined by Synthesize, on
// whose error the chain falls back).
func (p *Edge) Available(ctx context.Context) bool { return p.Endpoint != "" }
// Synthesize implementiert Provider. Rückgabeformat: MP3.
// Synthesize implements Provider. Return format: MP3.
func (p *Edge) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("edge: leerer Text")
return nil, fmt.Errorf("edge: empty text")
}
voice := resolveVoice("edge", lang, p.pinned, p.Voices)
if voice == "" {
return nil, fmt.Errorf("edge: keine Stimme für Sprache %q konfiguriert", lang)
return nil, fmt.Errorf("edge: no voice configured for language %q", lang)
}
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
var cancel context.CancelFunc
@@ -135,7 +134,7 @@ func (p *Edge) Synthesize(ctx context.Context, text, lang string) ([]byte, error
case websocket.TextMessage:
if strings.Contains(string(msg), "Path:turn.end") {
if audio.Len() == 0 {
return nil, fmt.Errorf("edge: Server meldete turn.end ohne Audio-Frames — Protokoll geändert? (%s)", redactURL(wsURL))
return nil, fmt.Errorf("edge: server sent turn.end without audio frames; protocol changed? (%s)", redactURL(wsURL))
}
return audio.Bytes(), nil
}
@@ -148,8 +147,8 @@ func (p *Edge) Synthesize(ctx context.Context, text, lang string) ([]byte, error
}
}
// dialWithSkewRetry baut die Verbindung; bei 403 (Uhrabweichung) wird der
// Server-Zeitstempel übernommen und EINMAL wiederholt.
// dialWithSkewRetry establishes the connection; on 403 (clock drift) the
// server timestamp is adopted and the dial is retried ONCE.
func (p *Edge) dialWithSkewRetry(ctx context.Context) (*websocket.Conn, string, error) {
conn, wsURL, resp, err := p.dial(ctx)
if err == nil {
@@ -167,16 +166,16 @@ func (p *Edge) dialWithSkewRetry(ctx context.Context) (*websocket.Conn, string,
}
}
if resp != nil {
return nil, wsURL, fmt.Errorf("edge: WebSocket-Verbindung zu %s fehlgeschlagen (HTTP %d; Netz/Timeout oder Protokoll geändert?): %w",
return nil, wsURL, fmt.Errorf("edge: WebSocket connection to %s failed (HTTP %d; network/timeout, or the Edge TTS protocol changed?): %w",
redactURL(wsURL), resp.StatusCode, err)
}
return nil, wsURL, fmt.Errorf("edge: WebSocket-Verbindung zu %s fehlgeschlagen — Netz/Timeout oder das Edge-TTS-Protokoll hat sich geändert: %w",
return nil, wsURL, fmt.Errorf("edge: WebSocket connection to %s failed; network/timeout, or the Edge TTS protocol changed: %w",
redactURL(wsURL), err)
}
// dial öffnet die Verbindung gemäß Protokoll: Query mit Token,
// ConnectionId, Sec-MS-GEC (v2) und Version; Header mit Origin,
// Edge-User-Agent und MUID-Cookie.
// dial opens the connection according to the protocol: query with token,
// ConnectionId, Sec-MS-GEC (v2) and version; headers with Origin, Edge
// user agent and MUID cookie.
func (p *Edge) dial(ctx context.Context) (*websocket.Conn, string, *http.Response, error) {
wsURL, err := p.requestURL(time.Now())
if err != nil {
@@ -200,18 +199,18 @@ func (p *Edge) dial(ctx context.Context) (*websocket.Conn, string, *http.Respons
return conn, wsURL, nil, nil
}
// requestURL ergänzt die Protokoll-Query-Parameter und validiert das
// Schema (ws/wss).
// requestURL adds the protocol query parameters and validates the scheme
// (ws/wss).
func (p *Edge) requestURL(now time.Time) (string, error) {
if p.Endpoint == "" {
return "", fmt.Errorf("edge: kein Endpunkt konfiguriert")
return "", fmt.Errorf("edge: no endpoint configured")
}
u, err := url.Parse(p.Endpoint)
if err != nil {
return "", fmt.Errorf("edge: Endpunkt %q unlesbar: %w", p.Endpoint, err)
return "", fmt.Errorf("edge: endpoint %q unreadable: %w", p.Endpoint, err)
}
if u.Scheme != "ws" && u.Scheme != "wss" {
return "", fmt.Errorf("edge: Endpunkt %q muss ws:// oder wss:// sein (ist %q)", p.Endpoint, u.Scheme)
return "", fmt.Errorf("edge: endpoint %q must be ws:// or wss:// (is %q)", p.Endpoint, u.Scheme)
}
q := u.Query()
q.Set("TrustedClientToken", edgeTrustedClientToken)
@@ -222,12 +221,12 @@ func (p *Edge) requestURL(now time.Time) (string, error) {
return u.String(), nil
}
// edgeSecMSGEC erzeugt den DRM-Token Sec-MS-GEC v2: Windows-Filetime-Ticks
// (100 ns, 5-Minuten-Raster, Uhrenkorrektur) + Trusted-Client-Token →
// „<CS64>-<SHA256-Variante>" (beides großes Hex, siehe edgedrm.go).
// edgeSecMSGEC produces the DRM token Sec-MS-GEC v2: Windows filetime ticks
// (100 ns, 5-minute grid, clock correction) + trusted client token →
// "<CS64>-<SHA256 variant>" (both upper-case hex, see edgedrm.go).
func edgeSecMSGEC(now time.Time) string {
secs := now.Unix() + edgeClockSkew.Load() + drmWinEpoch
secs -= secs % 300 // 5-Minuten-Raster
secs -= secs % 300 // 5-minute grid
ticks := uint64(secs) * 10_000_000
strToHash := fmt.Appendf(nil, "%d%s", ticks, edgeTrustedClientToken)
digest := drmSHA256Digest(strToHash)
@@ -253,16 +252,16 @@ func edgeSecMSGEC(now time.Time) string {
return strings.ToUpper(hex.EncodeToString(cs64Buf[:])) + "-" + strings.ToUpper(hex.EncodeToString(shaBuf[:]))
}
// protocolErr überführt Lese-/Schreib-/Verbindungsabbrüche in klare
// „Protokoll geändert?"-Fehler — Edge ist ein undokumentierter Endpoint,
// Änderungen sollen sofort diagnostizierbar sein.
// protocolErr turns read/write/connection aborts into clear "protocol
// changed?" errors; Edge is an undocumented endpoint and changes should be
// diagnosable immediately.
func protocolErr(err error) error {
return fmt.Errorf("edge: Kommunikation fehlgeschlagen — Netz/Timeout oder das Edge-TTS-Protokoll hat sich geändert: %w", err)
return fmt.Errorf("edge: communication failed; network/timeout, or the Edge TTS protocol changed: %w", err)
}
// splitEdgeBinary zerlegt eine binäre Server-Nachricht: Die ersten 2 Bytes
// (big-endian) geben die Länge des Header-Blocks an (CRLF-Zeilen wie
// „Path:audio"), danach kommt die Audio-Payload.
// splitEdgeBinary splits a binary server message: the first 2 bytes
// (big-endian) give the length of the header block (CRLF lines such as
// "Path:audio"), followed by the audio payload.
func splitEdgeBinary(msg []byte) (header string, payload []byte, ok bool) {
if len(msg) < 2 {
return "", nil, false
@@ -274,9 +273,9 @@ func splitEdgeBinary(msg []byte) (header string, payload []byte, ok bool) {
return string(msg[2 : 2+headerLen]), msg[2+headerLen:], true
}
// edgeSpeechConfigMsg ist die erste Client-Nachricht: Header-Block +
// JSON-Body mit Ausgabeformat und Metadaten-Optionen (Satzgrenzen an,
// Wortgrenzen aus — Referenz-Default).
// edgeSpeechConfigMsg is the first client message: header block + JSON body
// with output format and metadata options (sentence boundaries on, word
// boundaries off; reference default).
func edgeSpeechConfigMsg(now time.Time) string {
return fmt.Sprintf(
"X-Timestamp:%s\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n"+
@@ -284,17 +283,17 @@ func edgeSpeechConfigMsg(now time.Time) string {
edgeTimestamp(now), edgeOutputFormat)
}
// edgeSynthesisHeaders ist der Header-Block der SSML-Anfrage. Der
// nachgestellte Z beim Timestamp ist Referenz-Verhalten („Microsoft Edge
// bug"), bewusst mitgeschleppt.
// edgeSynthesisHeaders is the header block of the SSML request. The
// trailing Z on the timestamp is reference behaviour ("Microsoft Edge
// bug"), deliberately carried along.
func edgeSynthesisHeaders(now time.Time, requestID string) string {
return fmt.Sprintf(
"X-RequestId:%s\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:%sZ\r\nPath:ssml\r\n\r\n",
requestID, edgeTimestamp(now))
}
// edgeSSML baut die Sprach-Anfrage (xml:lang wie die Referenz fest
// „en-US"; der Text wird von Steuerzeichen befreit und XML-escapt).
// edgeSSML builds the speech request (xml:lang fixed to "en-US" like the
// reference; the text is stripped of control characters and XML-escaped).
func edgeSSML(voice, text string) string {
return fmt.Sprintf(
"<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>"+
@@ -304,8 +303,8 @@ func edgeSSML(voice, text string) string {
voice, edgeEscape(text))
}
// edgeEscape entfernt nicht unterstützte Steuerzeichen (Referenz:
// remove_incompatible_characters) und escapt danach & < >.
// edgeEscape removes unsupported control characters (reference:
// remove_incompatible_characters) and then escapes & < >.
func edgeEscape(s string) string {
clean := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
@@ -331,27 +330,27 @@ func edgeEscape(s string) string {
return string(esc)
}
// edgeTimestamp formatiert den Zeitstempel im edge-tts-Format.
// edgeTimestamp formats the timestamp in the edge-tts format.
func edgeTimestamp(now time.Time) string {
return now.Format("Mon Jan 02 2006 15:04:05 GMT+0000 (Coordinated Universal Time)")
}
// edgeRequestID erzeugt eine 32-stellige Hex-ID (UUID v4 ohne Striche).
// edgeRequestID produces a 32-character hex ID (UUID v4 without dashes).
func edgeRequestID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// Fallback: zeitbasiert — die ID muss nur eindeutig pro Anfrage sein.
// Fallback: time based; the ID only has to be unique per request.
now := time.Now().UnixNano()
for i := 0; i < 16; i++ {
b[i] = byte(now >> (uint(i%8) * 8))
}
}
b[6] = (b[6] & 0x0f) | 0x40 // UUID-Version
b[8] = (b[8] & 0x3f) | 0x80 // Variante
b[6] = (b[6] & 0x0f) | 0x40 // UUID version
b[8] = (b[8] & 0x3f) | 0x80 // variant
return hex.EncodeToString(b[:])
}
// edgeMUID erzeugt die zufällige MUID für das Cookie (32 Hex, groß).
// edgeMUID produces the random MUID for the cookie (32 hex, upper case).
func edgeMUID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
@@ -360,8 +359,8 @@ func edgeMUID() string {
return strings.ToUpper(hex.EncodeToString(b[:]))
}
// redactURL maskiert die Query-Parameter (enthalten DRM-Token und IDs) in
// Fehlermeldungen — die URL selbst genügt zur Diagnose.
// redactURL masks the query parameters (they contain the DRM token and
// IDs) in error messages; the URL itself is enough for diagnosis.
func redactURL(raw string) string {
u, err := url.Parse(raw)
if err != nil {
+83 -84
View File
@@ -14,72 +14,72 @@ import (
"github.com/gorilla/websocket"
)
// TestEdgeAvailable: Edge braucht nur einen Endpunkt, keinen Key — der
// Konstruktor bäkt den Default-Endpunkt ein, daher ist ein über NewEdge
// gebauter Edge immer verfügbar; nur der Nullwert (direkt gebaut, ohne
// Endpunkt) ist es nicht.
// TestEdgeAvailable: Edge needs only an endpoint, no key. The constructor
// bakes in the default endpoint, so an Edge built via NewEdge is always
// available; only the zero value (built directly, without an endpoint) is
// not.
func TestEdgeAvailable(t *testing.T) {
if (&Edge{}).Available(context.Background()) {
t.Error("Available ohne Endpunkt (Nullwert) = true, want false")
t.Error("Available without endpoint (zero value) = true, want false")
}
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "wss://edge.example.com",
})})
if !p.Available(context.Background()) {
t.Error("Available mit Endpunkt = false, want true")
t.Error("Available with endpoint = false, want true")
}
if !NewEdge(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) {
t.Error("Available mit Default-Endpunkt = false, want true (edge ist der Key-freie Fallback)")
t.Error("Available with default endpoint = false, want true (edge is the key-free fallback)")
}
}
// TestEdgeVerbindungFehlschlaegt (Fehlerpfad-Mock): ein garantiert toter
// Loopback-Port liefert einen klaren Fehler — sofort, ohne Netz.
func TestEdgeVerbindungFehlschlaegt(t *testing.T) {
// TestEdgeConnectionFails (error-path mock): a guaranteed dead loopback
// port yields a clear error, immediately and without network.
func TestEdgeConnectionFails(t *testing.T) {
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "ws://127.0.0.1:1/",
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
_, err := p.Synthesize(context.Background(), "Hello", "de")
if err == nil {
t.Fatal("Synthesize gegen toten Port: kein Fehler")
t.Fatal("Synthesize against dead port: no error")
}
if !strings.Contains(err.Error(), "edge") || !strings.Contains(err.Error(), "WebSocket") {
t.Errorf("Fehler = %v, will klaren edge/WebSocket-Fehler", err)
t.Errorf("error = %v, want a clear edge/WebSocket error", err)
}
}
// TestEdgeUngueltigesSchema: ein Nicht-w(s)-Endpunkt ist ein klarer Fehler.
func TestEdgeUngueltigesSchema(t *testing.T) {
// TestEdgeInvalidScheme: a non-ws(s) endpoint is a clear error.
func TestEdgeInvalidScheme(t *testing.T) {
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "http://127.0.0.1:1234/",
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
_, err := p.Synthesize(context.Background(), "Hello", "de")
if err == nil || !strings.Contains(err.Error(), "ws://") {
t.Errorf("Fehler = %v, will Schema-Hinweis ws://", err)
t.Errorf("error = %v, want scheme hint ws://", err)
}
}
// TestEdgeSSML: die SSML-Anfrage enthält Stimme und den von Steuerzeichen
// befreiten, XML-escapten Text; xml:lang ist wie in der Referenz fest
// „en-US".
// TestEdgeSSML: the SSML request contains the voice and the text stripped
// of control characters and XML-escaped; xml:lang is fixed to "en-US" as in
// the reference.
func TestEdgeSSML(t *testing.T) {
got := edgeSSML("de-DE-KatjaNeural", "AT&T <Kunde> „fertig“")
got := edgeSSML("de-DE-KatjaNeural", "AT&T <customer> „done“")
for _, want := range []string{
"voice name='de-DE-KatjaNeural'",
"xml:lang='en-US'",
"AT&amp;T &lt;Kunde&gt;",
"AT&amp;T &lt;customer&gt;",
} {
if !strings.Contains(got, want) {
t.Errorf("SSML = %q, will %q", got, want)
t.Errorf("SSML = %q, want %q", got, want)
}
}
if strings.Contains(got, "AT&T") {
t.Errorf("SSML enthält unescaptes &: %q", got)
t.Errorf("SSML contains unescaped &: %q", got)
}
}
// TestEdgeEscape: Steuerzeichen (u. a. Vertical Tab aus OCR-Texten) werden
// durch Leerzeichen ersetzt, & < > escapt.
// TestEdgeEscape: control characters (e.g. vertical tab from OCR texts) are
// replaced by spaces, & < > are escaped.
func TestEdgeEscape(t *testing.T) {
in := "a\x0bb&c<d>\x08e"
if want := "a b&amp;c&lt;d&gt; e"; edgeEscape(in) != want {
@@ -87,19 +87,19 @@ func TestEdgeEscape(t *testing.T) {
}
}
// TestEdgeSpeechConfigMsg: Output-Format (mono-mp3) und Pfad stimmen.
// TestEdgeSpeechConfigMsg: output format (mono mp3) and path are correct.
func TestEdgeSpeechConfigMsg(t *testing.T) {
msg := edgeSpeechConfigMsg(time.Unix(0, 0).UTC())
if !strings.Contains(msg, "Path:speech.config") {
t.Errorf("speech.config-Nachricht ohne Path: %q", msg)
t.Errorf("speech.config message without Path: %q", msg)
}
if !strings.Contains(msg, edgeOutputFormat) {
t.Errorf("speech.config-Nachricht ohne Ausgabeformat %q: %q", edgeOutputFormat, msg)
t.Errorf("speech.config message without output format %q: %q", edgeOutputFormat, msg)
}
}
// TestEdgeRequestURL: Query-Parameter (Token, Sec-MS-GEC, Version) werden
// ergänzt; der Token landet nicht in der URL-Debug-Ausgabe.
// TestEdgeRequestURL: query parameters (token, Sec-MS-GEC, version) are
// added; the token does not end up in the redacted URL output.
func TestEdgeRequestURL(t *testing.T) {
p := &Edge{Endpoint: "wss://edge.example.com/path"}
raw, err := p.requestURL(time.Unix(1700000000, 0).UTC())
@@ -112,20 +112,20 @@ func TestEdgeRequestURL(t *testing.T) {
"Sec-MS-GEC-Version=" + edgeSecMSGECVersion,
} {
if !strings.Contains(raw, want) {
t.Errorf("requestURL = %q, will %q", raw, want)
t.Errorf("requestURL = %q, want %q", raw, want)
}
}
if got := redactURL(raw); strings.Contains(got, edgeTrustedClientToken) {
t.Errorf("redactURL leakt den Token: %q", got)
t.Errorf("redactURL leaks the token: %q", got)
}
}
// TestEdgeSecMSGEC: Format „<16 Hex>-<64 Hex>" (groß), 5-Minuten-Raster
// (innerhalb des Fensters stabil, über die Grenze anders) und exakte
// Referenzvektoren der edge-tts-DRM-Implementierung (v2).
// TestEdgeSecMSGEC: format "<16 hex>-<64 hex>" (upper case), 5-minute grid
// (stable within the window, different across the boundary) and exact
// reference vectors of the edge-tts DRM implementation (v2).
func TestEdgeSecMSGEC(t *testing.T) {
// Referenzvektoren: (Unix-Sekunden, erwarteter Token) — erzeugt mit der
// Python-Referenz (edge-tts src/edge_tts/drm.py, Stand 2026-10).
// Reference vectors: (unix seconds, expected token), generated with the
// Python reference (edge-tts src/edge_tts/drm.py, as of 2026-10).
vectors := []struct {
ts int64
want string
@@ -141,36 +141,36 @@ func TestEdgeSecMSGEC(t *testing.T) {
}
}
// Fenster-Stabilität (Basis am Fensterbeginn, mod 300 == 0).
// Window stability (base at window start, mod 300 == 0).
base := time.Unix(1700000400, 0).UTC()
token := edgeSecMSGEC(base)
if edgeSecMSGEC(base.Add(4*time.Minute)) != token {
t.Error("Sec-MS-GEC weicht innerhalb des 5-Minuten-Fensters ab")
t.Error("Sec-MS-GEC differs within the 5-minute window")
}
if edgeSecMSGEC(base.Add(6*time.Minute)) == token {
t.Error("Sec-MS-GEC ändert sich über die Fenster-Grenze nicht")
t.Error("Sec-MS-GEC does not change across the window boundary")
}
if len(token) != 16+1+64 || !strings.Contains(token, "-") {
t.Errorf("Token-Format = %q, want <16 Hex>-<64 Hex>", token)
t.Errorf("token format = %q, want <16 hex>-<64 hex>", token)
}
}
// TestEdgeRequestID: 32 Hex-Zeichen, eindeutig.
// TestEdgeRequestID: 32 hex characters, unique.
func TestEdgeRequestID(t *testing.T) {
a, b := edgeRequestID(), edgeRequestID()
if len(a) != 32 {
t.Errorf("RequestID-Länge = %d, want 32", len(a))
t.Errorf("request ID length = %d, want 32", len(a))
}
if _, err := hex.DecodeString(a); err != nil {
t.Errorf("RequestID ist kein Hex: %v", err)
t.Errorf("request ID is not hex: %v", err)
}
if a == b {
t.Error("RequestID nicht eindeutig")
t.Error("request ID not unique")
}
}
// TestEdgeSplitBinary: Binär-Rahmen: 2 Bytes big-endian Header-Länge, dann
// Header (Path:audio), dann Payload.
// TestEdgeSplitBinary: binary frame: 2 bytes big-endian header length, then
// header (Path:audio), then payload.
func TestEdgeSplitBinary(t *testing.T) {
msg := []byte{0, 12, 'P', 'a', 't', 'h', ':', 'a', 'u', 'd', 'i', 'o', '\r', '\n', 1, 2, 3}
header, payload, ok := splitEdgeBinary(msg)
@@ -178,15 +178,15 @@ func TestEdgeSplitBinary(t *testing.T) {
t.Errorf("splitEdgeBinary = %q, %x, %v", header, payload, ok)
}
if _, _, ok := splitEdgeBinary([]byte{0, 99, 'x'}); ok {
t.Error("übergroßer Header-Längenangabe soll abgelehnt werden")
t.Error("oversized header length should be rejected")
}
}
// fakeEdgeServer ist ein Loopback-Fake des Edge-WSS-Protokolls (kein
// echter Netz-Call — alles bleibt auf 127.0.0.1). Er prüft die beiden
// Client-Nachrichten und antwortet nach Protokoll: Audio-Binär-Rahmen mit
// Path:audio, danach turn.end. audio ist die zu liefernde Payload;
// closing schließt die Verbindung vor turn.end (Fehlerpfad).
// fakeEdgeServer is a loopback fake of the Edge WSS protocol (no real
// network call; everything stays on 127.0.0.1). It checks the two client
// messages and answers per protocol: a binary audio frame with Path:audio,
// then turn.end. audio is the payload to deliver; closing closes the
// connection before turn.end (error path).
func fakeEdgeServer(t *testing.T, audio []byte, closing bool) (*httptest.Server, *fakeEdgeSeen) {
t.Helper()
seen := &fakeEdgeSeen{}
@@ -208,21 +208,21 @@ func fakeEdgeServer(t *testing.T, audio []byte, closing bool) (*httptest.Server,
return
}
if _, cfg, err := conn.ReadMessage(); err != nil {
t.Errorf("speech.config lesen: %v", err)
t.Errorf("reading speech.config: %v", err)
} else {
seen.mu.Lock()
seen.config = string(cfg)
seen.mu.Unlock()
}
if _, req, err := conn.ReadMessage(); err != nil {
t.Errorf("ssml lesen: %v", err)
t.Errorf("reading ssml: %v", err)
} else {
seen.mu.Lock()
seen.ssml = string(req)
seen.mu.Unlock()
}
if closing {
return // Verbindung dichtmachen, ohne zu antworten
return // close the connection without answering
}
header := "X-RequestId:dummy\r\nPath:audio\r\n"
frame := make([]byte, 0, 2+len(header)+len(audio))
@@ -252,10 +252,9 @@ func (s *fakeEdgeSeen) snapshot() (configMsg, ssml string) {
return s.config, s.ssml
}
// TestEdgeLoopbackFakeServer: kompletter Protokoll-Durchlauf gegen den
// Loopback-Fake (fake-bar getestet: Handshake-Parameter, beide
// Client-Nachrichten, Binär-Rahmen-Parsing, turn.end-Ende) — siehe
// Task-03-Report für die Abgrenzung zum echten Endpoint.
// TestEdgeLoopbackFakeServer: complete protocol run against the loopback
// fake (tested fakeably: handshake parameters, both client messages,
// binary frame parsing, turn.end termination).
func TestEdgeLoopbackFakeServer(t *testing.T) {
wantAudio := []byte("\xff\xfb\x90\x00" + "edge-audio")
ts, seen := fakeEdgeServer(t, wantAudio, false)
@@ -263,9 +262,9 @@ func TestEdgeLoopbackFakeServer(t *testing.T) {
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "ws://" + ts.Listener.Addr().String(),
})})
data, err := p.Synthesize(context.Background(), "Hallo & fertig", "de")
data, err := p.Synthesize(context.Background(), "Hello & done", "de")
if err != nil {
t.Fatalf("Synthesize gegen Loopback-Fake: %v", err)
t.Fatalf("Synthesize against loopback fake: %v", err)
}
if string(data) != string(wantAudio) {
t.Errorf("Synthesize = %x, want %x", data, wantAudio)
@@ -275,47 +274,47 @@ func TestEdgeLoopbackFakeServer(t *testing.T) {
t.Errorf("speech.config = %q", configMsg)
}
if !strings.Contains(ssml, "Path:ssml") {
t.Errorf("ssml-Anfrage ohne Path:ssml: %q", ssml)
t.Errorf("ssml request without Path:ssml: %q", ssml)
}
if !strings.Contains(ssml, "voice name='de-DE-KatjaNeural'") {
t.Errorf("ssml ohne Default-Stimme de: %q", ssml)
t.Errorf("ssml without default voice de: %q", ssml)
}
if !strings.Contains(ssml, "Hallo &amp; fertig") {
t.Errorf("ssml ohne escapten Text: %q", ssml)
if !strings.Contains(ssml, "Hello &amp; done") {
t.Errorf("ssml without escaped text: %q", ssml)
}
}
// TestEdgeLoopbackVerbindungsAbbruch: Server schließt nach den Anfragen
// ohne Audio → klarer „Protokoll geändert?"-Fehler.
func TestEdgeLoopbackVerbindungsAbbruch(t *testing.T) {
// TestEdgeLoopbackConnectionAbort: the server closes after the requests
// without audio → clear "protocol changed?" error.
func TestEdgeLoopbackConnectionAbort(t *testing.T) {
ts, _ := fakeEdgeServer(t, nil, true)
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "ws://" + ts.Listener.Addr().String(),
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
if err == nil || !strings.Contains(err.Error(), "Protokoll") {
t.Errorf("Fehler = %v, will Protokoll-Änderungs-Hinweis", err)
_, err := p.Synthesize(context.Background(), "Hello", "de")
if err == nil || !strings.Contains(err.Error(), "protocol") {
t.Errorf("error = %v, want protocol-change hint", err)
}
}
// TestEdgeIntegrationEcht ist die als testing.Short überspringbare
// Integrations-Markierung gegen den ECHTEN Microsoft-Endpunkt (kostenlos,
// aber echtes Netz — Standard-Testläufe und -short überspringen; einmalige
// manuelle Abnahme mit TALK_TEST_REAL_EDGE=1).
func TestEdgeIntegrationEcht(t *testing.T) {
// TestEdgeIntegrationReal is the integration marker against the REAL
// Microsoft endpoint, skippable via testing.Short (free, but real network;
// standard test runs and -short skip it; one-off manual acceptance with
// TALK_TEST_REAL_EDGE=1).
func TestEdgeIntegrationReal(t *testing.T) {
if testing.Short() {
t.Skip("Übersprungen (-short)")
t.Skip("skipped (-short)")
}
if os.Getenv("TALK_TEST_REAL_EDGE") != "1" {
t.Skip("Übersprungen (echtes Netz) — mit TALK_TEST_REAL_EDGE=1 ausführen")
t.Skip("skipped (real network); run with TALK_TEST_REAL_EDGE=1")
}
p := NewEdge(ChainConfig{})
data, err := p.Synthesize(context.Background(), "Kurzer Sprachtest über Edge.", "de")
data, err := p.Synthesize(context.Background(), "Short speech test via Edge.", "de")
if err != nil {
t.Fatalf("Synthesize gegen echten Endpunkt: %v", err)
t.Fatalf("Synthesize against real endpoint: %v", err)
}
if len(data) == 0 {
t.Fatal("Synthesize gegen echten Endpunkt: 0 Bytes")
t.Fatal("Synthesize against real endpoint: 0 bytes")
}
t.Logf("Edge-Integration: %d Bytes Audio", len(data))
t.Logf("Edge integration: %d bytes of audio", len(data))
}
+46 -46
View File
@@ -4,35 +4,35 @@ import (
"encoding/binary"
)
// Edge-DRM: Sec-MS-GEC v2. Microsoft verlangt seit der Protokoll-Änderung
// (edge-tts-Referenz, Stand 2026) einen Token der Form
// „<16 Hex CS64>-<64 Hex SHA256-Variante>" über den 5-Minuten-gerundeten
// Windows-Filetime-Ticks + Trusted-Client-Token. Beide Digests sind
// absichtlich verschleierte Varianten bekannter Algorithmen — die
// Konstanten und der Kontrollfluss unten sind eine wörtliche Portierung
// der Referenz (edge-tts src/edge_tts/drm.py, MIT) und gegen deren
// Ausgaben tabellengeprüft (TestEdgeDRMReferenzvektoren). Ändert Microsoft
// das Schema erneut, liefert der Edge-Provider klare Fehler („Protokoll
// geändert?"), statt still zu versagen.
// Edge DRM: Sec-MS-GEC v2. Since the protocol change (edge-tts reference,
// as of 2026) Microsoft requires a token of the form
// "<16 hex CS64>-<64 hex SHA256 variant>" over the 5-minute-rounded
// Windows filetime ticks + trusted client token. Both digests are
// deliberately obfuscated variants of well-known algorithms; the constants
// and the control flow below are a literal port of the reference (edge-tts
// src/edge_tts/drm.py, MIT) and are table-checked against its outputs
// (TestEdgeSecMSGEC). If Microsoft changes the scheme again, the Edge
// provider returns clear errors ("protocol changed?") instead of failing
// silently.
const (
drmWinEpoch = 11644473600 // Sekunden zwischen 1601-01-01 und 1970-01-01
drmWinEpoch = 11644473600 // seconds between 1601-01-01 and 1970-01-01
)
// drmIV ist der Standard-SHA-256-Initialvektor.
// drmIV is the standard SHA-256 initial vector.
var drmIV = [8]uint32{
0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A,
0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19,
}
// drmMidState ist der Arbeitsregister-Stand der Referenz für den
// Mid-Block-Einstieg (Runde 14 des ersten Blocks).
// drmMidState is the reference's working-register state for the mid-block
// entry (round 14 of the first block).
var drmMidState = [8]uint32{
0xFD9FBEA2, 0x91243CE7, 0xBE499094, 0xCD2E1E0A,
0x6C006F49, 0x04BD35AB, 0xFEB0A7E4, 0x03EFFBD2,
}
// drmSchedule sind vorberechnete Schedule-Anteile des ersten Blocks.
// drmSchedule holds precomputed schedule parts of the first block.
var drmSchedule = [14]uint32{
0xEB24BF9E, 0x1558691F, 0xA0C09EE2, 0x0CFA32BA,
0xA622EE8C, 0xD1DD9491, 0xC0DD25A0, 0xF9E70AB6,
@@ -40,7 +40,7 @@ var drmSchedule = [14]uint32{
0xD258B060, 0x72766564,
}
// drmAdjustment ersetzt die Rundungskonstante K[14] im Mid-Block-Pfad.
// drmAdjustment replaces the round constant K[14] on the mid-block path.
const (
drmAdjustment uint32 = 0xC9DC06A7
drmSigma0Adj uint32 = 0x059C0B80
@@ -50,7 +50,7 @@ const (
drmCS64Adj1 uint32 = 0x0B1A0000
)
// drmK sind die Standard-SHA-256-Rundungskonstanten.
// drmK are the standard SHA-256 round constants.
var drmK = [64]uint32{
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
@@ -62,7 +62,7 @@ var drmK = [64]uint32{
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
}
// drmCS64Coefficients: (Term-, Offset-Koeffizient) je Zeile.
// drmCS64Coefficients: (term coefficient, offset coefficient) per row.
var drmCS64Coefficients = [14][2]uint32{
{0xE82E91B1, 0xB32700D0}, {0x44DE24C6, 0x88DFF51D},
{0x3BEB65BC, 0x29514038}, {0xC89C5933, 0xB328CBDF},
@@ -73,23 +73,23 @@ var drmCS64Coefficients = [14][2]uint32{
{0x35FA38C6, 0xBB04A2C1}, {0xCB49E3C6, 0xC5626167},
}
// drmSigma0 ist die (verschleiert notierte) SHA-256-σ0-Mischfunktion.
// drmSigma0 is the (obfuscated-notation) SHA-256 σ0 mixing function.
func drmSigma0(v uint32) uint32 {
return v>>7 ^ v<<25 ^ v>>18 ^ v<<14 ^ v>>3
}
// drmSigma1 ist die SHA-256-σ1-Mischfunktion.
// drmSigma1 is the SHA-256 σ1 mixing function.
func drmSigma1(v uint32) uint32 {
return v>>17 ^ v<<15 ^ v>>19 ^ v<<13 ^ v>>10
}
// drmRol16 rotiert 32 Bit um 16.
// drmRol16 rotates 32 bits by 16.
func drmRol16(v uint32) uint32 { return v<<16 | v>>16 }
// drmRotR rotiert 32 Bit rechts um n.
// drmRotR rotates 32 bits right by n.
func drmRotR(v uint32, n uint) uint32 { return v>>n | v<<(32-n) }
// drmExpand erweitert die Message-Schedule ab from auf 64 Wörter.
// drmExpand extends the message schedule from index from to 64 words.
func drmExpand(words []uint32, from int) []uint32 {
for i := from; i < 64; i++ {
x, y := words[i-2], words[i-15]
@@ -98,9 +98,9 @@ func drmExpand(words []uint32, from int) []uint32 {
return words
}
// drmRounds führt die SHA-256-Runden aus — wörtlich nach Referenz,
// inklusive des Mid-Block-Einstiegs (firstRound 14 mit ersetzter
// Rundungskonstante) und der 8er-Rollen-Rotation der Register.
// drmRounds executes the SHA-256 rounds, literally following the
// reference, including the mid-block entry (firstRound 14 with the replaced
// round constant) and the 8-way role rotation of the registers.
func drmRounds(state [8]uint32, words []uint32, firstRound int) [8]uint32 {
a, b, c, d, e, f, g, h := state[0], state[1], state[2], state[3], state[4], state[5], state[6], state[7]
midBlock := firstRound == 14
@@ -120,35 +120,35 @@ func drmRounds(state [8]uint32, words []uint32, firstRound int) [8]uint32 {
a, b, c, d, e, f, g, h = t1+t2, a, b, c, d+t1, e, f, g
}
for i := aligned; i < 64; i += 8 {
// Runde i
// round i
t1 := h + (drmRotR(e, 6) ^ drmRotR(e, 11) ^ drmRotR(e, 25)) + (g ^ (e & (f ^ g))) + k(i) + words[i]
d += t1
h = t1 + (drmRotR(a, 2) ^ drmRotR(a, 13) ^ drmRotR(a, 22)) + ((a & b) | (c & (a | b)))
// Runde i+1 (Rollen rotiert)
// round i+1 (roles rotated)
t1 = g + (drmRotR(d, 6) ^ drmRotR(d, 11) ^ drmRotR(d, 25)) + (f ^ (d & (e ^ f))) + k(i+1) + words[i+1]
c += t1
g = t1 + (drmRotR(h, 2) ^ drmRotR(h, 13) ^ drmRotR(h, 22)) + ((h & a) | (b & (h | a)))
// Runde i+2
// round i+2
t1 = f + (drmRotR(c, 6) ^ drmRotR(c, 11) ^ drmRotR(c, 25)) + (e ^ (c & (d ^ e))) + k(i+2) + words[i+2]
b += t1
f = t1 + (drmRotR(g, 2) ^ drmRotR(g, 13) ^ drmRotR(g, 22)) + ((g & h) | (a & (g | h)))
// Runde i+3
// round i+3
t1 = e + (drmRotR(b, 6) ^ drmRotR(b, 11) ^ drmRotR(b, 25)) + (d ^ (b & (c ^ d))) + k(i+3) + words[i+3]
a += t1
e = t1 + (drmRotR(f, 2) ^ drmRotR(f, 13) ^ drmRotR(f, 22)) + ((f & g) | (h & (f | g)))
// Runde i+4
// round i+4
t1 = d + (drmRotR(a, 6) ^ drmRotR(a, 11) ^ drmRotR(a, 25)) + (c ^ (a & (b ^ c))) + k(i+4) + words[i+4]
h += t1
d = t1 + (drmRotR(e, 2) ^ drmRotR(e, 13) ^ drmRotR(e, 22)) + ((e & f) | (g & (e | f)))
// Runde i+5
// round i+5
t1 = c + (drmRotR(h, 6) ^ drmRotR(h, 11) ^ drmRotR(h, 25)) + (b ^ (h & (a ^ b))) + k(i+5) + words[i+5]
g += t1
c = t1 + (drmRotR(d, 2) ^ drmRotR(d, 13) ^ drmRotR(d, 22)) + ((d & e) | (f & (d | e)))
// Runde i+6
// round i+6
t1 = b + (drmRotR(g, 6) ^ drmRotR(g, 11) ^ drmRotR(g, 25)) + (a ^ (g & (h ^ a))) + k(i+6) + words[i+6]
f += t1
b = t1 + (drmRotR(c, 2) ^ drmRotR(c, 13) ^ drmRotR(c, 22)) + ((c & d) | (e & (c | d)))
// Runde i+7
// round i+7
t1 = a + (drmRotR(f, 6) ^ drmRotR(f, 11) ^ drmRotR(f, 25)) + (h ^ (f & (g ^ h))) + k(i+7) + words[i+7]
e += t1
a = t1 + (drmRotR(b, 2) ^ drmRotR(b, 13) ^ drmRotR(b, 22)) + ((b & c) | (d & (b | c)))
@@ -156,16 +156,16 @@ func drmRounds(state [8]uint32, words []uint32, firstRound int) [8]uint32 {
return [8]uint32{a, b, c, d, e, f, g, h}
}
// drmSHA256Init berechnet den Zustand nach dem (verschleiert notierten)
// ersten Block: 14 Nullwörter + die ersten 7 Bytes von data als
// 24-Bit-/32-Bit-Big-Endian-Wörter, Schedule mit Korrekturen, Runden ab 14.
// drmSHA256Init computes the state after the (obfuscated-notation) first
// block: 14 zero words + the first 7 bytes of data as 24-bit/32-bit
// big-endian words, schedule with corrections, rounds from 14 onwards.
func drmSHA256Init(data []byte) [8]uint32 {
words := make([]uint32, 30, 64)
words[14] = uint32(data[0])<<16 | uint32(data[1])<<8 | uint32(data[2])
words[15] = binary.BigEndian.Uint32(data[3:7])
for i := 16; i < 30; i++ {
v := drmSchedule[i-16]
// σ1-Anteil von W[i-2] (Index 16 liest W[14], dazu die Korrektur).
// σ1 part of W[i-2] (index 16 reads W[14], plus the correction).
s1 := drmSigma1(words[i-2])
if i == 16 {
s1 ^= drmSigma1Adj
@@ -174,7 +174,7 @@ func drmSHA256Init(data []byte) [8]uint32 {
if i-7 >= 14 {
v += words[i-7]
}
if i-15 >= 14 { // nur i == 29, liest W[14], dazu die Korrektur
if i-15 >= 14 { // only i == 29, reads W[14], plus the correction
s0 := drmSigma0(words[i-15])
if i == 29 {
s0 ^= drmSigma0Adj
@@ -193,9 +193,9 @@ func drmSHA256Init(data []byte) [8]uint32 {
return out
}
// drmSHA256Digest ist die verschleierte SHA-256-Variante der Referenz:
// Block 1 steckt in drmSHA256Init (erste 7 Bytes), danach data[7:] mit
// Referenz-Längenfeld (57+len(data)) und Standard-Runden.
// drmSHA256Digest is the reference's obfuscated SHA-256 variant: block 1
// lives in drmSHA256Init (first 7 bytes), followed by data[7:] with the
// reference length field (57+len(data)) and standard rounds.
func drmSHA256Digest(data []byte) [8]uint32 {
length := 57 + len(data)
padded := append([]byte(nil), data[7:]...)
@@ -224,8 +224,8 @@ func drmSHA256Digest(data []byte) [8]uint32 {
return state
}
// drmWordsLE4Even zerlegt data in little-endian u32-Wörter (auf 4 Bytes
// null-aufgefüllt, Wortzahl gerade).
// drmWordsLE4Even splits data into little-endian u32 words (zero-padded to
// 4 bytes, even word count).
func drmWordsLE4Even(data []byte) []uint32 {
padded := make([]byte, len(data))
copy(padded, data)
@@ -242,8 +242,8 @@ func drmWordsLE4Even(data []byte) []uint32 {
return words
}
// drmCS64 berechnet die beiden CS64-Prüfsummen-Wörter (wörtliche
// Portierung; Schlüssel aus den ersten 8 Digest-Bytes little-endian).
// drmCS64 computes the two CS64 checksum words (literal port; keys from
// the first 8 digest bytes, little-endian).
func drmCS64(data []byte, digest [8]uint32) (a, b uint32) {
var digestBytes [32]byte
for i, w := range digest {
+33 -33
View File
@@ -8,51 +8,50 @@ import (
"strings"
)
// ElevenLabs-Konstanten (öffentlicher Vertrag: Env-Namen sind Teil der
// Fleet-Doku, Endpunkt-Default Teil der Konfigurierbarkeit).
// ElevenLabs constants (public contract: the env names are part of the
// fleet documentation, the endpoint default part of the configurability).
const (
// DefaultEndpointElevenLabs ist der produktive API-Endpunkt.
// DefaultEndpointElevenLabs is the production API endpoint.
DefaultEndpointElevenLabs = "https://api.elevenlabs.io"
// EnvElevenLabsKey liefert den API-Key (Spec: Keys ausschließlich Env,
// nie config.toml).
// EnvElevenLabsKey provides the API key (spec: keys come exclusively
// from the environment, never from config.toml).
EnvElevenLabsKey = "TALK_ELEVENLABS_KEY"
// EnvElevenLabsEndpoint überschreibt den Endpunkt (Tests/Firmenserver;
// schlägt die config.toml, siehe voices.go/chain.go-Doku).
// EnvElevenLabsEndpoint overrides the endpoint (tests/corporate
// servers; beats config.toml, see the voices.go/chain.go docs).
EnvElevenLabsEndpoint = "TALK_ELEVENLABS_ENDPOINT"
elevenLabsModel = "eleven_multilingual_v2" // multilingual v2, Task-Brief
elevenLabsModel = "eleven_multilingual_v2" // multilingual v2
)
// ElevenLabs ist die Premium-Stufe der Kette. Synthesize führt POST
// <endpoint>/v1/text-to-speech/<voiceID> aus — Header `xi-api-key`, Body
// ElevenLabs is the premium tier of the chain. Synthesize performs POST
// <endpoint>/v1/text-to-speech/<voiceID> with header `xi-api-key` and body
// {text, model_id: "eleven_multilingual_v2", voice_settings:
// {stability: 0.5, similarity_boost: 0.75}} — und liefert die Antwort-Bytes
// unverändert zurück. Rückgabeformat: MP3 (dokumentierter Vertrag des
// audio-Pakets).
// {stability: 0.5, similarity_boost: 0.75}} and returns the response bytes
// unchanged. Return format: MP3 (documented contract of the audio package).
type ElevenLabs struct {
// Endpoint ist die Basis-URL (ohne Pfad); leer wird beim Bau durch den
// Default ersetzt.
// Endpoint is the base URL (without path); empty is replaced by the
// default at construction.
Endpoint string
// APIKey ist der ElevenLabs-Key (aus TALK_ELEVENLABS_KEY).
// APIKey is the ElevenLabs key (from TALK_ELEVENLABS_KEY).
APIKey string
// Voices ist das config-Mapping voice.elevenlabs.<lang> = id; leer =
// neutrale Repo-Defaults (siehe voices.go).
// Voices is the config mapping voice.elevenlabs.<lang> = id; empty =
// neutral repo defaults (see voices.go).
Voices map[string]string
// Client ist der HTTP-Client; nil = geteilter Client mit Timeout
// Client is the HTTP client; nil = shared client with timeout
// (cloudTimeout).
Client *http.Client
pinned string // --voice-Pin (VoicePinner)
pinned string // --voice pin (VoicePinner)
}
// NewElevenLabs baut den Provider aus der Ketten-Konfiguration: Endpunkt
// aus TALK_ELEVENLABS_ENDPOINT > [providers.elevenlabs].endpoint > Default;
// Key ausschließlich aus TALK_ELEVENLABS_KEY.
// NewElevenLabs builds the provider from the chain configuration: endpoint
// from TALK_ELEVENLABS_ENDPOINT > [providers.elevenlabs].endpoint > default;
// key exclusively from TALK_ELEVENLABS_KEY.
func NewElevenLabs(cfg ChainConfig) *ElevenLabs {
return &ElevenLabs{
Endpoint: endpointFrom(cfg, "elevenlabs", EnvElevenLabsEndpoint, DefaultEndpointElevenLabs),
@@ -61,20 +60,21 @@ func NewElevenLabs(cfg ChainConfig) *ElevenLabs {
}
}
// Name implementiert Provider.
// Name implements Provider.
func (*ElevenLabs) Name() string { return "elevenlabs" }
// PinVoice implementiert VoicePinner (CLI-Flag --voice).
// PinVoice implements VoicePinner (CLI flag --voice).
func (p *ElevenLabs) PinVoice(id string) { p.pinned = id }
// Available implementiert Provider: Key und Endpunkt müssen gesetzt sein
// (bewusst ohne Netz-Call — Available muss billig bleiben; ob der Key
// gültig ist, stellt Synthesize fest und die Kette fällt weiter).
// Available implements Provider: key and endpoint must be set (deliberately
// without a network call; Available must stay cheap. Whether the key is
// valid is determined by Synthesize, and the chain falls back).
func (p *ElevenLabs) Available(ctx context.Context) bool {
return p.APIKey != "" && p.Endpoint != ""
}
// elevenLabsRequest ist der Antwort-Body (Task-Brief, wörtliche Werte).
// elevenLabsRequest is the request body (literal values from the API
// contract).
type elevenLabsRequest struct {
Text string `json:"text"`
ModelID string `json:"model_id"`
@@ -86,14 +86,14 @@ type elevenLabsVoiceSettings struct {
SimilarityBoost float64 `json:"similarity_boost"`
}
// Synthesize implementiert Provider. Rückgabeformat: MP3.
// Synthesize implements Provider. Return format: MP3.
func (p *ElevenLabs) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("elevenlabs: leerer Text")
return nil, fmt.Errorf("elevenlabs: empty text")
}
voice := resolveVoice("elevenlabs", lang, p.pinned, p.Voices)
if voice == "" {
return nil, fmt.Errorf("elevenlabs: keine Stimme für Sprache %q konfiguriert", lang)
return nil, fmt.Errorf("elevenlabs: no voice configured for language %q", lang)
}
body, err := json.Marshal(elevenLabsRequest{
Text: text,
@@ -101,7 +101,7 @@ func (p *ElevenLabs) Synthesize(ctx context.Context, text, lang string) ([]byte,
VoiceSettings: elevenLabsVoiceSettings{Stability: 0.5, SimilarityBoost: 0.75},
})
if err != nil {
return nil, fmt.Errorf("elevenlabs: Anfrage-Body bauen: %w", err)
return nil, fmt.Errorf("elevenlabs: building request body: %w", err)
}
url := strings.TrimRight(p.Endpoint, "/") + "/v1/text-to-speech/" + voice
return postForBytes(ctx, p.Client, "elevenlabs", url,
+47 -47
View File
@@ -11,7 +11,7 @@ import (
"testing"
)
// fakeTTServation zeichnet einen Request am Fake-Server auf.
// recordedRequest records a request seen by the fake server.
type recordedRequest struct {
method string
path string
@@ -19,8 +19,8 @@ type recordedRequest struct {
body []byte
}
// fakeTTSServer ist der httptest-Fake eines Cloud-Providers: antwortet mit
// status + body und zeichnet den (einzigen) gesehenen Request auf.
// fakeTTSServer is the httptest fake of a cloud provider: responds with
// status + body and records the (single) request it saw.
func fakeTTSServer(t *testing.T, status int, body []byte) (*httptest.Server, *recordedRequest) {
t.Helper()
rec := &recordedRequest{}
@@ -28,7 +28,7 @@ func fakeTTSServer(t *testing.T, status int, body []byte) (*httptest.Server, *re
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body2, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("Fake-Server: Body lesen: %v", err)
t.Errorf("fake server: reading body: %v", err)
}
mu.Lock()
rec.method, rec.path, rec.header, rec.body = r.Method, r.URL.Path, r.Header.Clone(), body2
@@ -44,14 +44,14 @@ func envMap(kv map[string]string) func(string) string {
return func(k string) string { return kv[k] }
}
// fakeMP3 ist eine beliebige Byte-Folge — die Provider geben Antwort-Bytes
// unverändert zurück, der audio-Sink dekodiert (eigene Tests).
var fakeMP3 = []byte("\xff\xfb\x90\x00" + "audio-bytes-von-elf")
// fakeMP3 is an arbitrary byte sequence; the providers return response
// bytes unchanged, the audio sink decodes (its own tests).
var fakeMP3 = []byte("\xff\xfb\x90\x00" + "audio-bytes-from-eleven")
// TestElevenLabsSynthese200: 200 mit Bytes → Bytes kommen unverändert
// zurück; Methode, Pfad (inkl. Voice-ID), Header (xi-api-key) und der
// JSON-Body (model_id, voice_settings) entsprechen dem Task-Brief.
func TestElevenLabsSynthese200(t *testing.T) {
// TestElevenLabsSynthesize200: 200 with bytes → bytes come back unchanged;
// method, path (incl. voice ID), header (xi-api-key) and the JSON body
// (model_id, voice_settings) match the API contract.
func TestElevenLabsSynthesize200(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewElevenLabs(ChainConfig{
Getenv: envMap(map[string]string{
@@ -60,18 +60,18 @@ func TestElevenLabsSynthese200(t *testing.T) {
}),
})
data, err := p.Synthesize(context.Background(), "Hallo Welt", "de")
data, err := p.Synthesize(context.Background(), "Hello world", "de")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if string(data) != string(fakeMP3) {
t.Errorf("Synthesize = %q, want Fake-Bytes %q", data, fakeMP3)
t.Errorf("Synthesize = %q, want fake bytes %q", data, fakeMP3)
}
if rec.method != http.MethodPost {
t.Errorf("Methode = %s, want POST", rec.method)
t.Errorf("method = %s, want POST", rec.method)
}
if want := "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM"; rec.path != want {
t.Errorf("Pfad = %q, want %q (neutrales Repo-Default „Rachel“)", rec.path, want)
t.Errorf("path = %q, want %q (neutral repo default \"Rachel\")", rec.path, want)
}
if got := rec.header.Get("xi-api-key"); got != "testkey" {
t.Errorf("xi-api-key = %q, want %q", got, "testkey")
@@ -86,9 +86,9 @@ func TestElevenLabsSynthese200(t *testing.T) {
} `json:"voice_settings"`
}
if err := json.Unmarshal(rec.body, &body); err != nil {
t.Fatalf("Antwort-Body ist kein JSON: %v (%s)", err, rec.body)
t.Fatalf("request body is not JSON: %v (%s)", err, rec.body)
}
if body.Text != "Hallo Welt" {
if body.Text != "Hello world" {
t.Errorf("text = %q", body.Text)
}
if body.ModelID != "eleven_multilingual_v2" {
@@ -99,9 +99,9 @@ func TestElevenLabsSynthese200(t *testing.T) {
}
}
// TestElevenLabsHTTPFehler: 500 und 401 sind klare Fehler, die Status und
// einen Body-Ausschnitt nennen (die Kette fällt daran weiter).
func TestElevenLabsHTTPFehler(t *testing.T) {
// TestElevenLabsHTTPError: 500 and 401 are clear errors naming the status
// and a body snippet (the chain falls back on them).
func TestElevenLabsHTTPError(t *testing.T) {
cases := []struct {
status int
body string
@@ -117,32 +117,32 @@ func TestElevenLabsHTTPFehler(t *testing.T) {
EnvElevenLabsKey: "testkey",
EnvElevenLabsEndpoint: ts.URL,
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
_, err := p.Synthesize(context.Background(), "Hello", "de")
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Synthesize-Fehler = %v, will HTTP %s", err, tc.want)
t.Fatalf("Synthesize error = %v, want HTTP %s", err, tc.want)
}
if !strings.Contains(err.Error(), "elevenlabs") {
t.Errorf("Fehler soll den Provider nennen: %v", err)
t.Errorf("error should name the provider: %v", err)
}
})
}
}
// TestElevenLabsAvailable: ohne Key nicht verfügbar, mit Key verfügbar —
// ohne jeden Netz-Call.
// TestElevenLabsAvailable: not available without a key, available with
// one; without any network call.
func TestElevenLabsAvailable(t *testing.T) {
if NewElevenLabs(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) {
t.Error("Available ohne Key = true, want false")
t.Error("Available without key = true, want false")
}
p := NewElevenLabs(ChainConfig{Getenv: envMap(map[string]string{EnvElevenLabsKey: "k"})})
if !p.Available(context.Background()) {
t.Error("Available mit Key = false, want true")
t.Error("Available with key = false, want true")
}
}
// TestElevenLabsVoiceRangfolge: --voice-Pin schlägt config-Mapping, Mapping
// schlägt Repo-Default (beobachtbar am Request-Pfad).
func TestElevenLabsVoiceRangfolge(t *testing.T) {
// TestElevenLabsVoicePrecedence: the --voice pin beats the config mapping,
// the mapping beats the repo default (observable in the request path).
func TestElevenLabsVoicePrecedence(t *testing.T) {
mk := func(t *testing.T, voices map[string]string, pin string) (*ElevenLabs, *recordedRequest) {
t.Helper()
ts, rec := fakeTTSServer(t, 200, fakeMP3)
@@ -159,46 +159,46 @@ func TestElevenLabsVoiceRangfolge(t *testing.T) {
return p, rec
}
t.Run("config-Mapping schlägt Default", func(t *testing.T) {
p, rec := mk(t, map[string]string{"de": "AusConfig"}, "")
if _, err := p.Synthesize(context.Background(), "Hallo", "de"); err != nil {
t.Run("config mapping beats default", func(t *testing.T) {
p, rec := mk(t, map[string]string{"de": "FromConfig"}, "")
if _, err := p.Synthesize(context.Background(), "Hello", "de"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/AusConfig"; rec.path != want {
t.Errorf("Pfad = %q, want %q", rec.path, want)
if want := "/v1/text-to-speech/FromConfig"; rec.path != want {
t.Errorf("path = %q, want %q", rec.path, want)
}
})
t.Run("Pin (--voice) schlägt config-Mapping", func(t *testing.T) {
p, rec := mk(t, map[string]string{"de": "AusConfig"}, "Gepinnt")
if _, err := p.Synthesize(context.Background(), "Hallo", "de"); err != nil {
t.Run("pin (--voice) beats config mapping", func(t *testing.T) {
p, rec := mk(t, map[string]string{"de": "FromConfig"}, "Pinned")
if _, err := p.Synthesize(context.Background(), "Hello", "de"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/Gepinnt"; rec.path != want {
t.Errorf("Pfad = %q, want %q", rec.path, want)
if want := "/v1/text-to-speech/Pinned"; rec.path != want {
t.Errorf("path = %q, want %q", rec.path, want)
}
})
t.Run("unbekannte Sprache fällt auf en-Default", func(t *testing.T) {
t.Run("unknown language falls back to en default", func(t *testing.T) {
p, rec := mk(t, nil, "")
if _, err := p.Synthesize(context.Background(), "Hallo", "fr"); err != nil {
if _, err := p.Synthesize(context.Background(), "Hello", "fr"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM"; rec.path != want {
t.Errorf("Pfad = %q, want en-Default %q", rec.path, want)
t.Errorf("path = %q, want en default %q", rec.path, want)
}
})
}
// TestElevenLabsLeererText ist ein klarer Fehler ohne Request.
func TestElevenLabsLeererText(t *testing.T) {
// TestElevenLabsEmptyText is a clear error without a request.
func TestElevenLabsEmptyText(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewElevenLabs(ChainConfig{Getenv: envMap(map[string]string{
EnvElevenLabsKey: "k",
EnvElevenLabsEndpoint: ts.URL,
})})
if _, err := p.Synthesize(context.Background(), "", "de"); err == nil {
t.Error("Synthesize(\"\") soll fehlschlagen")
t.Error("Synthesize(\"\") should fail")
}
if rec.method != "" {
t.Error("Bei leerem Text darf kein Request gehen")
t.Error("no request must be sent for empty text")
}
}
+19 -19
View File
@@ -9,30 +9,30 @@ import (
"time"
)
// cloudTimeout ist das Budget für einen einzigen Synthese-Call eines
// Cloud-Providers (Aufbau + Anfrage + vollständige Antwort). Ansagen sind
// kurz; nach Ablauf greift der Fallback auf die nächste Ketten-Stufe.
// cloudTimeout is the budget for a single synthesis call of a cloud
// provider (connect + request + complete response). Announcements are
// short; once it expires the chain falls back to the next tier.
const cloudTimeout = 45 * time.Second
// cloudClient ist der geteilte HTTP-Client der REST-Provider (mit Timeout,
// im Gegensatz zu http.DefaultClient). Bei Ausbleiben einer Antwort greift
// so der Ketten-Fallback statt einer Endlos-Blockade.
// cloudClient is the shared HTTP client of the REST providers (with a
// timeout, unlike http.DefaultClient). If a response never arrives, the
// chain falls back instead of blocking forever.
var cloudClient = &http.Client{Timeout: cloudTimeout}
// maxErrBody begrenzt, wie viel einer Fehler-Antwort in die Fehlermeldung
// übernommen wird (Diagnose ja, Spam nein).
// maxErrBody limits how much of an error response is copied into the error
// message (diagnosis yes, spam no).
const maxErrBody = 200
// postForBytes macht den gemeinsamen Kern der REST-Provider: POST von JSON
// an url mit Headern, erwartet 2xx und liefert die rohen Antwort-Bytes
// (Audio). Alles andere — non-2xx, Netz-Fehler, Timeouts, leere Antworten —
// ist ein klarer Fehler, an dem die Kette in die nächste Stufe fällt.
// postForBytes is the shared core of the REST providers: POST JSON to url
// with headers, expect 2xx and return the raw response bytes (audio).
// Everything else (non-2xx, network errors, timeouts, empty responses) is a
// clear error on which the chain falls back to the next tier.
func postForBytes(ctx context.Context, client *http.Client, providerName, url string, headers map[string]string, body []byte) ([]byte, error) {
if client == nil {
client = cloudClient
}
// Eigenes Zeit-Budget setzen, falls der Aufrufer keins mitgibt — die
// Kette soll auch ohne fremden Deadline-Kontext weiterfallen können.
// Apply our own time budget if the caller does not bring one; the chain
// must be able to fall through even without an external deadline.
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, cloudTimeout)
@@ -41,7 +41,7 @@ func postForBytes(ctx context.Context, client *http.Client, providerName, url st
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("%s: Anfrage an %s bauen: %w", providerName, url, err)
return nil, fmt.Errorf("%s: building request to %s: %w", providerName, url, err)
}
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
@@ -50,21 +50,21 @@ func postForBytes(ctx context.Context, client *http.Client, providerName, url st
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%s: Anfrage an %s fehlgeschlagen (Netz/Timeout?): %w", providerName, url, err)
return nil, fmt.Errorf("%s: request to %s failed (network/timeout?): %w", providerName, url, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrBody))
return nil, fmt.Errorf("%s: HTTP %d von %s: %s", providerName, resp.StatusCode, url, bytes.TrimSpace(snippet))
return nil, fmt.Errorf("%s: HTTP %d from %s: %s", providerName, resp.StatusCode, url, bytes.TrimSpace(snippet))
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%s: Antwort von %s lesen: %w", providerName, url, err)
return nil, fmt.Errorf("%s: reading response from %s: %w", providerName, url, err)
}
if len(data) == 0 {
return nil, fmt.Errorf("%s: leere Antwort von %s (0 Bytes Audio)", providerName, url)
return nil, fmt.Errorf("%s: empty response from %s (0 bytes of audio)", providerName, url)
}
return data, nil
}
+29 -28
View File
@@ -8,47 +8,48 @@ import (
"strings"
)
// OpenAI-TTS-Konstanten (öffentlicher Vertrag wie bei ElevenLabs).
// OpenAI TTS constants (public contract as with ElevenLabs).
const (
// DefaultEndpointOpenAI ist der produktive API-Endpunkt.
// DefaultEndpointOpenAI is the production API endpoint.
DefaultEndpointOpenAI = "https://api.openai.com"
// DefaultModelOpenAI: Entscheidungsnotiz aus dem Task-Brief — der
// Modell-String ist konfigurierbar ([providers.openai] model), Default
// "tts-1"; "gpt-4o-mini-tts" ist ein gültiger Alternativwert.
// DefaultModelOpenAI: decision note. The model string is configurable
// ([providers.openai] model), default "tts-1"; "gpt-4o-mini-tts" is a
// valid alternative.
DefaultModelOpenAI = "tts-1"
EnvOpenAIKey = "TALK_OPENAI_KEY"
EnvOpenAIEndpoint = "TALK_OPENAI_ENDPOINT"
)
// OpenAI ist die zweite Stufe der Kette (für Nutzer mit vorhandenem
// OpenAI-Key). Synthesize führt POST <endpoint>/v1/audio/speech aus —
// Bearer-Key, Body {model, voice, input, response_format: "mp3"} — und
// liefert die Antwort-Bytes unverändert zurück. Rückgabeformat: MP3.
// OpenAI is the second tier of the chain (for users with an existing OpenAI
// key). Synthesize performs POST <endpoint>/v1/audio/speech with a bearer
// key and body {model, voice, input, response_format: "mp3"} and returns
// the response bytes unchanged. Return format: MP3.
type OpenAI struct {
// Endpoint ist die Basis-URL (ohne Pfad); leer = Default beim Bau.
// Endpoint is the base URL (without path); empty = default at
// construction.
Endpoint string
// APIKey ist der OpenAI-Key (aus TALK_OPENAI_KEY).
// APIKey is the OpenAI key (from TALK_OPENAI_KEY).
APIKey string
// Model ist der TTS-Modell-String; leer = DefaultModelOpenAI.
// Model is the TTS model string; empty = DefaultModelOpenAI.
Model string
// Voices ist das config-Mapping voice.openai.<lang> = id; leer =
// neutrale Repo-Defaults (alloy, siehe voices.go).
// Voices is the config mapping voice.openai.<lang> = id; empty =
// neutral repo defaults (alloy, see voices.go).
Voices map[string]string
// Client ist der HTTP-Client; nil = geteilter Client mit Timeout.
// Client is the HTTP client; nil = shared client with timeout.
Client *http.Client
pinned string // --voice-Pin (VoicePinner)
pinned string // --voice pin (VoicePinner)
}
// NewOpenAI baut den Provider aus der Ketten-Konfiguration: Endpunkt aus
// TALK_OPENAI_ENDPOINT > [providers.openai].endpoint > Default; Modell aus
// [providers.openai].model; Key ausschließlich aus TALK_OPENAI_KEY.
// NewOpenAI builds the provider from the chain configuration: endpoint from
// TALK_OPENAI_ENDPOINT > [providers.openai].endpoint > default; model from
// [providers.openai].model; key exclusively from TALK_OPENAI_KEY.
func NewOpenAI(cfg ChainConfig) *OpenAI {
return &OpenAI{
Endpoint: endpointFrom(cfg, "openai", EnvOpenAIEndpoint, DefaultEndpointOpenAI),
@@ -58,19 +59,19 @@ func NewOpenAI(cfg ChainConfig) *OpenAI {
}
}
// Name implementiert Provider.
// Name implements Provider.
func (*OpenAI) Name() string { return "openai" }
// PinVoice implementiert VoicePinner (CLI-Flag --voice).
// PinVoice implements VoicePinner (CLI flag --voice).
func (p *OpenAI) PinVoice(id string) { p.pinned = id }
// Available implementiert Provider: Key und Endpunkt müssen gesetzt sein
// (ohne Netz-Call, siehe ElevenLabs.Available).
// Available implements Provider: key and endpoint must be set (without a
// network call, see ElevenLabs.Available).
func (p *OpenAI) Available(ctx context.Context) bool {
return p.APIKey != "" && p.Endpoint != ""
}
// openAIRequest ist der Anfrage-Body.
// openAIRequest is the request body.
type openAIRequest struct {
Model string `json:"model"`
Voice string `json:"voice"`
@@ -78,14 +79,14 @@ type openAIRequest struct {
ResponseFormat string `json:"response_format"`
}
// Synthesize implementiert Provider. Rückgabeformat: MP3.
// Synthesize implements Provider. Return format: MP3.
func (p *OpenAI) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("openai: leerer Text")
return nil, fmt.Errorf("openai: empty text")
}
voice := resolveVoice("openai", lang, p.pinned, p.Voices)
if voice == "" {
return nil, fmt.Errorf("openai: keine Stimme für Sprache %q konfiguriert", lang)
return nil, fmt.Errorf("openai: no voice configured for language %q", lang)
}
model := p.Model
if model == "" {
@@ -98,7 +99,7 @@ func (p *OpenAI) Synthesize(ctx context.Context, text, lang string) ([]byte, err
ResponseFormat: "mp3",
})
if err != nil {
return nil, fmt.Errorf("openai: Anfrage-Body bauen: %w", err)
return nil, fmt.Errorf("openai: building request body: %w", err)
}
url := strings.TrimRight(p.Endpoint, "/") + "/v1/audio/speech"
return postForBytes(ctx, p.Client, "openai", url,
+29 -29
View File
@@ -9,10 +9,10 @@ import (
"testing"
)
// TestOpenAISynthese200: 200 mit Bytes → Bytes unverändert; POST
// /v1/audio/speech, Bearer-Key, Body mit model/voice/input/response_format
// laut Task-Brief (Default-Modell tts-1, Default-Stimme alloy).
func TestOpenAISynthese200(t *testing.T) {
// TestOpenAISynthesize200: 200 with bytes → bytes unchanged; POST
// /v1/audio/speech, bearer key, body with model/voice/input/response_format
// per the API contract (default model tts-1, default voice alloy).
func TestOpenAISynthesize200(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewOpenAI(ChainConfig{
Getenv: envMap(map[string]string{
@@ -21,18 +21,18 @@ func TestOpenAISynthese200(t *testing.T) {
}),
})
data, err := p.Synthesize(context.Background(), "Build fertig", "en")
data, err := p.Synthesize(context.Background(), "Build finished", "en")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if string(data) != string(fakeMP3) {
t.Errorf("Synthesize = %q, want Fake-Bytes", data)
t.Errorf("Synthesize = %q, want fake bytes", data)
}
if rec.method != http.MethodPost {
t.Errorf("Methode = %s, want POST", rec.method)
t.Errorf("method = %s, want POST", rec.method)
}
if want := "/v1/audio/speech"; rec.path != want {
t.Errorf("Pfad = %q, want %q", rec.path, want)
t.Errorf("path = %q, want %q", rec.path, want)
}
if got := rec.header.Get("Authorization"); got != "Bearer sk-test" {
t.Errorf("Authorization = %q, want Bearer sk-test", got)
@@ -45,15 +45,15 @@ func TestOpenAISynthese200(t *testing.T) {
ResponseFormat string `json:"response_format"`
}
if err := json.Unmarshal(rec.body, &body); err != nil {
t.Fatalf("Body ist kein JSON: %v (%s)", err, rec.body)
t.Fatalf("body is not JSON: %v (%s)", err, rec.body)
}
if body.Model != "tts-1" {
t.Errorf("model = %q, want Default tts-1", body.Model)
t.Errorf("model = %q, want default tts-1", body.Model)
}
if body.Voice != "alloy" {
t.Errorf("voice = %q, want Default alloy", body.Voice)
t.Errorf("voice = %q, want default alloy", body.Voice)
}
if body.Input != "Build fertig" {
if body.Input != "Build finished" {
t.Errorf("input = %q", body.Input)
}
if body.ResponseFormat != "mp3" {
@@ -61,8 +61,8 @@ func TestOpenAISynthese200(t *testing.T) {
}
}
// TestOpenAIHTTPFehler: 500 und 401 sind klare Fehler mit Status.
func TestOpenAIHTTPFehler(t *testing.T) {
// TestOpenAIHTTPError: 500 and 401 are clear errors with the status.
func TestOpenAIHTTPError(t *testing.T) {
for _, status := range []int{500, 401} {
t.Run(http.StatusText(status), func(t *testing.T) {
ts, _ := fakeTTSServer(t, status, []byte("boom"))
@@ -70,21 +70,21 @@ func TestOpenAIHTTPFehler(t *testing.T) {
EnvOpenAIKey: "sk-test",
EnvOpenAIEndpoint: ts.URL,
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
_, err := p.Synthesize(context.Background(), "Hello", "de")
if err == nil || !strings.Contains(err.Error(), fmt.Sprintf("%d", status)) {
t.Fatalf("Synthesize-Fehler = %v, will HTTP %d", err, status)
t.Fatalf("Synthesize error = %v, want HTTP %d", err, status)
}
if !strings.Contains(err.Error(), "openai") {
t.Errorf("Fehler soll den Provider nennen: %v", err)
t.Errorf("error should name the provider: %v", err)
}
})
}
}
// TestOpenAIModelKonfigurierbar: [providers.openai].model ändert den
// Modell-String im Body (Entscheidungsnotiz: Default tts-1, Alternativen
// wie gpt-4o-mini-tts erlaubt).
func TestOpenAIModelKonfigurierbar(t *testing.T) {
// TestOpenAIModelConfigurable: [providers.openai].model changes the model
// string in the body (decision note: default tts-1, alternatives such as
// gpt-4o-mini-tts allowed).
func TestOpenAIModelConfigurable(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewOpenAI(ChainConfig{
Getenv: envMap(map[string]string{
@@ -93,7 +93,7 @@ func TestOpenAIModelKonfigurierbar(t *testing.T) {
}),
Model: "gpt-4o-mini-tts",
})
if _, err := p.Synthesize(context.Background(), "Hallo", "de"); err != nil {
if _, err := p.Synthesize(context.Background(), "Hello", "de"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
var body struct {
@@ -107,20 +107,20 @@ func TestOpenAIModelKonfigurierbar(t *testing.T) {
}
}
// TestOpenAIAvailable: Key entscheidet — ohne Netz-Call.
// TestOpenAIAvailable: the key decides; without a network call.
func TestOpenAIAvailable(t *testing.T) {
if NewOpenAI(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) {
t.Error("Available ohne Key = true, want false")
t.Error("Available without key = true, want false")
}
p := NewOpenAI(ChainConfig{Getenv: envMap(map[string]string{EnvOpenAIKey: "sk"})})
if !p.Available(context.Background()) {
t.Error("Available mit Key = false, want true")
t.Error("Available with key = false, want true")
}
}
// TestOpenAIVoiceRangfolge: Pin schlägt Mapping schlägt Default.
func TestOpenAIVoiceRangfolge(t *testing.T) {
t.Run("Mapping schlägt Default", func(t *testing.T) {
// TestOpenAIVoicePrecedence: pin beats mapping beats default.
func TestOpenAIVoicePrecedence(t *testing.T) {
t.Run("mapping beats default", func(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewOpenAI(ChainConfig{
Getenv: envMap(map[string]string{
@@ -140,7 +140,7 @@ func TestOpenAIVoiceRangfolge(t *testing.T) {
t.Errorf("voice = %q, want nova (config)", body.Voice)
}
})
t.Run("Pin schlägt Mapping", func(t *testing.T) {
t.Run("pin beats mapping", func(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewOpenAI(ChainConfig{
Getenv: envMap(map[string]string{
+69 -69
View File
@@ -13,35 +13,36 @@ import (
"unicode/utf16"
)
// OSBuiltin spricht über die im Betriebssystem eingebaute TTS-Runtime.
// Es ist der Notnagel-Provider der Kette: überall vorhanden, ohne Key und
// ohne Netz — aber die einfachste Stimme.
// OSBuiltin speaks through the TTS runtime built into the operating system.
// It is the chain's provider of last resort: present everywhere, without a
// key and without network, but with the plainest voice.
//
// Plattform-Mechanismen (Auswahl zur Laufzeit über runtime.GOOS, damit eine
// Binärversion alle Ziele kompiliert; jede Route ist reines exec ohne Shell):
// Platform mechanisms (selected at runtime via runtime.GOOS so that one
// binary compiles for all targets; every route is pure exec without a
// shell):
//
// Linux: Synthesize über espeak/espeak-ng (--stdout → WAV-Bytes).
// Fehlt beides, liefert Synthesize ErrNoByteOutput und
// SpeakDirect spielt über spd-say (speech-dispatcher) ab —
// spd-say kann Audio nach eigener --help-Ausgabe nur selbst über
// den Daemon ausgeben, nicht in eine Datei/in einen Prozess
// schreiben (verifiziert am Dev-Host 2026-10-18).
// macOS: Synthesize über say (-o temporäre WAV-Datei,
// --data-format=LEI16@22050); Stimmen de→Anna, en→Samantha
// (vorinstalliert; ersetzt durch das Voice-Mapping in Task 03).
// Windows: Synthesize über PowerShell System.Speech in temporäre WAV-
// Datei (SetOutputToWaveFile); Sprache via SelectVoiceByHints
// mit Fallback auf die Systemstimme.
// Linux: Synthesize via espeak/espeak-ng (--stdout → WAV bytes).
// If both are missing, Synthesize returns ErrNoByteOutput and
// SpeakDirect plays via spd-say (speech-dispatcher); according
// to its own --help output spd-say can only emit audio itself
// through the daemon, not write it to a file or a process
// (verified on the dev host 2026-10-18).
// macOS: Synthesize via say (-o temporary WAV file,
// --data-format=LEI16@22050); voices de→Anna, en→Samantha
// (preinstalled).
// Windows: Synthesize via PowerShell System.Speech into a temporary WAV
// file (SetOutputToWaveFile); language via SelectVoiceByHints
// with fallback to the system voice.
//
// Fehlende Binaries/Runtimes führen zu Available()==false bzw. klaren
// Fehlern — nie zu Panics.
// Missing binaries/runtimes lead to Available()==false or clear errors,
// never to panics.
type OSBuiltin struct{}
// Name implementiert Provider.
// Name implements Provider.
func (OSBuiltin) Name() string { return "osbuiltin" }
// Available implementiert Provider: true, wenn mindestens ein
// Plattform-Mechanismus vorhanden ist.
// Available implements Provider: true if at least one platform mechanism is
// present.
func (OSBuiltin) Available(ctx context.Context) bool {
switch runtime.GOOS {
case "darwin":
@@ -50,7 +51,7 @@ func (OSBuiltin) Available(ctx context.Context) bool {
case "windows":
_, err := exec.LookPath("powershell")
return err == nil
default: // linux und sonstige unixartige Systeme
default: // linux and other unix-like systems
for _, bin := range []string{"espeak", "espeak-ng", "spd-say"} {
if _, err := exec.LookPath(bin); err == nil {
return true
@@ -60,40 +61,40 @@ func (OSBuiltin) Available(ctx context.Context) bool {
}
}
// Describe nennt den gefundenen Plattform-Mechanismus für die Diagnose
// (`talk doctor`): z. B. „espeak-ng (Audio-Bytes)", „spd-say (nur
// Direktansage; espeak-ng für Audio-Bytes fehlt)", „say", „powershell
// (SAPI)" — oder, was fehlt.
// Describe names the platform mechanism found, for diagnosis
// (`talk doctor`): e.g. "espeak-ng (audio bytes)", "spd-say (direct speech
// only; install espeak-ng for audio bytes)", "say", "powershell (SAPI)",
// or what is missing.
func (OSBuiltin) Describe() string {
switch runtime.GOOS {
case "darwin":
if _, err := exec.LookPath("say"); err == nil {
return "say (macOS)"
}
return "say nicht gefunden"
return "say not found"
case "windows":
if _, err := exec.LookPath("powershell"); err == nil {
return "powershell (SAPI System.Speech)"
}
return "powershell nicht gefunden"
return "powershell not found"
default:
for _, bin := range []string{"espeak-ng", "espeak"} {
if _, err := exec.LookPath(bin); err == nil {
return bin + " (Audio-Bytes)"
return bin + " (audio bytes)"
}
}
if _, err := exec.LookPath("spd-say"); err == nil {
return "spd-say (nur Direktansage; espeak-ng für Audio-Bytes fehlt)"
return "spd-say (direct speech only; install espeak-ng for audio bytes)"
}
return "weder espeak-ng/espeak noch spd-say gefunden (espeak-ng installieren)"
return "neither espeak-ng/espeak nor spd-say found (install espeak-ng)"
}
}
// Synthesize implementiert Provider. Rückgabeformat: RIFF/WAVE (PCM) —
// abspielbar über jeden Sink des audio-Pakets.
// Synthesize implements Provider. Return format: RIFF/WAVE (PCM), playable
// through every sink of the audio package.
func (p OSBuiltin) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("osbuiltin: leerer Text")
return nil, fmt.Errorf("osbuiltin: empty text")
}
switch runtime.GOOS {
case "darwin":
@@ -105,31 +106,30 @@ func (p OSBuiltin) Synthesize(ctx context.Context, text, lang string) ([]byte, e
}
}
// SpeakDirect implementiert DirectSpeaker: direkte, blockierende Ausgabe
// über das OS-TTS mit eigener Wiedergabe. Notwendig auf Linux ohne espeak;
// auf macOS/Windows nicht erforderlich (Synthesize liefert WAV) und ist
// dort ein klarer Fehler.
// SpeakDirect implements DirectSpeaker: direct, blocking output through the
// OS TTS with its own playback. Required on Linux without espeak; not
// needed on macOS/Windows (Synthesize returns WAV) and a clear error there.
func (p OSBuiltin) SpeakDirect(ctx context.Context, text, lang string) error {
if text == "" {
return fmt.Errorf("osbuiltin: leerer Text")
return fmt.Errorf("osbuiltin: empty text")
}
switch runtime.GOOS {
case "darwin", "windows":
return fmt.Errorf("osbuiltin: SpeakDirect auf %s nicht nötig — Synthesize liefert WAV; über einen Sink abspielen", runtime.GOOS)
return fmt.Errorf("osbuiltin: SpeakDirect not needed on %s; Synthesize returns WAV, play it through a sink", runtime.GOOS)
default:
if _, err := exec.LookPath("spd-say"); err != nil {
return fmt.Errorf("osbuiltin: spd-say nicht gefunden (speech-dispatcher installieren oder espeak für Audio-Bytes): %w", err)
return fmt.Errorf("osbuiltin: spd-say not found (install speech-dispatcher, or espeak for audio bytes): %w", err)
}
cmd := exec.CommandContext(ctx, "spd-say", "-w", "-l", lang, text)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("osbuiltin: spd-say fehlgeschlagen: %w: %s", err, bytes.TrimSpace(out))
return fmt.Errorf("osbuiltin: spd-say failed: %w: %s", err, bytes.TrimSpace(out))
}
return nil
}
}
// synthEspeak versucht espeak, dann espeak-ng. Beide schreiben WAV
// (RIFF/PCM) auf stdout.
// synthEspeak tries espeak, then espeak-ng. Both write WAV (RIFF/PCM) to
// stdout.
func (OSBuiltin) synthEspeak(ctx context.Context, text, lang string) ([]byte, error) {
for _, bin := range []string{"espeak", "espeak-ng"} {
if _, err := exec.LookPath(bin); err != nil {
@@ -138,26 +138,26 @@ func (OSBuiltin) synthEspeak(ctx context.Context, text, lang string) ([]byte, er
cmd := exec.CommandContext(ctx, bin, "-v", lang, "--stdout", text)
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("osbuiltin: %s fehlgeschlagen: %w", bin, withStderr(err))
return nil, fmt.Errorf("osbuiltin: %s failed: %w", bin, withStderr(err))
}
if len(out) == 0 {
return nil, fmt.Errorf("osbuiltin: %s lieferte keine Audiodaten", bin)
return nil, fmt.Errorf("osbuiltin: %s returned no audio data", bin)
}
return out, nil
}
return nil, fmt.Errorf("osbuiltin: unter %s kein espeak/espeak-ng für Audio-Bytes gefunden: %w", runtime.GOOS, ErrNoByteOutput)
return nil, fmt.Errorf("osbuiltin: no espeak/espeak-ng for audio bytes found on %s: %w", runtime.GOOS, ErrNoByteOutput)
}
// synthSay erzeugt WAV über das macOS-say-Kommando in einer temporären
// Datei. --data-format=LEI16@22050 erzwingt 16-Bit-PCM; die Endung .wav
// den WAVE-Container.
// synthSay produces WAV via the macOS say command into a temporary file.
// --data-format=LEI16@22050 forces 16-bit PCM; the .wav extension forces
// the WAVE container.
func (OSBuiltin) synthSay(ctx context.Context, text, lang string) ([]byte, error) {
if _, err := exec.LookPath("say"); err != nil {
return nil, fmt.Errorf("osbuiltin: say nicht gefunden: %w", err)
return nil, fmt.Errorf("osbuiltin: say not found: %w", err)
}
tmp, err := os.CreateTemp("", "talk-say-*.wav")
if err != nil {
return nil, fmt.Errorf("osbuiltin: temporäre Datei: %w", err)
return nil, fmt.Errorf("osbuiltin: temporary file: %w", err)
}
name := tmp.Name()
tmp.Close()
@@ -166,16 +166,16 @@ func (OSBuiltin) synthSay(ctx context.Context, text, lang string) ([]byte, error
cmd := exec.CommandContext(ctx, "say", "-v", sayVoice(lang),
"--data-format=LEI16@22050", "-o", name, text)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("osbuiltin: say fehlgeschlagen: %w: %s", err, bytes.TrimSpace(out))
return nil, fmt.Errorf("osbuiltin: say failed: %w: %s", err, bytes.TrimSpace(out))
}
data, err := os.ReadFile(name)
if err != nil {
return nil, fmt.Errorf("osbuiltin: say-Ausgabe lesen: %w", err)
return nil, fmt.Errorf("osbuiltin: reading say output: %w", err)
}
return data, nil
}
// sayVoice mappt Sprachen auf vorinstallierte macOS-Stimmen.
// sayVoice maps languages to preinstalled macOS voices.
func sayVoice(lang string) string {
if lang == "en" {
return "Samantha" // en-US
@@ -183,18 +183,18 @@ func sayVoice(lang string) string {
return "Anna" // de-DE
}
// synthSAPI erzeugt WAV über die Windows-SAPI (System.Speech) per
// PowerShell. Das Skript wird als -EncodedCommand (Base64/UTF-16LE)
// übergeben und der Text darin base64-dekodiert übergeben — dadurch gibt
// es kein Quoting-Problem mit Sonderzeichen. Ist die Wunschsprache nicht
// installiert, fällt SAPI auf die Systemstimme zurück (try/catch).
// synthSAPI produces WAV via the Windows SAPI (System.Speech) through
// PowerShell. The script is passed as -EncodedCommand (Base64/UTF-16LE) and
// the text inside it is passed base64-decoded, so there is no quoting
// problem with special characters. If the requested language is not
// installed, SAPI falls back to the system voice (try/catch).
func (OSBuiltin) synthSAPI(ctx context.Context, text, lang string) ([]byte, error) {
if _, err := exec.LookPath("powershell"); err != nil {
return nil, fmt.Errorf("osbuiltin: powershell nicht gefunden: %w", err)
return nil, fmt.Errorf("osbuiltin: powershell not found: %w", err)
}
tmp, err := os.CreateTemp("", "talk-sapi-*.wav")
if err != nil {
return nil, fmt.Errorf("osbuiltin: temporäre Datei: %w", err)
return nil, fmt.Errorf("osbuiltin: temporary file: %w", err)
}
name := tmp.Name()
tmp.Close()
@@ -214,16 +214,16 @@ $s.Dispose()
cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-STA", "-EncodedCommand", psEncode(script))
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("osbuiltin: SAPI fehlgeschlagen: %w: %s", err, bytes.TrimSpace(out))
return nil, fmt.Errorf("osbuiltin: SAPI failed: %w: %s", err, bytes.TrimSpace(out))
}
data, err := os.ReadFile(name)
if err != nil {
return nil, fmt.Errorf("osbuiltin: SAPI-Ausgabe lesen: %w", err)
return nil, fmt.Errorf("osbuiltin: reading SAPI output: %w", err)
}
return data, nil
}
// sapiCulture mappt Sprachen auf SAPI-Kulturbezeichner.
// sapiCulture maps languages to SAPI culture identifiers.
func sapiCulture(lang string) string {
if lang == "en" {
return "en-US"
@@ -231,7 +231,7 @@ func sapiCulture(lang string) string {
return "de-DE"
}
// psEncode kodiert ein PowerShell-Skript für -EncodedCommand
// psEncode encodes a PowerShell script for -EncodedCommand
// (UTF-16LE + Base64).
func psEncode(script string) string {
runes := utf16.Encode([]rune(script))
@@ -242,8 +242,8 @@ func psEncode(script string) string {
return base64.StdEncoding.EncodeToString(buf)
}
// withStderr reichert einen exec-Fehler um die gesammelte stderr-Ausgabe
// des Prozesses an, wenn vorhanden.
// withStderr enriches an exec error with the process's collected stderr
// output, if any.
func withStderr(err error) error {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 {
+65 -65
View File
@@ -10,9 +10,9 @@ import (
"testing"
)
// fakeBin schreibt ein ausführbares Shell-Skript namens name in dir, das
// body ausführt. Damit werden die externen Prozesse des Providers an der
// echten Prozessgrenze gefaked (PATH-Manipulation, kein Mock-Framework).
// fakeBin writes an executable shell script called name into dir that runs
// body. This fakes the provider's external processes at the real process
// boundary (PATH manipulation, no mock framework).
func fakeBin(t *testing.T, dir, name, body string) {
t.Helper()
script := "#!/bin/sh\n" + body + "\n"
@@ -22,8 +22,8 @@ func fakeBin(t *testing.T, dir, name, body string) {
}
}
// emptyPATH setzt PATH auf ein Verzeichnis ohne einzige Binärdatei —
// kein espeak, kein spd-say.
// emptyPATH sets PATH to a directory without a single binary: no espeak,
// no spd-say.
func emptyPATH(t *testing.T) string {
t.Helper()
dir := t.TempDir()
@@ -31,15 +31,15 @@ func emptyPATH(t *testing.T) string {
return dir
}
// prependPATH stellt dir vor den bestehenden PATH: Binaries darin
// überschatten System-Binaries gleichen Namens (fake espeak vor einem
// echten), während Hilfsprogramme wie cat weiterhin erreichbar bleiben.
// prependPATH puts dir in front of the existing PATH: binaries in it shadow
// system binaries of the same name (fake espeak before a real one) while
// helpers such as cat stay reachable.
func prependPATH(t *testing.T, dir string) {
t.Helper()
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
}
// TestOSBuiltinName und TestNewRegistry pinnen die Registry-Sicht.
// TestOSBuiltinName and TestNewRegistry pin the registry view.
func TestOSBuiltinName(t *testing.T) {
if got := (OSBuiltin{}).Name(); got != "osbuiltin" {
t.Errorf("Name() = %q, want %q", got, "osbuiltin")
@@ -56,7 +56,7 @@ func TestNewRegistry(t *testing.T) {
t.Errorf("New(%q).Name() = %q", name, p.Name())
}
}
// Task 03: die Cloud-Provider stehen in der Registry.
// The cloud providers are in the registry.
for _, name := range []string{"elevenlabs", "openai", "edge"} {
p, err := New(name, ChainConfig{})
if err != nil {
@@ -66,38 +66,38 @@ func TestNewRegistry(t *testing.T) {
t.Errorf("New(%q).Name() = %q", name, p.Name())
}
}
if _, err := New("gibtsnicht", ChainConfig{}); err == nil {
t.Error("New(\"gibtsnicht\") soll fehlschlagen")
if _, err := New("doesnotexist", ChainConfig{}); err == nil {
t.Error("New(\"doesnotexist\") should fail")
} else if !strings.Contains(err.Error(), "osbuiltin") || !strings.Contains(err.Error(), "elevenlabs") {
t.Errorf("New-Fehler soll verfügbare Provider nennen: %v", err)
t.Errorf("New error should name the available providers: %v", err)
}
}
// TestAvailableOhneTTS: leere PATH-Umgebung → Available false.
func TestAvailableOhneTTS(t *testing.T) {
// TestAvailableWithoutTTS: empty PATH environment → Available false.
func TestAvailableWithoutTTS(t *testing.T) {
emptyPATH(t)
if (OSBuiltin{}).Available(context.Background()) {
t.Error("Available() = true ohne jedes TTS-Binary, want false")
t.Error("Available() = true without any TTS binary, want false")
}
}
// TestSynthesizeOhneTTS: fehlt byte-fähiges TTS, muss der Fehler
// ErrNoByteOutput sein (kein Panik, kein anderer Fehlertyp).
func TestSynthesizeOhneTTS(t *testing.T) {
// TestSynthesizeWithoutTTS: if byte-capable TTS is missing, the error must
// be ErrNoByteOutput (no panic, no other error type).
func TestSynthesizeWithoutTTS(t *testing.T) {
emptyPATH(t)
_, err := (OSBuiltin{}).Synthesize(context.Background(), "Hallo", "de")
_, err := (OSBuiltin{}).Synthesize(context.Background(), "Hello", "de")
if err == nil {
t.Fatal("Synthesize ohne TTS: kein Fehler")
t.Fatal("Synthesize without TTS: no error")
}
if !errors.Is(err, ErrNoByteOutput) {
t.Fatalf("Synthesize-Fehler = %v, want ErrNoByteOutput", err)
t.Fatalf("Synthesize error = %v, want ErrNoByteOutput", err)
}
}
// TestSynthesizeMitFakeEspeak: ein gefaktes espeak liefert WAV-Bytes auf
// stdout; Synthesize muss genau diese Bytes durchreichen und die Stimme
// (-v <lang>) sowie den Text übergeben bekommen haben.
func TestSynthesizeMitFakeEspeak(t *testing.T) {
// TestSynthesizeWithFakeEspeak: a faked espeak returns WAV bytes on stdout;
// Synthesize must pass exactly these bytes through and must have received
// the voice (-v <lang>) and the text.
func TestSynthesizeWithFakeEspeak(t *testing.T) {
dir := t.TempDir()
wav := filepath.Join(dir, "probe.wav")
if err := os.WriteFile(wav, minimalWAV, 0o644); err != nil {
@@ -108,95 +108,95 @@ func TestSynthesizeMitFakeEspeak(t *testing.T) {
cat "`+wav+`"`)
prependPATH(t, dir)
data, err := (OSBuiltin{}).Synthesize(context.Background(), "Hallo Welt", "de")
data, err := (OSBuiltin{}).Synthesize(context.Background(), "Hello world", "de")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if string(data) != string(minimalWAV) {
t.Errorf("Synthesize lieferte %d Bytes, want die WAV-Bytes des Fake-espeak (%d)", len(data), len(minimalWAV))
t.Errorf("Synthesize returned %d bytes, want the WAV bytes of the fake espeak (%d)", len(data), len(minimalWAV))
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("Fake-espeak wurde nicht aufgerufen: %v", err)
t.Fatalf("fake espeak was not called: %v", err)
}
if got := string(args); got != "-v de --stdout Hallo Welt\n" {
t.Errorf("Fake-espeak ARGS = %q, want %q", got, "-v de --stdout Hallo Welt\n")
if got := string(args); got != "-v de --stdout Hello world\n" {
t.Errorf("fake espeak ARGS = %q, want %q", got, "-v de --stdout Hello world\n")
}
if !(OSBuiltin{}).Available(context.Background()) {
t.Error("Available() = false trotz espeak im PATH")
t.Error("Available() = false despite espeak in PATH")
}
}
// TestSynthesizeLeererText ist ein klarer Fehler.
func TestSynthesizeLeererText(t *testing.T) {
// TestSynthesizeEmptyText is a clear error.
func TestSynthesizeEmptyText(t *testing.T) {
if _, err := (OSBuiltin{}).Synthesize(context.Background(), "", "de"); err == nil {
t.Error("Synthesize(\"\") soll fehlschlagen")
t.Error("Synthesize(\"\") should fail")
}
}
// TestSynthesizeFehlgeschlagenesEspeak: espeak crasht (exit 1) → klarer
// Fehler, kein ErrNoByteOutput.
func TestSynthesizeFehlgeschlagenesEspeak(t *testing.T) {
// TestSynthesizeFailingEspeak: espeak crashes (exit 1) → clear error, not
// ErrNoByteOutput.
func TestSynthesizeFailingEspeak(t *testing.T) {
dir := emptyPATH(t)
fakeBin(t, dir, "espeak", `echo "boom" >&2; exit 1`)
_, err := (OSBuiltin{}).Synthesize(context.Background(), "Hallo", "de")
_, err := (OSBuiltin{}).Synthesize(context.Background(), "Hello", "de")
if err == nil {
t.Fatal("Synthesize mit crashendem espeak: kein Fehler")
t.Fatal("Synthesize with crashing espeak: no error")
}
if errors.Is(err, ErrNoByteOutput) {
t.Errorf("Crashender espeak soll kein ErrNoByteOutput sein: %v", err)
t.Errorf("crashing espeak must not be ErrNoByteOutput: %v", err)
}
if !strings.Contains(err.Error(), "boom") {
t.Errorf("Fehler soll stderr des Prozesses enthalten: %v", err)
t.Errorf("error should contain the process stderr: %v", err)
}
}
// TestSpeakDirectMitFakeSpdSay: SpeakDirect ruft spd-say blockierend
// (-w) mit Sprache (-l <lang>) und Text auf.
func TestSpeakDirectMitFakeSpdSay(t *testing.T) {
// TestSpeakDirectWithFakeSpdSay: SpeakDirect calls spd-say blocking (-w)
// with language (-l <lang>) and text.
func TestSpeakDirectWithFakeSpdSay(t *testing.T) {
dir := emptyPATH(t)
seen := filepath.Join(dir, "seen.txt")
fakeBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
prependPATH(t, dir)
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hallo Welt", "de"); err != nil {
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hello world", "de"); err != nil {
t.Fatalf("SpeakDirect: %v", err)
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("Fake-spd-say wurde nicht aufgerufen: %v", err)
t.Fatalf("fake spd-say was not called: %v", err)
}
if got := string(args); got != "-w -l de Hallo Welt\n" {
t.Errorf("Fake-spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt\n")
if got := string(args); got != "-w -l de Hello world\n" {
t.Errorf("fake spd-say ARGS = %q, want %q", got, "-w -l de Hello world\n")
}
}
// TestSpeakDirectOhneSpdSay ist ein klarer Fehler.
func TestSpeakDirectOhneSpdSay(t *testing.T) {
// TestSpeakDirectWithoutSpdSay is a clear error.
func TestSpeakDirectWithoutSpdSay(t *testing.T) {
emptyPATH(t)
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hallo", "de"); err == nil {
t.Error("SpeakDirect ohne spd-say soll fehlschlagen")
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hello", "de"); err == nil {
t.Error("SpeakDirect without spd-say should fail")
}
}
// TestSpeakDirectEchtesSpdSay läuft nur, wenn TALK_TEST_REAL_SPD=1 gesetzt
// ist (erzeugt echte, hörbare Ausgabe über speech-dispatcher — einmalige
// manuelle Abnahme; Standard-Testläufe bleiben stumm).
func TestSpeakDirectEchtesSpdSay(t *testing.T) {
// TestSpeakDirectRealSpdSay runs only when TALK_TEST_REAL_SPD=1 is set (it
// produces real, audible output through speech-dispatcher; one-off manual
// acceptance, standard test runs stay silent).
func TestSpeakDirectRealSpdSay(t *testing.T) {
if os.Getenv("TALK_TEST_REAL_SPD") != "1" {
t.Skip("Übersprungen (hörbar) — mit TALK_TEST_REAL_SPD=1 ausführen")
t.Skip("skipped (audible); run with TALK_TEST_REAL_SPD=1")
}
if _, err := exec.LookPath("spd-say"); err != nil {
t.Skip("spd-say nicht installiert")
t.Skip("spd-say not installed")
}
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Sprachtest über speech dispatcher", "de"); err != nil {
t.Fatalf("SpeakDirect gegen echtes spd-say: %v", err)
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Speech test via speech dispatcher", "de"); err != nil {
t.Fatalf("SpeakDirect against real spd-say: %v", err)
}
}
// minimalWAV ist eine minimal gültige RIFF/WAVE-Datei (PCM 16 Bit, mono,
// 8000 Hz) mit vier Samples — handgebaut nach der RIFF-Spezifikation,
// unabhängig vom Parser im audio-Paket.
// minimalWAV is a minimal valid RIFF/WAVE file (PCM 16 bit, mono, 8000 Hz)
// with four samples, hand-built per the RIFF specification, independent of
// the parser in the audio package.
var minimalWAV = []byte{
'R', 'I', 'F', 'F', 44, 0, 0, 0, // ChunkSize = 4 + (8+16) + (8+8)
'W', 'A', 'V', 'E',
@@ -208,5 +208,5 @@ var minimalWAV = []byte{
2, 0, // BlockAlign
16, 0, // Bits
'd', 'a', 't', 'a', 8, 0, 0, 0,
0, 0, 0x10, 0x7F, 0xF0, 0x80, 0x00, 0x40, // 4 Samples
0, 0, 0x10, 0x7F, 0xF0, 0x80, 0x00, 0x40, // 4 samples
}
+45 -46
View File
@@ -8,69 +8,68 @@ import (
"strings"
)
// DefaultName ist der Provider, den New("") liefert (Einzelkonstruktion,
// z. B. für doctor). Die Default-Ansage-Kette ab Task 03 steht in
// DefaultOrder (chain.go): elevenlabs → openai → edge → osbuiltin.
// DefaultName is the provider New("") returns (single construction, e.g.
// for doctor). The default announcement chain lives in DefaultOrder
// (chain.go): elevenlabs → openai → edge → osbuiltin.
const DefaultName = "osbuiltin"
// ErrNoByteOutput meldet, dass die Plattform keine über Synthesize
// beziehbaren Audio-Bytes liefern kann — konkret Linux mit speech-dispatcher
// (spd-say) ohne espeak: spd-say kann Audio nur selbst über den Daemon
// abspielen, nicht in einen Prozess schreiben. Aufrufer sollen auf
// DirectSpeaker ausweichen (siehe dort).
// ErrNoByteOutput reports that the platform cannot provide audio bytes via
// Synthesize; concretely Linux with speech-dispatcher (spd-say) but without
// espeak: spd-say can only play audio itself through the daemon, it cannot
// write it to a process. Callers should fall back to DirectSpeaker (see
// there).
var ErrNoByteOutput = errors.New(
"diese Plattform kann keine Audio-Bytes liefern (Linux ohne espeak; spd-say spielt nur direkt)")
"this platform cannot provide audio bytes (Linux without espeak; spd-say only speaks directly)")
// Provider ist der Vertrag aller TTS-Backends von talk.
// Provider is the contract of all talk TTS backends.
//
// Synthesize liefert dekodierbare Audio-Bytes; das Format ist von der
// Implementierung dokumentiert: osbuiltin liefert RIFF/WAVE (PCM), die
// Cloud-Provider elevenlabs, openai und edge liefern MP3 — beides wird vom
// audio-Paket (oto-Sink) verstanden.
// Synthesize returns decodable audio bytes; the format is documented by the
// implementation: osbuiltin returns RIFF/WAVE (PCM), the cloud providers
// elevenlabs, openai and edge return MP3. Both are understood by the audio
// package (oto sink).
type Provider interface {
// Name liefert den eindeutigen Providernamen (CLI --provider, Config).
// Name returns the unique provider name (CLI --provider, config).
Name() string
// Available meldet, ob der Provider auf diesem System überhaupt nutzbar
// ist (z. B. Binary oder Key vorhanden). Muss billig und ohne Neben-
// wirkungen sein; false heißt: Kette soll überspringen werden.
// Available reports whether the provider is usable on this system at all
// (e.g. binary or key present). Must be cheap and side-effect free;
// false means: the chain should skip this tier.
Available(ctx context.Context) bool
// Synthesize erzeugt Audio-Bytes für text in der Sprache lang.
// Synthesize produces audio bytes for text in language lang.
Synthesize(ctx context.Context, text, lang string) ([]byte, error)
}
// DirectSpeaker ist eine dokumentierte optionale Erweiterung über das
// Provider-Interface hinaus (kein Bestandteil des Basisvertrags): Provider,
// die Text ohne Umweg über Synthesize-Bytes selbst hörbar ausgeben können.
// Notwendig auf Linux-Systemen, deren OS-TTS (speech-dispatcher) keine
// Audio-Bytes an Prozesse liefern kann — dort spielt osbuiltin.SpeakDirect
// über spd-say selbst ab (blockierend bis zum Ende der Ansage).
// DirectSpeaker is a documented optional extension beyond the Provider
// interface (not part of the base contract): providers that can make text
// audible themselves without going through Synthesize bytes. Required on
// Linux systems whose OS TTS (speech-dispatcher) cannot hand audio bytes to
// processes; there osbuiltin.SpeakDirect plays via spd-say itself (blocking
// until the announcement has finished).
//
// In der Kette (chain.go) ist SpeakDirect die letzte Stufe, wenn kein
// Provider Bytes liefern kann; Chain implementiert DirectSpeaker selbst und
// reicht an den Stufen-Provider durch.
// In the chain (chain.go) SpeakDirect is the last tier when no provider can
// deliver bytes; Chain implements DirectSpeaker itself and forwards to the
// tier provider.
type DirectSpeaker interface {
// SpeakDirect gibt text in der Sprache lang direkt über das OS-TTS aus
// und blockiert bis zum Ende der Wiedergabe (oder bis ctx abgebrochen
// wird).
// SpeakDirect speaks text in language lang directly through the OS TTS
// and blocks until playback has finished (or ctx is cancelled).
SpeakDirect(ctx context.Context, text, lang string) error
}
// VoicePinner ist eine dokumentierte optionale Erweiterung über das
// Provider-Interface hinaus: Provider, die eine explizite Voice-ID pinnen
// können (das CLI-Flag --voice). Die gepinnte Stimme schlägt das
// voice.<provider>.<lang>-Mapping der config.toml und die eingebauten
// neutralen Defaults. Die Kette ruft PinVoice beim Bau auf alle Stufen auf,
// die die Erweiterung implementieren (Stufen ohne Voice-Begriff, z. B.
// osbuiltin, loggen einen Hinweis statt still zu ignorieren).
// VoicePinner is a documented optional extension beyond the Provider
// interface: providers that can pin an explicit voice ID (the CLI flag
// --voice). The pinned voice beats the voice.<provider>.<lang> mapping of
// config.toml and the built-in neutral defaults. The chain calls PinVoice
// at construction on all tiers that implement the extension (tiers without
// a notion of voice, e.g. osbuiltin, log a note instead of silently
// ignoring it).
type VoicePinner interface {
PinVoice(id string)
}
// registry bildet Providernamen auf Konstruktoren; NewChain und New bauen
// darüber. Die Konstruktoren erhalten die ChainConfig (Endpunkte, Voices,
// Keys via Getenv), damit Kette und Daemon (Task 04) dieselbe Quelle haben.
// registry maps provider names to constructors; NewChain and New build
// through it. The constructors receive the ChainConfig (endpoints, voices,
// keys via Getenv) so that the chain and the daemon share one source.
var registry = map[string]func(ChainConfig) Provider{
"elevenlabs": func(cfg ChainConfig) Provider { return NewElevenLabs(cfg) },
"openai": func(cfg ChainConfig) Provider { return NewOpenAI(cfg) },
@@ -78,7 +77,7 @@ var registry = map[string]func(ChainConfig) Provider{
"osbuiltin": func(ChainConfig) Provider { return OSBuiltin{} },
}
// Names liefert die registrierten Providernamen (sortiert).
// Names returns the registered provider names (sorted).
func Names() []string {
names := make([]string, 0, len(registry))
for name := range registry {
@@ -88,16 +87,16 @@ func Names() []string {
return names
}
// New liefert den Provider mit dem angegebenen Namen (Einzelkonstruktion);
// "" wählt DefaultName. Für die Ansage-Kette (Fallback) NewChain verwenden.
// Unbekannte Namen sind ein klarer Fehler.
// New returns the provider with the given name (single construction); ""
// selects DefaultName. For the announcement chain (fallback) use NewChain.
// Unknown names are a clear error.
func New(name string, cfg ChainConfig) (Provider, error) {
if name == "" {
name = DefaultName
}
ctor, ok := registry[name]
if !ok {
return nil, fmt.Errorf("unbekannter oder noch nicht implementierter Provider %q (verfügbar: %s)",
return nil, fmt.Errorf("unknown provider %q (available: %s)",
name, strings.Join(Names(), ", "))
}
if cfg.Getenv == nil {
+21 -22
View File
@@ -2,28 +2,27 @@ package provider
import "sort"
// voiceDefaults sind die neutralen Repo-Defaults je Provider und Sprache
// (Teil des öffentlichen Vertrags: `talk voices` aus Task 08 listet sie,
// überschrieben werden sie durch voice.<provider>.<lang> in der config.toml
// oder das CLI-Flag --voice).
// voiceDefaults are the neutral repo defaults per provider and language
// (part of the public contract: `talk voices` lists them; they are
// overridden by voice.<provider>.<lang> in config.toml or the CLI flag
// --voice).
//
// Entscheidungsnotiz ElevenLabs-Stimme: Spec und Plan führen die persönliche
// Stimme KXxZd16DiBqt82nbarJx ausdrücklich als PRIVATEN Default des Nutzers
// („nicht Repo-Default", Spec Further Notes); der Repo-Default ist deshalb
// die neutrale, mehrsprachige Standardstimme „Rachel" (21m00Tcm4TlvDq8ikWAM
// aus dem öffentlichen ElevenLabs-Katalog). Die persönliche Stimme gehört in
// die eigene config.toml:
// Decision note on the ElevenLabs voice: spec and plan explicitly treat the
// personal voice KXxZd16DiBqt82nbarJx as the user's PRIVATE default ("not a
// repo default", spec Further Notes); the repo default is therefore the
// neutral, multilingual standard voice "Rachel" (21m00Tcm4TlvDq8ikWAM from
// the public ElevenLabs catalog). The personal voice belongs in one's own
// config.toml:
//
// [voice.elevenlabs]
// de = "KXxZd16DiBqt82nbarJx"
// en = "KXxZd16DiBqt82nbarJx"
//
// OpenAI „alloy" und die Edge-Neural-Stimmen (de-DE-KatjaNeural,
// en-US-AriaNeural) sind die jeweils neutralen Standardstimmen der APIs
// (Edge-Stimmen sind im Task-Brief vorgegeben).
// OpenAI "alloy" and the Edge neural voices (de-DE-KatjaNeural,
// en-US-AriaNeural) are the respective neutral standard voices of the APIs.
var voiceDefaults = map[string]map[string]string{
"elevenlabs": {
"de": "21m00Tcm4TlvDq8ikWAM", // „Rachel", multilingual
"de": "21m00Tcm4TlvDq8ikWAM", // "Rachel", multilingual
"en": "21m00Tcm4TlvDq8ikWAM",
},
"openai": {
@@ -35,19 +34,19 @@ var voiceDefaults = map[string]map[string]string{
"en": "en-US-AriaNeural",
},
"osbuiltin": {
// osbuiltin mappt intern selbst (macOS Anna/Samantha, SAPI-Kultur,
// espeak -v <lang>) und kennt keine Voice-IDs — hier nur der
// Vollständigkeit wegen dokumentiert.
// osbuiltin maps internally on its own (macOS Anna/Samantha, SAPI
// culture, espeak -v <lang>) and has no voice IDs; documented here
// only for completeness.
"de": "",
"en": "",
},
}
// resolveVoice liefert die Voice-ID für einen Provider und eine Sprache.
// Rangfolge: override (CLI-Flag --voice bzw. PinVoice) > Mapping aus der
// config.toml > neutrales Repo-Default > en-Default > alphabetisch erstes
// Default. Ein leerer Rückgabewert heißt: der Provider kennt für diese
// Sprache keine Stimme (osbuiltin) und entscheidet selbst.
// resolveVoice returns the voice ID for a provider and a language.
// Precedence: override (CLI flag --voice or PinVoice) > mapping from
// config.toml > neutral repo default > en default > alphabetically first
// default. An empty result means: the provider has no voice for this
// language (osbuiltin) and decides on its own.
func resolveVoice(name, lang, override string, cfgVoices map[string]string) string {
if override != "" {
return override
+11 -11
View File
@@ -2,9 +2,9 @@ package provider
import "testing"
// TestResolveVoiceRangfolge pinnt die dokumentierte Rangfolge
// --voice-Pin > config-Mapping > Repo-Default > en-Default.
func TestResolveVoiceRangfolge(t *testing.T) {
// TestResolveVoicePrecedence pins the documented precedence
// --voice pin > config mapping > repo default > en default.
func TestResolveVoicePrecedence(t *testing.T) {
cases := []struct {
name string
provider string
@@ -13,14 +13,14 @@ func TestResolveVoiceRangfolge(t *testing.T) {
cfg map[string]string
want string
}{
{"Default edge de", "edge", "de", "", nil, "de-DE-KatjaNeural"},
{"Default edge en", "edge", "en", "", nil, "en-US-AriaNeural"},
{"Default elevenlabs de (neutral, nicht privat)", "elevenlabs", "de", "", nil, "21m00Tcm4TlvDq8ikWAM"},
{"Default openai en", "openai", "en", "", nil, "alloy"},
{"Mapping schlägt Default", "edge", "de", "", map[string]string{"de": "de-DE-ConradNeural"}, "de-DE-ConradNeural"},
{"Pin schlägt Mapping", "edge", "de", "Gepinnt", map[string]string{"de": "de-DE-ConradNeural"}, "Gepinnt"},
{"unbekannte Sprache → en-Default", "edge", "fr", "", nil, "en-US-AriaNeural"},
{"osbuiltin kennt keine Voice-IDs", "osbuiltin", "de", "", nil, ""},
{"default edge de", "edge", "de", "", nil, "de-DE-KatjaNeural"},
{"default edge en", "edge", "en", "", nil, "en-US-AriaNeural"},
{"default elevenlabs de (neutral, not private)", "elevenlabs", "de", "", nil, "21m00Tcm4TlvDq8ikWAM"},
{"default openai en", "openai", "en", "", nil, "alloy"},
{"mapping beats default", "edge", "de", "", map[string]string{"de": "de-DE-ConradNeural"}, "de-DE-ConradNeural"},
{"pin beats mapping", "edge", "de", "Pinned", map[string]string{"de": "de-DE-ConradNeural"}, "Pinned"},
{"unknown language falls back to en default", "edge", "fr", "", nil, "en-US-AriaNeural"},
{"osbuiltin has no voice IDs", "osbuiltin", "de", "", nil, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
+9 -8
View File
@@ -1,9 +1,10 @@
// Package queue implementiert die Ansage-Queue des Daemons: serialisierte
// Abarbeitung (eine Ansage gleichzeitig), Prioritäten (high > normal > low,
// FIFO je Level), now-Items springen vor, dazu den persistenten Mute-State
// (Datei mute im Config-Dir). Seit Task 05 unter dem Anti-Spam-Gesetz
// (rate.go/quiet.go): Rate-Limit — jede gestartete Ansage bucht das Fenster;
// low über dem Budget verfällt, normal parkt bis zum Slot, high darf mit
// Burst-Recht verstoßen — und Ruhezeiten, die still verwerfen oder durch
// einen Toast ersetzen. now:true bricht bewusst aus beiden aus.
// Package queue is the daemon's announcement queue: serialized playback (one
// announcement at a time), priorities (high > normal > low, FIFO per level),
// now items that jump ahead, plus the persistent mute state (file "mute" in
// the config directory). The anti-spam laws (rate.go, quiet.go) sit on top:
// a rate limit where every started announcement books the window (low over
// budget is discarded, normal parks until the slot, high may break it with
// burst rights), quiet hours that discard silently or toast instead, a text
// length cap and a maximum queue age. now:true deliberately breaks out of
// all of them.
package queue
+50 -50
View File
@@ -6,9 +6,9 @@ import (
"time"
)
// TestTruncateText: Kürzung auf max Runen an einer Wortgrenze (nicht in der
// ersten Hälfte), ohne hängende Satzzeichen; kurze Texte und max 0 bleiben
// unangetastet; Runen zählen, nicht Bytes.
// TestTruncateText: cut to max runes at a word boundary (not in the first
// half), without dangling punctuation; short texts and max 0 stay untouched;
// runes count, not bytes.
func TestTruncateText(t *testing.T) {
cases := []struct {
name string
@@ -17,13 +17,13 @@ func TestTruncateText(t *testing.T) {
want string
truncated bool
}{
{"kurz bleibt", "Build fertig", 400, "Build fertig", false},
{"genau max bleibt", "abcde", 5, "abcde", false},
{"unbegrenzt", strings.Repeat("x", 1000), 0, strings.Repeat("x", 1000), false},
{"wortgrenze", "Der Build ist fertig und bereit", 14, "Der Build ist", true},
{"satzzeichen weg", "Fertig, aber dann kam noch mehr", 8, "Fertig", true},
{"keine wortgrenze in zweiter hälfte → hart", "abcdefghijklmnop qrs", 10, "abcdefghij", true},
{"umlaute als eine rune", "Übergröße ärgert", 9, "Übergröße", true},
{"short stays", "Build done", 400, "Build done", false},
{"exactly max stays", "abcde", 5, "abcde", false},
{"unlimited", strings.Repeat("x", 1000), 0, strings.Repeat("x", 1000), false},
{"word boundary", "The build is done and ready", 14, "The build is", true},
{"punctuation dropped", "Done, but then there was more", 6, "Done", true},
{"no boundary in second half → hard cut", "abcdefghijklmnop qrs", 10, "abcdefghij", true},
{"umlauts count as one rune", "Übergröße ärgert", 9, "Übergröße", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -32,83 +32,83 @@ func TestTruncateText(t *testing.T) {
t.Errorf("truncateText(%q, %d) = %q/%v, want %q/%v", c.in, c.max, got, tr, c.want, c.truncated)
}
if c.max > 0 && len([]rune(got)) > c.max {
t.Errorf("Ergebnis %q länger als %d Runen", got, c.max)
t.Errorf("result %q longer than %d runes", got, c.max)
}
})
}
}
// TestEnqueueKuerztText: Das Gesetz MaxText greift beim Eintritt — die
// gekürzte Fassung wird gesprochen, Admission meldet Truncated.
func TestEnqueueKuerztText(t *testing.T) {
g := neueSpielerin()
// TestEnqueueTruncatesText: the MaxText law applies on admission; the
// shortened version is spoken and Admission reports Truncated.
func TestEnqueueTruncatesText(t *testing.T) {
g := newSpeaker()
q := New(Laws{MaxText: 20}, g.speak, nil)
defer q.Close()
adm, err := q.Enqueue(Item{Text: "Dieser Text ist deutlich länger als zwanzig Zeichen"})
adm, err := q.Enqueue(Item{Text: "This text is clearly longer than twenty characters"})
if err != nil || adm.ID == "" || !adm.Truncated {
t.Fatalf("Enqueue = %+v, %v; want aufgenommen + Truncated", adm, err)
t.Fatalf("Enqueue = %+v, %v; want admitted + Truncated", adm, err)
}
items := g.warteBis(t, 1)
if items[0].Text != "Dieser Text ist" {
t.Errorf("gesprochen = %q, want gekürzte Fassung", items[0].Text)
items := g.waitUntil(t, 1)
if items[0].Text != "This text is clearly" {
t.Errorf("spoken = %q, want the shortened version", items[0].Text)
}
g.step <- struct{}{}
adm, _ = q.Enqueue(Item{Text: "kurz"})
adm, _ = q.Enqueue(Item{Text: "short"})
if adm.Truncated {
t.Error("kurzer Text als gekürzt gemeldet")
t.Error("short text reported as truncated")
}
g.warteBis(t, 2)
g.waitUntil(t, 2)
g.step <- struct{}{}
}
// TestDecideExpired: Ein Item, das länger als MaxAge gewartet hat, wird
// verworfen — vor Ruhezeit und Rate-Limit, aber nach now (now bricht aus).
// TestDecideExpired: an item that waited longer than MaxAge is discarded,
// before quiet hours and rate limit but after now (now breaks out).
func TestDecideExpired(t *testing.T) {
laws := Laws{MaxAge: time.Minute, RateLimit: time.Hour}
now := time.Now()
alt := Item{Text: "alt", Priority: PriorityNormal, enqueued: now.Add(-2 * time.Minute)}
frisch := Item{Text: "frisch", Priority: PriorityNormal, enqueued: now.Add(-10 * time.Second)}
old := Item{Text: "old", Priority: PriorityNormal, enqueued: now.Add(-2 * time.Minute)}
fresh := Item{Text: "fresh", Priority: PriorityNormal, enqueued: now.Add(-10 * time.Second)}
if v := laws.decide(alt, now, true); v != verdictDiscardExpired {
t.Errorf("altes Item bei freiem Slot = %v, want verdictDiscardExpired", v)
if v := laws.decide(old, now, true); v != verdictDiscardExpired {
t.Errorf("old item with open slot = %v, want verdictDiscardExpired", v)
}
if v := laws.decide(frisch, now, true); v != verdictPlay {
t.Errorf("frisches Item bei freiem Slot = %v, want verdictPlay", v)
if v := laws.decide(fresh, now, true); v != verdictPlay {
t.Errorf("fresh item with open slot = %v, want verdictPlay", v)
}
if v := laws.decide(frisch, now, false); v != verdictWait {
t.Errorf("frisches Item ohne Slot = %v, want verdictWait", v)
if v := laws.decide(fresh, now, false); v != verdictWait {
t.Errorf("fresh item without slot = %v, want verdictWait", v)
}
altNow := alt
altNow.Now = true
if v := laws.decide(altNow, now, false); v != verdictPlay {
t.Errorf("altes now-Item = %v, want verdictPlay (now bricht aus)", v)
oldNow := old
oldNow.Now = true
if v := laws.decide(oldNow, now, false); v != verdictPlay {
t.Errorf("old now item = %v, want verdictPlay (now breaks out)", v)
}
if v := (Laws{}).decide(alt, now, true); v != verdictPlay {
t.Errorf("MaxAge 0 (unbegrenzt) verwirft altes Item: %v", v)
if v := (Laws{}).decide(old, now, true); v != verdictPlay {
t.Errorf("MaxAge 0 (unlimited) discards old item: %v", v)
}
}
// TestGeparktesItemVerfaellt: Ein normal-Item parkt auf den Rate-Limit-Slot;
// ist es beim Fälligwerden älter als MaxAge, wird es verworfen (Protokoll
// nennt max_age) und nicht mehr gesprochen.
func TestGeparktesItemVerfaellt(t *testing.T) {
// TestParkedItemExpires: a normal item parks for the rate-limit slot; if it
// is older than MaxAge when it comes due, it is discarded (log names
// max_age) and never spoken.
func TestParkedItemExpires(t *testing.T) {
logb := &syncLog{}
g := neueSpielerin()
g := newSpeaker()
q := New(Laws{RateLimit: 300 * time.Millisecond, MaxAge: 150 * time.Millisecond}, g.speak, logb)
defer q.Close()
q.Enqueue(Item{Text: "erste"}) // bucht das Fenster
g.warteBis(t, 1)
q.Enqueue(Item{Text: "first"}) // books the window
g.waitUntil(t, 1)
g.step <- struct{}{}
adm, _ := q.Enqueue(Item{Text: "parkt zu lange"}) // parkt ~300 ms > MaxAge
adm, _ := q.Enqueue(Item{Text: "parks too long"}) // parks ~300 ms > MaxAge
if adm.ID == "" || adm.Discarded != "" {
t.Fatalf("beim Eintritt darf nichts verfallen: %+v", adm)
t.Fatalf("nothing may expire on admission: %+v", adm)
}
waitFor(t, func() bool { return strings.Contains(logb.String(), "max_age") })
g.keineWeitere(t)
g.noFurther(t)
if !strings.Contains(logb.String(), adm.ID) {
t.Errorf("Protokoll nennt die verfallene Ansage nicht:\n%s", logb.String())
t.Errorf("log does not name the expired announcement:\n%s", logb.String())
}
}
+16 -20
View File
@@ -7,31 +7,27 @@ import (
"strings"
)
// MuteFileName ist der Name der Mute-Datei im Config-Verzeichnis (bindender
// Vertrag: „persistenten Mute-State toggeln (Datei mute im Config-Dir)").
// MuteFileName is the mute file's name inside the config directory.
const MuteFileName = "mute"
// ErrNoMutePath meldet einen Set-Versuch ohne konfigurierten Datei-Pfad
// (Config-Dir nicht bestimmbar — der Daemon deaktiviert dann Mute mit einer
// Start-Warnung statt zu scheitern).
var ErrNoMutePath = errors.New("mute: kein Datei-Pfad konfiguriert (Config-Dir nicht bestimmbar)")
// ErrNoMutePath reports a Set attempt without a configured file path (config
// directory unknown; the daemon then runs without mute and warns at start).
var ErrNoMutePath = errors.New("mute: no file path configured (config directory unknown)")
// MuteStore verwaltet den persistenten Mute-State als Datei im
// Config-Verzeichnis: Inhalt „true" = stumm, alles andere (oder keine Datei)
// = nicht stumm.
// MuteStore manages the persistent mute state as a file in the config
// directory: content "true" = muted, anything else (or no file) = not muted.
//
// Der Daemon liest die Datei bei jeder /speak-Anfrage neu — dadurch wirkt
// auch ein im Degraded Mode (Daemon tot) von der CLI direkt geschriebener
// State sofort, und der Zustand überlebt Daemon-Restarts.
// The daemon re-reads the file on every /speak request, so a state written
// by the CLI while no daemon runs takes effect immediately, and the state
// survives daemon restarts.
type MuteStore struct {
// Path ist der vollständige Pfad der Mute-Datei. Leer (kein Config-Dir
// bestimmbar): Muted() ist immer false, Set ein Fehler.
// Path is the mute file's full path. Empty (no config directory): Muted
// is always false and Set an error.
Path string
}
// Muted meldet den persistenten Mute-State. Eine fehlende oder unlesbare
// Datei heißt: nicht stumm (Fehlstart ohne Config-Dir wird nie „stumm aus
// Versehen").
// Muted reports the persistent mute state. A missing or unreadable file
// means not muted: a broken setup must never be "accidentally silent".
func (s MuteStore) Muted() bool {
if s.Path == "" {
return false
@@ -43,9 +39,9 @@ func (s MuteStore) Muted() bool {
return strings.TrimSpace(string(b)) == "true"
}
// Set schreibt den Mute-State atomar (temporäre Datei + Rename, damit
// Daemon und CLI nie eine halb geschriebene Datei lesen) und legt das
// Config-Verzeichnis bei Bedarf an.
// Set writes the mute state atomically (temp file + rename, so daemon and
// CLI never read a half-written file) and creates the config directory if
// needed.
func (s MuteStore) Set(muted bool) error {
if s.Path == "" {
return ErrNoMutePath
+122 -128
View File
@@ -12,18 +12,18 @@ import (
"time"
)
// Priority eines Queue-Items — öffentlicher Vertrag der HTTP-API
// (low | normal | high; leer wird als normal behandelt).
// Priority of a queue item; public contract of the HTTP API
// (low | normal | high; empty is treated as normal).
type Priority string
// Erlaubte Prioritäts-Werte (Spec: high > normal > low, FIFO je Level).
// Allowed priority values (high > normal > low, FIFO per level).
const (
PriorityLow Priority = "low"
PriorityNormal Priority = "normal"
PriorityHigh Priority = "high"
)
// Valid meldet, ob p ein erlaubter Prioritäts-Wert ist.
// Valid reports whether p is an allowed priority value.
func (p Priority) Valid() bool {
switch p {
case PriorityLow, PriorityNormal, PriorityHigh:
@@ -33,43 +33,42 @@ func (p Priority) Valid() bool {
}
}
// ErrClosed meldet einen Enqueue-Versuch nach Close (Daemon fährt herunter).
var ErrClosed = errors.New("queue: bereits geschlossen")
// ErrClosed reports an Enqueue attempt after Close (daemon shutting down).
var ErrClosed = errors.New("queue: already closed")
// Item ist eine Ansage in der Warteschlange. Alle Felder kommen aus der
// HTTP-API (POST /speak); ID und Ablauf-Verwaltung liegen bei der Queue.
// Item is an announcement in the queue. All fields come from the HTTP API
// (POST /speak); ID and lifecycle belong to the queue.
type Item struct {
// ID ist die kurze Ansage-Kennung (Antwort von POST /speak; Grundlage
// des --wait-Pollings der CLI). Wird von Enqueue vergeben, wenn leer.
// ID is the short announcement identifier (returned by POST /speak; the
// CLI polls it for --wait). Assigned by Enqueue when empty.
ID string
// Text ist der Ansage-Text.
// Text is the announcement text.
Text string
// Lang ist die Sprach-Pin (de | en); leer = automatische Erkennung
// durch den Daemon (langdetect).
// Lang pins the language (de | en); empty = detected by the daemon.
Lang string
// Provider pinnt die Provider-Kette auf eine Stufe (--provider); leer
// = konfigurierte Default-Kette.
// Provider pins the chain to one tier (--provider); empty = the
// configured default chain.
Provider string
// Voice pinnt eine Voice-ID (--voice); leer = Voice-Mapping/Defaults.
// Voice pins a voice ID (--voice); empty = voice mapping/defaults.
Voice string
// Priority steuert die Abarbeitungs-Reihenfolge (high > normal > low,
// FIFO je Level).
// Priority controls processing order (high > normal > low, FIFO per
// level).
Priority Priority
// Now lässt die Ansage vor alle wartenden Items springen (auch vor
// high) — sie unterbricht aber nie die laufende Ansage.
// Now lets the announcement jump ahead of every waiting item (including
// high); it never interrupts the announcement that is playing.
Now bool
seq uint64 // Enqueue-Reihenfolge: FIFO-Tiebrecher innerhalb eines Levels
enqueued time.Time // Eintrittszeit (Basis für Laws.MaxAge)
seq uint64 // enqueue order: FIFO tiebreaker within a level
enqueued time.Time // admission time (basis for Laws.MaxAge)
}
// rank liefert die Abarbeitungs-Klasse: now über high über normal über low.
// rank returns the processing class: now over high over normal over low.
func (it Item) rank() int {
if it.Now {
return 3
@@ -84,56 +83,53 @@ func (it Item) rank() int {
}
}
// Speaker spielt ein Item ab. Im Daemon: Provider-Kette bauen und Speak
// aufrufen (Synthese mit Fallback + Wiedergabe über den Audio-Sink — die
// Kette aus internal/provider wird unverändert wiederverwendet). ctx wird
// abgebrochen, wenn die Ansage abgebrochen (POST /cancel) oder der Daemon
// heruntergefahren wird; die Kette bricht ihre Netz- und Prozess-Arbeit
// dann ab. Der Rückgabe-Fehler wird protokolliert, nie an die API gereicht.
// Speaker plays an item. In the daemon: build the provider chain and call
// Speak (synthesis with fallback plus playback through the audio sink). ctx
// is cancelled when the announcement is cancelled (POST /cancel) or the
// daemon shuts down; the chain then aborts its network and process work.
// The returned error is logged, never passed to the API.
type Speaker func(ctx context.Context, item Item) error
// Queue ist die serialisierte Ansage-Queue des Daemons: genau eine Ansage
// gleichzeitig, Reihenfolge now > high > normal > low (FIFO je Level) —
// seit Task 05 unter dem Anti-Spam-Gesetz (Laws): normal parkt bis zum
// Rate-Limit-Slot, low wird über dem Budget verworfen, high darf mit
// Burst-Recht verstoßen, now bricht bewusst aus, und Ruhezeiten verwerfen
// bzw. toasten statt Audio.
// Queue is the daemon's serialized announcement queue: exactly one
// announcement at a time, order now > high > normal > low (FIFO per level),
// under the anti-spam laws: normal parks until the rate-limit slot, low
// over budget is discarded, high may break the limit with burst rights, now
// deliberately breaks out, quiet hours discard or toast instead of audio,
// stale items expire.
//
// Die Synchronisation ist bewusst simpel und offensichtlich korrekt statt
// clever: EIN Mutex + EIN Cond schützen alles (Items, aktuelles Item,
// Closed-Flag, cancelCur, Slot-Wecker). Der Worker ist der einzige, der
// Items entnimmt; take und das Setzen von current/cancelCur geschehen unter
// demselben Lock, damit ein Item nie zwischen „wartet" und „läuft" der
// Cancelierung entwischen kann. Cancel/Close rufen cancelCur unter dem Lock
// ab — der Worker bemerkt die Abbrechung in seiner speak-Funktion über ctx.
// Geparkte Items bleiben in der Warteschlange liegen; ein Slot-Wecker
// (armWakeLocked) broadcastet zum Fenster-Ablauf, damit der Worker auch ohne
// neuen Enqueue aufwacht — die Wait-Bedingung prüft jedes Mal die Gesetze
// neu, verlorene Signale sind dadurch harmlos.
// Synchronization is deliberately simple and obviously correct rather than
// clever: ONE mutex and ONE cond protect everything (items, current item,
// closed flag, cancelCur, slot timer). The worker is the only one taking
// items; take and setting current/cancelCur happen under the same lock, so
// an item can never slip past cancellation between "waiting" and "playing".
// Cancel/Close call cancelCur under the lock; the worker notices via ctx in
// its speak function. Parked items stay in the queue; a slot timer
// (armWakeLocked) broadcasts when the window elapses so the worker wakes
// without a new Enqueue. The wait condition re-evaluates the laws every
// time, so lost signals are harmless.
type Queue struct {
laws Laws
limit *RateLimiter
speak Speaker
log io.Writer
toast func(Item) // Ruhezeit-Toast (optional; gesetzt vor dem ersten Enqueue)
toast func(Item) // quiet-hours toast (optional; set before the first Enqueue)
mu sync.Mutex
notEmpty *sync.Cond // Signal: neues Item; Broadcast: Close/Slot frei
notEmpty *sync.Cond // Signal: new item; Broadcast: Close / slot open
items []Item
current *Item
cancelCur context.CancelFunc
seq uint64
closed bool
wake *time.Timer // weckt den geparkten Worker zum Slot-Termin
wakeAt time.Time // Termin des laufenden Weckers
done chan struct{} // geschlossen, wenn der Worker-Loop endet
wake *time.Timer // wakes the parked worker at the slot time
wakeAt time.Time // time of the armed timer
done chan struct{} // closed when the worker loop ends
}
// New startet die Queue mit ihrem Worker-Loop (eine Ansage gleichzeitig).
// laws trägt die Anti-Spam-Gesetze (Nullwert = alles erlaubt; der Daemon
// liefert die Config-Gesetze). log ist optional (nil = still) und empfängt
// die Lebenslauf-Zeilen der Items. Close beendet den Loop und wartet auf
// sein Ende.
// New starts the queue with its worker loop (one announcement at a time).
// laws carries the anti-spam laws (zero value = everything allowed; the
// daemon supplies the configured laws). log is optional (nil = silent) and
// receives the items' lifecycle lines. Close ends the loop and waits for it.
func New(laws Laws, speak Speaker, log io.Writer) *Queue {
q := &Queue{
laws: laws,
@@ -147,24 +143,24 @@ func New(laws Laws, speak Speaker, log io.Writer) *Queue {
return q
}
// OnToast setzt die Benachrichtigung, die in der Ruhezeit mit quiet_mode =
// "toast" statt Audio ausgelöst wird (Daemon: Desktop-Notification). Ohne
// Setzen bleibt es bei der Protokollzeile. Vor dem ersten Enqueue setzen.
// OnToast sets the notification fired during quiet hours with quiet_mode =
// "toast" instead of audio (daemon: desktop notification). Unset, only the
// log line remains. Set it before the first Enqueue.
func (q *Queue) OnToast(fn func(Item)) { q.toast = fn }
// fireToast ruft die Toast-Benachrichtigung auf (nie unter dem Mutex).
// fireToast calls the toast hook (never under the mutex).
func (q *Queue) fireToast(item Item) {
if q.toast != nil {
q.toast(item)
}
}
// Enqueue stellt item in die Warteschlange und liefert die Aufnahme-
// Entscheidung: Aufgenommene tragen ihre ID (bei leerer ID neu vergeben:
// kurz und zufällig). Ruhezeit-Verwurf/Toast und low über dem Fenster-Budget
// werden schon beim Eintritt entschieden (die 202-Antwort von POST /speak
// nennt den Grund); alles andere entscheidet der Worker, wenn das Item
// fällig ist. Nach Close ist Enqueue ein Fehler.
// Enqueue puts item into the queue and returns the admission decision:
// admitted items carry their ID (assigned when empty: short and random).
// Quiet-hours discard/toast and low over the window budget are decided on
// admission (the 202 response of POST /speak names the reason); everything
// else is decided by the worker when the item comes due. After Close,
// Enqueue is an error.
func (q *Queue) Enqueue(item Item) (Admission, error) {
q.mu.Lock()
if q.closed {
@@ -176,24 +172,24 @@ func (q *Queue) Enqueue(item Item) (Admission, error) {
}
var truncated bool
if item.Text, truncated = truncateText(item.Text, q.laws.MaxText); truncated {
defer q.logf("queue: ansage (%q …) auf %d Zeichen gekürzt (max_text)", firstRunes(item.Text, 40), q.laws.MaxText)
defer q.logf("queue: announcement (%q …) truncated to %d characters (max_text)", firstRunes(item.Text, 40), q.laws.MaxText)
}
item.enqueued = time.Now()
switch q.laws.decide(item, item.enqueued, q.limit.Allow()) {
case verdictDiscardQuiet:
q.mu.Unlock()
q.logf("queue: ansage (%q, priorität %s) verworfen — ruhezeit %s aktiv (modus discard)",
q.logf("queue: announcement (%q, priority %s) discarded: quiet hours %s active (mode discard)",
item.Text, item.Priority, q.laws.Quiet)
return Admission{Discarded: DiscardQuietHours}, nil
case verdictToast:
q.mu.Unlock()
q.logf("queue: ansage (%q, priorität %s) durch toast ersetzt — ruhezeit %s aktiv (modus toast)",
q.logf("queue: announcement (%q, priority %s) replaced by toast: quiet hours %s active (mode toast)",
item.Text, item.Priority, q.laws.Quiet)
q.fireToast(item)
return Admission{Discarded: DiscardQuietHours, Toast: true}, nil
case verdictDiscardRate:
q.mu.Unlock()
q.logf("queue: ansage (%q, priorität low) verworfen — rate-limit %s",
q.logf("queue: announcement (%q, priority low) discarded: rate limit %s",
item.Text, q.laws.RateLimit)
return Admission{Discarded: DiscardRateLimit}, nil
}
@@ -208,7 +204,7 @@ func (q *Queue) Enqueue(item Item) (Admission, error) {
return Admission{ID: item.ID, Truncated: truncated}, nil
}
// firstRunes liefert die ersten n Runen von s (Protokoll-Vorschau).
// firstRunes returns the first n runes of s (log preview).
func firstRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
@@ -217,8 +213,8 @@ func firstRunes(s string, n int) string {
return string(r[:n])
}
// Pending liefert eine Momentaufnahme der wartenden Items in
// Abarbeitungs-Reihenfolge (bestes Item zuerst) — Basis für GET /status.
// Pending returns a snapshot of the waiting items in processing order (best
// item first); basis for GET /status.
func (q *Queue) Pending() []Item {
q.mu.Lock()
defer q.mu.Unlock()
@@ -228,8 +224,8 @@ func (q *Queue) Pending() []Item {
return out
}
// itemBefore meldet, ob a vor b abgearbeitet wird: höhere Klasse zuerst,
// innerhalb derselben Klasse die frühere Enqueue-Reihenfolge.
// itemBefore reports whether a is processed before b: higher class first,
// earlier enqueue order within the same class.
func itemBefore(a, b Item) bool {
if a.rank() != b.rank() {
return a.rank() > b.rank()
@@ -237,7 +233,7 @@ func itemBefore(a, b Item) bool {
return a.seq < b.seq
}
// Current liefert das gerade laufende Item (false: Queue ist idle).
// Current returns the item that is playing (false: queue is idle).
func (q *Queue) Current() (Item, bool) {
q.mu.Lock()
defer q.mu.Unlock()
@@ -247,8 +243,8 @@ func (q *Queue) Current() (Item, bool) {
return *q.current, true
}
// Cancel bricht die laufende Ansage ab und leert die Queue. Rückgabe ist
// die Zahl der abgebrochenen Ansagen (laufende + wartende).
// Cancel aborts the playing announcement and empties the queue. It returns
// the number of cancelled announcements (playing + waiting).
func (q *Queue) Cancel() int {
q.mu.Lock()
defer q.mu.Unlock()
@@ -263,9 +259,9 @@ func (q *Queue) Cancel() int {
return n
}
// Close fährt die Queue herunter: wartende Items werden verworfen, die
// laufende Ansage abgebrochen, der Worker-Loop beendet. Close wartet, bis
// der Loop sicher beendet ist (idempotent).
// Close shuts the queue down: waiting items are dropped, the playing
// announcement is aborted, the worker loop ends. Close waits until the loop
// has ended (idempotent).
func (q *Queue) Close() {
q.mu.Lock()
if q.closed {
@@ -287,10 +283,9 @@ func (q *Queue) Close() {
<-q.done
}
// LawState liefert die /status-Sicht auf die Anti-Spam-Gesetze: das
// konfigurierte Rate-Limit mit dem nächsten freien Slot (Nullzeit = frei
// oder unbegrenzt) und das Ruhezeit-Fenster mit seinem Ende, wenn es
// gerade aktiv ist.
// LawState returns the /status view of the laws: the configured rate limit
// with the next open slot (zero = open or unlimited), the quiet window with
// its end if active, and the length and age limits.
func (q *Queue) LawState() LawState {
now := time.Now()
return LawState{
@@ -306,19 +301,19 @@ func (q *Queue) LawState() LawState {
}
}
// loop ist der Worker: wartet auf fällige Items und spielt sie einzeln ab.
// „Fällig" heißt seit Task 05 auch gesetzkonform: Geparkte schlafen bis zum
// Rate-Limit-Slot (armWakeLocked weckt), low- und Ruhezeit-Verwürfe laufen
// ohne Audio durchs Protokoll, now und high-burst spielen sofort.
// Nach Close (oder wenn Close während einer Ansage zuschlägt) beendet er
// sich, sobald die laufende Ansage zurückgekehrt ist.
// loop is the worker: it waits for due items and plays them one by one.
// "Due" includes the laws: parked items sleep until the rate-limit slot
// (armWakeLocked wakes them), discards pass through the worker without
// audio but with a log line, now and high burst play immediately. After
// Close (also when Close hits during an announcement) it exits as soon as
// the playing announcement has returned.
func (q *Queue) loop() {
defer close(q.done)
for {
q.mu.Lock()
for !q.closed && !q.readyLocked() {
if len(q.items) > 0 {
// Bestes Item parkt auf den Slot: Wecker zum Termin armieren.
// The best item parks for its slot: arm the timer.
q.armWakeLocked(q.limit.Next())
}
q.notEmpty.Wait()
@@ -329,27 +324,28 @@ func (q *Queue) loop() {
}
item, v := q.takeLocked()
if v == verdictWait {
// Kurz ohne Fortschritt geweckt (z. B. verlorenes Signal):
// wieder schlafen legen. Kann nach readyLocked eigentlich nie
// eintreten — sicherheitshalber geparkt statt gedreht.
// Woken without progress (for example a lost signal): go back to
// sleep. Cannot really happen after readyLocked; parked rather
// than spun, just in case.
q.mu.Unlock()
continue
}
if v == verdictPlay {
// Fenster buchen, BEVOR gesprochen wird: Sobald das Item als
// laufend gilt, steht der nächste Slot-Termin fest.
// Book the window BEFORE speaking: as soon as the item counts as
// playing, the next slot time is fixed.
q.limit.Reserve()
ctx, cancel := context.WithCancel(context.Background())
q.current = &item
q.cancelCur = cancel
q.mu.Unlock()
q.logf("queue: ansage %s (%q, Priorität %s, now=%v) startet", item.ID, item.Text, item.Priority, item.Now)
q.logf("queue: announcement %s (%q, priority %s, now=%v) starting", item.ID, item.Text, item.Priority, item.Now)
start := time.Now()
err := q.speak(ctx, item)
dur := time.Since(start)
// Abbruch VOR dem eigenen cancel() ablesen — danach wäre ctx.Err()
// immer gesetzt und jede fertige Ansage hieße „abgebrochen".
// Read the cancellation BEFORE our own cancel(): afterwards
// ctx.Err() is always set and every finished announcement would
// read as cancelled.
cancelled := ctx.Err() != nil
cancel()
@@ -360,37 +356,37 @@ func (q *Queue) loop() {
switch {
case cancelled:
q.logf("queue: ansage %s abgebrochen", item.ID)
q.logf("queue: announcement %s cancelled", item.ID)
case err != nil:
q.logf("queue: ansage %s fehlgeschlagen nach %s: %v", item.ID, dur.Round(time.Millisecond), err)
q.logf("queue: announcement %s failed after %s: %v", item.ID, dur.Round(time.Millisecond), err)
default:
q.logf("queue: ansage %s fertig nach %s", item.ID, dur.Round(time.Millisecond))
q.logf("queue: announcement %s finished after %s", item.ID, dur.Round(time.Millisecond))
}
continue
}
q.mu.Unlock()
switch v {
case verdictDiscardRate:
q.logf("queue: ansage %s (%q, priorität low) verworfen — rate-limit %s",
q.logf("queue: announcement %s (%q, priority low) discarded: rate limit %s",
item.ID, item.Text, q.laws.RateLimit)
case verdictDiscardQuiet:
q.logf("queue: ansage %s (%q) verworfen — ruhezeit %s aktiv (modus discard)",
q.logf("queue: announcement %s (%q) discarded: quiet hours %s active (mode discard)",
item.ID, item.Text, q.laws.Quiet)
case verdictToast:
q.logf("queue: ansage %s (%q) durch toast ersetzt — ruhezeit %s aktiv (modus toast)",
q.logf("queue: announcement %s (%q) replaced by toast: quiet hours %s active (mode toast)",
item.ID, item.Text, q.laws.Quiet)
q.fireToast(item)
case verdictDiscardExpired:
q.logf("queue: ansage %s (%q) verworfen — %s gewartet, älter als max_age %s",
q.logf("queue: announcement %s (%q) discarded: waited %s, older than max_age %s",
item.ID, item.Text, time.Since(item.enqueued).Round(time.Second), q.laws.MaxAge)
}
}
}
// readyLocked meldet, ob der Worker arbeiten kann (mu muss gehalten
// werden): geschlossen immer; sonst nur, wenn das beste Item nicht bloß
// auf seinen Rate-Limit-Slot parkt. Verwürfe zählen als Arbeit — sie
// laufen ohne Audio, aber mit Protokoll durch den Worker.
// readyLocked reports whether the worker has work (mu must be held): always
// when closed; otherwise only when the best item is not merely parked for
// its rate-limit slot. Discards count as work: they pass through the worker
// without audio but with a log line.
func (q *Queue) readyLocked() bool {
if q.closed || len(q.items) == 0 {
return q.closed
@@ -398,10 +394,9 @@ func (q *Queue) readyLocked() bool {
return q.verdictLocked(q.items[q.bestIndexLocked()]) != verdictWait
}
// takeLocked entnimmt das als nächstes fällige Item, wenn es spielbar oder
// entscheidbar ist (mu muss gehalten werden). Bei verdictWait wird NICHT
// entnommen — das Item bleibt geparkt, bis der Slot frei ist (oder ein
// besseres Item vorgezogen wird).
// takeLocked removes the next due item when it is playable or decidable
// (mu must be held). On verdictWait it is NOT removed: the item stays
// parked until the slot opens (or a better item overtakes it).
func (q *Queue) takeLocked() (Item, verdict) {
best := q.bestIndexLocked()
item := q.items[best]
@@ -412,8 +407,7 @@ func (q *Queue) takeLocked() (Item, verdict) {
return item, v
}
// bestIndexLocked liefert den Index des als nächstes fälligen Items
// (mu muss gehalten werden).
// bestIndexLocked returns the index of the next due item (mu must be held).
func (q *Queue) bestIndexLocked() int {
best := 0
for i := 1; i < len(q.items); i++ {
@@ -424,16 +418,16 @@ func (q *Queue) bestIndexLocked() int {
return best
}
// verdictLocked wendet die Gesetze auf ein Item an (mu muss gehalten
// werden — die Lock-Ordnung Queue → RateLimiter bleibt gewahrt).
// verdictLocked applies the laws to an item (mu must be held; the lock order
// queue → RateLimiter is preserved).
func (q *Queue) verdictLocked(item Item) verdict {
return q.laws.decide(item, time.Now(), q.limit.Allow())
}
// armWakeLocked armiert den Slot-Wecker auf until (mu muss gehalten
// werden): Er broadcastet die Cond zum Termin, damit der geparkte Worker
// auch ohne neuen Enqueue aufwacht. Ein bereits laufender Wecker mit
// früherem (oder gleichem) Termin reicht; sonst wird er ersetzt.
// armWakeLocked arms the slot timer for until (mu must be held): it
// broadcasts the cond at that time so the parked worker wakes without a new
// Enqueue. A running timer with an earlier (or equal) time suffices;
// otherwise it is replaced.
func (q *Queue) armWakeLocked(until time.Time) {
if q.wake != nil && !q.wakeAt.After(until) {
return
@@ -454,9 +448,9 @@ func (q *Queue) armWakeLocked(until time.Time) {
})
}
// newID liefert eine kurze, kollisionsarme ID (6 Hex-Zeichen). IDs müssen
// nur pro Daemon-Lauf eindeutig sein (--wait-Polling); der Fallback ist
// praktisch unerreichbar, aber deterministisch sicher.
// newID returns a short, collision-resistant ID (6 hex characters). IDs only
// need to be unique per daemon run (--wait polling); the fallback is
// practically unreachable but deterministic.
func newID() string {
var b [3]byte
if _, err := rand.Read(b[:]); err == nil {
@@ -465,8 +459,8 @@ func newID() string {
return fmt.Sprintf("id%d", time.Now().UnixNano())
}
// logf schreibt eine Protokoll-Zeile, wenn ein Log gesetzt ist. Wird nie
// unter dem Mutex gerufen (Aufrufer achten darauf).
// logf writes a log line when a log is set. Never called under the mutex
// (callers take care of that).
func (q *Queue) logf(format string, args ...any) {
if q.log != nil {
fmt.Fprintf(q.log, format+"\n", args...)
+239 -233
View File
@@ -11,23 +11,22 @@ import (
"time"
)
// gesteuerteSpielerin ist ein testbarer Speaker: Jede Ansage meldet ihren
// Start auf started (Kanal-Puffer: der Worker blockiert nie) und wartet
// dann, bis der Test sie per step freigibt — so ist die Abarbeitung
// vollständig deterministisch steuerbar. Ein abgebrochener ctx beendet das
// Warten (Cancel-/Close-Pfade). Alle Felder werden nur vom Test gelesen;
// started-Reihenfolge == Start-Reihenfolge.
type gesteuerteSpielerin struct {
// controlledSpeaker is a testable Speaker: every announcement reports its
// start on started (buffered channel: the worker never blocks) and then
// waits until the test releases it via step, so processing is fully
// deterministic. A cancelled ctx ends the wait (Cancel/Close paths). All
// fields are read only by the test; started order == start order.
type controlledSpeaker struct {
started chan Item
step chan struct{}
consumed int // von warteBis konsumierte Starts (nur Test-Goroutine)
consumed int // starts consumed by waitUntil (test goroutine only)
}
func neueSpielerin() *gesteuerteSpielerin {
return &gesteuerteSpielerin{started: make(chan Item, 16), step: make(chan struct{})}
func newSpeaker() *controlledSpeaker {
return &controlledSpeaker{started: make(chan Item, 16), step: make(chan struct{})}
}
func (g *gesteuerteSpielerin) speak(ctx context.Context, item Item) error {
func (g *controlledSpeaker) speak(ctx context.Context, item Item) error {
g.started <- item
select {
case <-g.step:
@@ -37,9 +36,9 @@ func (g *gesteuerteSpielerin) speak(ctx context.Context, item Item) error {
}
}
// warteBis wartet, bis insgesamt n Ansagen gestartet sind, und liefert die
// seit dem letzten warteBis-Aufruf neu gestarteten.
func (g *gesteuerteSpielerin) warteBis(t *testing.T, n int) []Item {
// waitUntil waits until n announcements have started in total and returns
// the ones started since the previous waitUntil call.
func (g *controlledSpeaker) waitUntil(t *testing.T, n int) []Item {
t.Helper()
var items []Item
deadline := time.After(2 * time.Second)
@@ -49,29 +48,29 @@ func (g *gesteuerteSpielerin) warteBis(t *testing.T, n int) []Item {
g.consumed++
items = append(items, it)
case <-deadline:
t.Fatalf("nur %d von %d Ansagen gestartet", g.consumed, n)
t.Fatalf("only %d of %d announcements started", g.consumed, n)
}
}
return items
}
// keineWeitere prüft, dass keine weitere Ansage startet (Poll-Fenster).
func (g *gesteuerteSpielerin) keineWeitere(t *testing.T) {
// noFurther checks that no further announcement starts (poll window).
func (g *controlledSpeaker) noFurther(t *testing.T) {
t.Helper()
select {
case it := <-g.started:
g.consumed++
t.Fatalf("unerwartete Ansage %q gestartet", it.Text)
t.Fatalf("unexpected announcement %q started", it.Text)
case <-time.After(150 * time.Millisecond):
}
}
// TestAbarbeitungsReihenfolge: now vor high vor normal vor low, FIFO je
// Level. Der erste Blocker besetzt den Worker; danach werden alle Level
// gleichzeitig eingereiht — die Abarbeitung muss der Klasse folgen.
// (Ohne Gesetze: Null-Laws = unbegrenzt, keine Ruhezeit.)
func TestAbarbeitungsReihenfolge(t *testing.T) {
g := neueSpielerin()
// TestProcessingOrder: now before high before normal before low, FIFO per
// level. The first blocker occupies the worker; then all levels are
// enqueued at once and processing must follow the class.
// (No laws: zero Laws = unlimited, no quiet hours.)
func TestProcessingOrder(t *testing.T) {
g := newSpeaker()
q := New(Laws{}, g.speak, nil)
defer q.Close()
@@ -80,131 +79,130 @@ func TestAbarbeitungsReihenfolge(t *testing.T) {
t.Fatal(err)
}
if adm.ID == "" || adm.Discarded != "" {
t.Fatalf("Enqueue = %+v, want Aufnahme mit ID", adm)
t.Fatalf("Enqueue = %+v, want admission with ID", adm)
}
g.warteBis(t, 1) // Blocker läuft, Worker gebunden.
g.waitUntil(t, 1) // blocker is playing, worker is busy
for _, it := range []Item{
{Text: "niedrig", Priority: PriorityLow},
{Text: "zweit-niedrig", Priority: PriorityLow},
{Text: "mittel", Priority: PriorityNormal},
{Text: "dringend", Priority: PriorityHigh},
{Text: "sofort", Now: true},
{Text: "low", Priority: PriorityLow},
{Text: "second-low", Priority: PriorityLow},
{Text: "normal", Priority: PriorityNormal},
{Text: "urgent", Priority: PriorityHigh},
{Text: "right-now", Now: true},
} {
if _, err := q.Enqueue(it); err != nil {
t.Fatal(err)
}
}
g.keineWeitere(t) // Serialisierung: Worker spielt noch den Blocker.
g.noFurther(t) // serialization: the worker is still on the blocker
// Freigeben: alles Übrige läuft in Klassen-Reihenfolge durch.
// Release: everything else runs through in class order.
close(g.step)
rest := g.warteBis(t, 6)
rest := g.waitUntil(t, 6)
var texts []string
for _, it := range rest {
texts = append(texts, it.Text)
}
want := []string{"sofort", "dringend", "mittel", "niedrig", "zweit-niedrig"}
want := []string{"right-now", "urgent", "normal", "low", "second-low"}
if !slices.Equal(texts, want) {
t.Errorf("Abarbeitung nach dem Blocker = %v, want %v", texts, want)
t.Errorf("processing after the blocker = %v, want %v", texts, want)
}
}
// TestCurrentUndPending: Status-Sicht auf laufendes Item + Warteschlange.
func TestCurrentUndPending(t *testing.T) {
g := neueSpielerin()
// TestCurrentAndPending: status view of the playing item and the queue.
func TestCurrentAndPending(t *testing.T) {
g := newSpeaker()
q := New(Laws{}, g.speak, nil)
defer q.Close()
if _, ok := q.Current(); ok {
t.Fatal("Current vor erster Ansage: want idle")
t.Fatal("Current before the first announcement: want idle")
}
q.Enqueue(Item{Text: "blocker"})
g.warteBis(t, 1)
g.waitUntil(t, 1)
q.Enqueue(Item{Text: "hoch", Priority: PriorityHigh})
q.Enqueue(Item{Text: "niedrig", Priority: PriorityLow})
q.Enqueue(Item{Text: "high", Priority: PriorityHigh})
q.Enqueue(Item{Text: "low", Priority: PriorityLow})
if cur, ok := q.Current(); !ok || cur.Text != "blocker" {
t.Errorf("Current = %+v (ok=%v), will Blocker", cur, ok)
t.Errorf("Current = %+v (ok=%v), want the blocker", cur, ok)
}
var pendingTexts []string
for _, it := range q.Pending() {
pendingTexts = append(pendingTexts, it.Text)
}
if !slices.Equal(pendingTexts, []string{"hoch", "niedrig"}) {
t.Errorf("Pending = %v, want [hoch niedrig]", pendingTexts)
if !slices.Equal(pendingTexts, []string{"high", "low"}) {
t.Errorf("Pending = %v, want [high low]", pendingTexts)
}
close(g.step)
g.warteBis(t, 3)
g.waitUntil(t, 3)
}
// TestCancel: laufende Ansage wird über ctx abgebrochen, Queue geleert,
// Gesamtzahl zurückgemeldet; danach ist die Queue wieder aufnahme- und
// spielbereit.
// TestCancel: the playing announcement is aborted via ctx, the queue
// emptied, the total reported; afterwards the queue admits and plays again.
func TestCancel(t *testing.T) {
g := neueSpielerin()
g := newSpeaker()
q := New(Laws{}, g.speak, nil)
defer q.Close()
q.Enqueue(Item{Text: "blocker"})
g.warteBis(t, 1)
g.waitUntil(t, 1)
q.Enqueue(Item{Text: "wartet-eins"})
q.Enqueue(Item{Text: "wartet-zwei"})
q.Enqueue(Item{Text: "waiting-one"})
q.Enqueue(Item{Text: "waiting-two"})
if n := q.Cancel(); n != 3 {
t.Errorf("Cancel = %d, want 3 (laufende + 2 wartende)", n)
t.Errorf("Cancel = %d, want 3 (playing + 2 waiting)", n)
}
g.keineWeitere(t) // Blocker durch ctx beendet, wartende Items sind weg.
g.noFurther(t) // blocker ended via ctx, waiting items are gone
waitIdle(t, q)
if n := q.Cancel(); n != 0 {
t.Errorf("Cancel auf leerer Queue = %d, want 0", n)
t.Errorf("Cancel on empty queue = %d, want 0", n)
}
// Queue nimmt wieder an und spielt:
adm, err := q.Enqueue(Item{Text: "danach"})
// The queue admits and plays again:
adm, err := q.Enqueue(Item{Text: "afterwards"})
if err != nil {
t.Fatal(err)
}
g.step <- struct{}{} // gibt die wartende Ansage im Speaker frei
gestartet := g.warteBis(t, 2)
if len(gestartet) != 1 || gestartet[0].ID != adm.ID {
t.Errorf("neue Ansage nicht gestartet: %+v", gestartet)
g.step <- struct{}{} // releases the waiting announcement in the speaker
started := g.waitUntil(t, 2)
if len(started) != 1 || started[0].ID != adm.ID {
t.Errorf("new announcement not started: %+v", started)
}
}
// TestClose: wartende Items werden verworfen, die laufende Ansage über den
// abgebrochenen ctx beendet, Close wartet auf den Worker-Exit, danach ist
// Enqueue ein Fehler (und Close idempotent).
// TestClose: waiting items are dropped, the playing announcement ends via
// the cancelled ctx, Close waits for the worker to exit, afterwards Enqueue
// is an error (and Close idempotent).
func TestClose(t *testing.T) {
g := neueSpielerin()
g := newSpeaker()
q := New(Laws{}, g.speak, nil)
q.Enqueue(Item{Text: "blocker"})
g.warteBis(t, 1)
q.Enqueue(Item{Text: "wartet"})
g.waitUntil(t, 1)
q.Enqueue(Item{Text: "waiting"})
q.Close()
g.keineWeitere(t)
g.noFurther(t)
if _, ok := q.Current(); ok {
t.Error("Current nach Close: want idle")
t.Error("Current after Close: want idle")
}
if _, err := q.Enqueue(Item{Text: "zu spät"}); err == nil {
t.Error("Enqueue nach Close: want Fehler")
if _, err := q.Enqueue(Item{Text: "too late"}); err == nil {
t.Error("Enqueue after Close: want error")
}
q.Close() // idempotent
}
// TestEnqueueVergibtIDs: IDs sind kurz und eindeutig.
func TestEnqueueVergibtIDs(t *testing.T) {
g := neueSpielerin()
// TestEnqueueAssignsIDs: IDs are short and unique.
func TestEnqueueAssignsIDs(t *testing.T) {
g := newSpeaker()
q := New(Laws{}, g.speak, nil)
defer q.Close()
@@ -215,15 +213,15 @@ func TestEnqueueVergibtIDs(t *testing.T) {
t.Fatal(err)
}
if len(adm.ID) < 4 || seen[adm.ID] {
t.Errorf("ID %q zu kurz oder doppelt", adm.ID)
t.Errorf("ID %q too short or duplicate", adm.ID)
}
seen[adm.ID] = true
}
}
// waitIdle pollt, bis kein Item mehr läuft (Cancel-/Close-Pfade geben das
// nach dem Worker-Exit synchron frei; das Pollen deckt die Übergangsphase
// ab, ohne auf Timing zu trauen).
// waitIdle polls until no item is playing (Cancel/Close release that
// synchronously after the worker exits; polling covers the transition
// without trusting timing).
func waitIdle(t *testing.T, q *Queue) {
t.Helper()
deadline := time.Now().Add(time.Second)
@@ -232,314 +230,323 @@ func waitIdle(t *testing.T, q *Queue) {
return
}
if time.Now().After(deadline) {
t.Fatal("Queue wird nicht idle")
t.Fatal("queue does not become idle")
}
time.Sleep(5 * time.Millisecond)
}
}
// TestMuteStore: Datei-Roundtrip, Fehlend-Heißt-Nicht-Stumm, atomares Set.
// TestMuteStore: file round trip, missing means not muted, atomic Set.
func TestMuteStore(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, MuteFileName)
var s MuteStore
if s.Muted() {
t.Error("MuteStore ohne Pfad: Muted() = true, want false")
t.Error("MuteStore without path: Muted() = true, want false")
}
if err := s.Set(true); err == nil {
t.Error("Set ohne Pfad: want Fehler")
t.Error("Set without path: want error")
}
s = MuteStore{Path: path}
if s.Muted() {
t.Error("ohne Datei: Muted() = true, want false (nie still aus Versehen)")
t.Error("without file: Muted() = true, want false (never silent by accident)")
}
if err := s.Set(true); err != nil {
t.Fatal(err)
}
if !s.Muted() {
t.Error("nach Set(true): Muted() = false")
t.Error("after Set(true): Muted() = false")
}
if b, _ := os.ReadFile(path); string(b) != "true\n" {
t.Errorf("Datei-Inhalt = %q, want %q", b, "true\n")
t.Errorf("file content = %q, want %q", b, "true\n")
}
if err := s.Set(false); err != nil {
t.Fatal(err)
}
if s.Muted() {
t.Error("nach Set(false): Muted() = true")
t.Error("after Set(false): Muted() = true")
}
// Müll in der Datei heißt: nicht stumm (nur „true" ist stumm).
if err := os.WriteFile(path, []byte("vielleicht\n"), 0o644); err != nil {
// Garbage in the file means not muted (only "true" is muted).
if err := os.WriteFile(path, []byte("maybe\n"), 0o644); err != nil {
t.Fatal(err)
}
if s.Muted() {
t.Error("Datei-Inhalt „vielleicht“: Muted() = true, want false")
t.Error("file content \"maybe\": Muted() = true, want false")
}
}
// TestRateLimiter: Struktur-Semantik (seit Task 05 in den Loop verwired).
// TestRateLimiter: the limiter's own semantics.
func TestRateLimiter(t *testing.T) {
// Unbegrenzt (Task-04-Default): Interval 0 erlaubt immer.
// Unlimited: interval 0 always allows.
unlimited := &RateLimiter{}
for i := 0; i < 3; i++ {
if !unlimited.Allow() {
t.Fatal("Interval 0: Allow = false, want true (unbegrenzt)")
t.Fatal("interval 0: Allow = false, want true (unlimited)")
}
unlimited.Reserve()
}
// Begrenzt: nach Reserve ist erst nach Interval wieder Allow.
// Limited: after Reserve, Allow only after the interval.
l := &RateLimiter{Interval: 30 * time.Millisecond}
if !l.Allow() {
t.Fatal("frisches Limit: Allow = false")
t.Fatal("fresh limiter: Allow = false")
}
l.Reserve()
if l.Allow() {
t.Fatal("sofort nach Reserve: Allow = true, want false")
t.Fatal("right after Reserve: Allow = true, want false")
}
if next := l.Next(); time.Now().After(next) {
t.Errorf("Next() = %v liegt in der Vergangenheit", next)
t.Errorf("Next() = %v lies in the past", next)
}
time.Sleep(40 * time.Millisecond)
if !l.Allow() {
t.Fatal("nach Interval: Allow = false, want true")
t.Fatal("after the interval: Allow = false, want true")
}
}
// fensterUm baut ein "HH:MM-HH:MM"-Ruhefenster, das t sicher einschließt
// (eine Minute davor bis eine Minute danach; Minute-Auflösung reicht).
func fensterUm(t time.Time) string {
return t.Add(-time.Minute).Format("15:04") + "-" + t.Add(time.Minute).Format("15:04")
// windowAround builds an "HH:MM-HH:MM" quiet window that safely contains t
// (three minutes before to four minutes after; minute resolution).
func windowAround(t time.Time) string {
return t.Add(-3*time.Minute).Format("15:04") + "-" + t.Add(4*time.Minute).Format("15:04")
}
// warteAuf verifies mit Pollen eine Bedingung (Zeit-Gesetze sind real, der
// gesteuerte Speaker allein sieht sie nicht).
func warteAuf(t *testing.T, timeout time.Duration, cond func() bool, was string) {
// waitForCond polls a condition (time laws are real; the controlled speaker
// alone cannot see them).
func waitForCond(t *testing.T, timeout time.Duration, cond func() bool, what string) {
t.Helper()
deadline := time.Now().Add(timeout)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("%s innerhalb %s nicht eingetreten", was, timeout)
t.Fatalf("%s did not happen within %s", what, timeout)
}
time.Sleep(5 * time.Millisecond)
}
}
// TestRateLimitLowVerworfenNormalParkt: mit kurzem Fenster spielt die
// erste Ansage sofort; low über dem Budget wird schon beim Eintritt
// verworfen (Admission nennt den Grund), normal parkt und spielt nach
// Fenster-Ablauf.
func TestRateLimitLowVerworfenNormalParkt(t *testing.T) {
g := neueSpielerin()
// TestRateLimitLowDiscardedNormalParks: with a short window the first
// announcement plays at once; low over budget is discarded on admission
// (Admission names the reason), normal parks and plays after the window.
func TestRateLimitLowDiscardedNormalParks(t *testing.T) {
g := newSpeaker()
q := New(Laws{RateLimit: 400 * time.Millisecond}, g.speak, nil)
defer q.Close()
if adm, err := q.Enqueue(Item{Text: "erste"}); err != nil || adm.ID == "" || adm.Discarded != "" {
t.Fatalf("erste Enqueue = %+v (%v), want Aufnahme", adm, err)
if adm, err := q.Enqueue(Item{Text: "first"}); err != nil || adm.ID == "" || adm.Discarded != "" {
t.Fatalf("first Enqueue = %+v (%v), want admission", adm, err)
}
g.warteBis(t, 1) // erste läuft — Fenster ist gebucht.
g.waitUntil(t, 1) // first is playing, the window is booked
adm, err := q.Enqueue(Item{Text: "low-eins", Priority: PriorityLow})
adm, err := q.Enqueue(Item{Text: "low-one", Priority: PriorityLow})
if err != nil {
t.Fatal(err)
}
if adm.ID != "" || adm.Discarded != DiscardRateLimit || adm.Toast {
t.Errorf("low über Budget = %+v, want Verwurf %q", adm, DiscardRateLimit)
t.Errorf("low over budget = %+v, want discard %q", adm, DiscardRateLimit)
}
if adm, err = q.Enqueue(Item{Text: "low-zwei", Priority: PriorityLow}); err != nil || adm.Discarded != DiscardRateLimit {
t.Errorf("zweiter low-Verwurf = %+v (%v)", adm, err)
if adm, err = q.Enqueue(Item{Text: "low-two", Priority: PriorityLow}); err != nil || adm.Discarded != DiscardRateLimit {
t.Errorf("second low discard = %+v (%v)", adm, err)
}
adm, err = q.Enqueue(Item{Text: "zweite"})
adm, err = q.Enqueue(Item{Text: "second"})
if err != nil || adm.ID == "" || adm.Discarded != "" {
t.Fatalf("normal = %+v (%v), want Aufnahme (parkt bis zum Slot)", adm, err)
t.Fatalf("normal = %+v (%v), want admission (parks until the slot)", adm, err)
}
// Status-Sicht während der erste läuft: zweite wartet (normal, geparkt).
warteAuf(t, time.Second, func() bool {
// Status view while the first plays: second waits (normal, parked).
waitForCond(t, time.Second, func() bool {
for _, it := range q.Pending() {
if it.Text == "zweite" {
if it.Text == "second" {
return true
}
}
return false
}, "geparkte normale Ansage in Pending")
}, "parked normal announcement in Pending")
close(g.step) // erste beenden — zweite muss weiter parken …
g.keineWeitere(t)
// … und nach Fenster-Ablauf starten (Deadline deckt Jitter locker).
gestartet := g.warteBis(t, 2)
if len(gestartet) != 1 || gestartet[0].Text != "zweite" {
t.Errorf("nach Fenster-Ablauf = %+v, will zweite", gestartet)
close(g.step) // end the first; second must keep parking …
g.noFurther(t)
// … and start after the window elapses (deadline covers jitter easily).
started := g.waitUntil(t, 2)
if len(started) != 1 || started[0].Text != "second" {
t.Errorf("after the window = %+v, want second", started)
}
// low-eins/low-zwei spielen nie: genau 2 Starts insgesamt.
g.keineWeitere(t)
// low-one/low-two never play: exactly 2 starts in total.
g.noFurther(t)
}
// TestHighBurstEinUndAus: mit Burst-Recht startet high sofort nach der
// laufenden Ansage, obwohl das Fenster voll ist; ohne Recht parkt high
// wie normal (kein Verstoß).
func TestHighBurstEinUndAus(t *testing.T) {
g := neueSpielerin()
// TestHighBurstOnAndOff: with burst rights high starts right after the
// playing announcement although the window is full; without rights high
// parks like normal (no violation).
func TestHighBurstOnAndOff(t *testing.T) {
g := newSpeaker()
q := New(Laws{RateLimit: 10 * time.Second, AllowHighBurst: true}, g.speak, nil)
q.Enqueue(Item{Text: "erste"})
g.warteBis(t, 1)
if adm, err := q.Enqueue(Item{Text: "dringend", Priority: PriorityHigh}); err != nil || adm.ID == "" {
t.Fatalf("high mit Burst-Recht = %+v (%v), want Aufnahme", adm, err)
q.Enqueue(Item{Text: "first"})
g.waitUntil(t, 1)
if adm, err := q.Enqueue(Item{Text: "urgent", Priority: PriorityHigh}); err != nil || adm.ID == "" {
t.Fatalf("high with burst rights = %+v (%v), want admission", adm, err)
}
g.step <- struct{}{} // erste beenden
gestartet := g.warteBis(t, 2) // high startet trotz vollem Fenster
if len(gestartet) != 1 || gestartet[0].Text != "dringend" {
t.Errorf("burst = %+v, will dringend sofort", gestartet)
g.step <- struct{}{} // end the first
started := g.waitUntil(t, 2) // high starts despite the full window
if len(started) != 1 || started[0].Text != "urgent" {
t.Errorf("burst = %+v, want urgent right away", started)
}
q.Close()
g2 := neueSpielerin()
g2 := newSpeaker()
q2 := New(Laws{RateLimit: 10 * time.Second, AllowHighBurst: false}, g2.speak, nil)
defer q2.Close()
q2.Enqueue(Item{Text: "erste"})
g2.warteBis(t, 1) // erste läuft — Fenster gebucht
if adm, err := q2.Enqueue(Item{Text: "dringend", Priority: PriorityHigh}); err != nil || adm.ID == "" {
t.Fatalf("high ohne Burst-Recht = %+v (%v), want Aufnahme (parkt)", adm, err)
q2.Enqueue(Item{Text: "first"})
g2.waitUntil(t, 1) // first is playing, window booked
if adm, err := q2.Enqueue(Item{Text: "urgent", Priority: PriorityHigh}); err != nil || adm.ID == "" {
t.Fatalf("high without burst rights = %+v (%v), want admission (parks)", adm, err)
}
g2.step <- struct{}{} // erste beenden
g2.keineWeitere(t) // high wartet den Slot ab — kein Verstoß ohne Recht
g2.step <- struct{}{} // end the first
g2.noFurther(t) // high waits for the slot: no violation without rights
}
// TestNowBypassesRateLimit: now startet trotz vollem Fensters sofort —
// der dokumentierte Ausbruch (er startet trotzdem ein neues Fenster).
// TestNowBypassesRateLimit: now starts at once despite a full window, the
// documented escape (it still books a new window).
func TestNowBypassesRateLimit(t *testing.T) {
g := neueSpielerin()
g := newSpeaker()
q := New(Laws{RateLimit: 10 * time.Second}, g.speak, nil)
defer q.Close()
q.Enqueue(Item{Text: "erste"})
g.warteBis(t, 1) // Fenster gebucht (10 s)
q.Enqueue(Item{Text: "first"})
g.waitUntil(t, 1) // window booked (10 s)
if adm, err := q.Enqueue(Item{Text: "sofort", Now: true}); err != nil || adm.ID == "" || adm.Discarded != "" {
t.Fatalf("now über vollem Fenster = %+v (%v), want Aufnahme", adm, err)
if adm, err := q.Enqueue(Item{Text: "right-now", Now: true}); err != nil || adm.ID == "" || adm.Discarded != "" {
t.Fatalf("now over a full window = %+v (%v), want admission", adm, err)
}
g.step <- struct{}{}
gestartet := g.warteBis(t, 2) // lange vor Fenster-Ablauf (10 s) — sonst Timeout
if len(gestartet) != 1 || gestartet[0].Text != "sofort" {
t.Errorf("now = %+v, will sofort", gestartet)
started := g.waitUntil(t, 2) // long before the window elapses (10 s), else timeout
if len(started) != 1 || started[0].Text != "right-now" {
t.Errorf("now = %+v, want right-now", started)
}
// Der now-Bruch hat ein neues Fenster gebucht: normal parkt jetzt.
if adm, err := q.Enqueue(Item{Text: "danach"}); err != nil || adm.ID == "" {
t.Fatalf("normal nach now = %+v (%v)", adm, err)
// The now break-out booked a new window: normal parks now.
if adm, err := q.Enqueue(Item{Text: "afterwards"}); err != nil || adm.ID == "" {
t.Fatalf("normal after now = %+v (%v)", adm, err)
}
close(g.step)
g.keineWeitere(t) // parkt bis zum Slot — nicht sofort
g.noFurther(t) // parks until the slot, not immediately
}
// TestRuhezeitDiscardUndToast: aktives Fenster verwirft normal und high
// (Burst ist ein Rate-Recht, kein Ruhe-Recht) — toast ersetzt stattdessen;
// now bricht bewusst aus.
func TestRuhezeitDiscardUndToast(t *testing.T) {
fenster := fensterUm(time.Now())
// TestQuietHoursDiscardAndToast: an active window discards normal and high
// (burst is a rate right, not a quiet right); toast replaces instead; now
// deliberately breaks out.
func TestQuietHoursDiscardAndToast(t *testing.T) {
window := windowAround(time.Now())
g := neueSpielerin()
q := New(Laws{Quiet: mustQuiet(t, fenster), QuietMode: QuietModeDiscard}, g.speak, nil)
g := newSpeaker()
q := New(Laws{Quiet: mustQuiet(t, window), QuietMode: QuietModeDiscard}, g.speak, nil)
defer q.Close()
adm, err := q.Enqueue(Item{Text: "normal"})
if err != nil || adm.ID != "" || adm.Discarded != DiscardQuietHours || adm.Toast {
t.Errorf("normal in Ruhezeit = %+v (%v), want stiller Verwurf", adm, err)
t.Errorf("normal in quiet hours = %+v (%v), want silent discard", adm, err)
}
if adm, err = q.Enqueue(Item{Text: "dringend", Priority: PriorityHigh}); err != nil || adm.Discarded != DiscardQuietHours {
t.Errorf("high in Ruhezeit = %+v (%v), want auch Verwurf", adm, err)
if adm, err = q.Enqueue(Item{Text: "urgent", Priority: PriorityHigh}); err != nil || adm.Discarded != DiscardQuietHours {
t.Errorf("high in quiet hours = %+v (%v), want discard too", adm, err)
}
adm, err = q.Enqueue(Item{Text: "sofort", Now: true})
adm, err = q.Enqueue(Item{Text: "right-now", Now: true})
if err != nil || adm.ID == "" || adm.Discarded != "" {
t.Fatalf("now in Ruhezeit = %+v (%v), want Ausbruch", adm, err)
t.Fatalf("now in quiet hours = %+v (%v), want break-out", adm, err)
}
gestartet := g.warteBis(t, 1)
if len(gestartet) != 1 || gestartet[0].Text != "sofort" {
t.Errorf("in Ruhezeit = %+v, will nur sofort", gestartet)
started := g.waitUntil(t, 1)
if len(started) != 1 || started[0].Text != "right-now" {
t.Errorf("in quiet hours = %+v, want only right-now", started)
}
g.keineWeitere(t)
g.noFurther(t)
g2 := neueSpielerin()
q2 := New(Laws{Quiet: mustQuiet(t, fenster), QuietMode: QuietModeToast}, g2.speak, nil)
g2 := newSpeaker()
q2 := New(Laws{Quiet: mustQuiet(t, window), QuietMode: QuietModeToast}, g2.speak, nil)
defer q2.Close()
toasted := make(chan Item, 1)
q2.OnToast(func(it Item) { toasted <- it })
adm, err = q2.Enqueue(Item{Text: "normal"})
if err != nil || adm.Discarded != DiscardQuietHours || !adm.Toast {
t.Errorf("normal in Ruhezeit (toast) = %+v (%v), want Toast-Ersetzung", adm, err)
t.Errorf("normal in quiet hours (toast) = %+v (%v), want toast replacement", adm, err)
}
g2.keineWeitere(t)
select {
case it := <-toasted:
if it.Text != "normal" {
t.Errorf("toast hook got %q", it.Text)
}
case <-time.After(time.Second):
t.Error("toast hook not fired")
}
g2.noFurther(t)
}
// mustQuiet parst ein Ruhefenster oder bricht den Test ab.
func mustQuiet(t *testing.T, fenster string) QuietHours {
// mustQuiet parses a quiet window or fails the test.
func mustQuiet(t *testing.T, window string) QuietHours {
t.Helper()
q, err := ParseQuietHours(fenster)
q, err := ParseQuietHours(window)
if err != nil {
t.Fatal(err)
}
return q
}
// TestLawState: unbegrenzt liefert freien Slot; nach gebuchtem Fenster
// liegt der nächste Slot in der Zukunft; Ruhezeit-Fenster ist sichtbar.
// TestLawState: unlimited yields an open slot; after a booked window the
// next slot lies in the future; the quiet window and limits are visible.
func TestLawState(t *testing.T) {
g := neueSpielerin()
g := newSpeaker()
q := New(Laws{}, g.speak, nil)
st := q.LawState()
if !st.NextSlot.IsZero() || st.RateLimit != 0 {
t.Errorf("unbegrenzt = %+v, want Nullzeit-Slot", st)
t.Errorf("unlimited = %+v, want zero-time slot", st)
}
q.Close()
// Das Ruhezeit-Fenster muss JETZT inaktiv sein, sonst würde die Ansage
// unten getoastet statt gesprochen — ein festes Fenster wäre eine
// Zeitbombe (rot zu bestimmten Tageszeiten). Deshalb dynamisch: eine
// Stunde, die zwei Stunden in der Zukunft beginnt.
// The quiet window must be inactive NOW, otherwise the announcement
// below would be toasted instead of spoken. A fixed window would be a
// time bomb (red at certain hours), hence dynamic: one hour starting two
// hours from now.
window := inactiveQuietWindow(time.Now())
fenster := mustQuiet(t, window)
if fenster.Active(time.Now()) {
t.Fatalf("Testfenster %s ist aktiv — darf nicht sein", window)
quiet := mustQuiet(t, window)
if quiet.Active(time.Now()) {
t.Fatalf("test window %s is active, must not be", window)
}
q2 := New(Laws{RateLimit: time.Hour, AllowHighBurst: true, Quiet: fenster, QuietMode: QuietModeToast}, g.speak, nil)
q2 := New(Laws{RateLimit: time.Hour, AllowHighBurst: true, Quiet: quiet, QuietMode: QuietModeToast, MaxText: 400, MaxAge: 2 * time.Minute}, g.speak, nil)
defer q2.Close()
st2 := q2.LawState()
if st2.RateLimit != time.Hour || !st2.AllowHighBurst {
t.Errorf("LawState = %+v, want 1h-Fenster mit Burst-Recht", st2)
if st2.RateLimit != time.Hour || !st2.AllowHighBurst || st2.MaxText != 400 || st2.MaxAge != 2*time.Minute {
t.Errorf("LawState = %+v, want 1h window with burst rights and limits 400/2m", st2)
}
if st2.Quiet.String() != window || st2.QuietMode != QuietModeToast {
t.Errorf("Ruhezeit in LawState = %q/%q, want %q/toast", st2.Quiet, st2.QuietMode, window)
t.Errorf("quiet hours in LawState = %q/%q, want %q/toast", st2.Quiet, st2.QuietMode, window)
}
if st2.QuietActive || !st2.QuietUntil.IsZero() {
t.Errorf("Ruhezeit inaktiv erwartet, LawState meldet active=%v until=%v", st2.QuietActive, st2.QuietUntil)
t.Errorf("quiet hours expected inactive, LawState reports active=%v until=%v", st2.QuietActive, st2.QuietUntil)
}
q2.Enqueue(Item{Text: "erste"})
g.warteBis(t, 1)
q2.Enqueue(Item{Text: "first"})
g.waitUntil(t, 1)
if next := q2.LawState().NextSlot; !next.After(time.Now()) {
t.Errorf("nach Start = %v, want Slot in der Zukunft", next)
t.Errorf("after start = %v, want slot in the future", next)
}
g.step <- struct{}{}
}
// inactiveQuietWindow liefert ein "HH:MM-HH:MM"-Fenster von einer Stunde,
// das zwei Stunden nach now beginnt — es enthält now garantiert nicht, egal
// zu welcher Tageszeit der Test läuft (auch über Mitternacht hinweg).
// inactiveQuietWindow returns a one-hour "HH:MM-HH:MM" window starting two
// hours after now; it never contains now, whatever the time of day (also
// across midnight).
func inactiveQuietWindow(now time.Time) string {
start := now.Add(2 * time.Hour)
end := now.Add(3 * time.Hour)
return start.Format("15:04") + "-" + end.Format("15:04")
}
// TestInactiveQuietWindow: Das Test-Hilfsfenster darf zu KEINER Tageszeit
// aktiv sein — sonst wäre TestLawState wieder eine Zeitbombe.
// TestInactiveQuietWindow: the helper window must not be active at ANY time
// of day, otherwise TestLawState would be a time bomb again.
func TestInactiveQuietWindow(t *testing.T) {
base := time.Date(2026, 10, 24, 0, 0, 0, 0, time.Local)
for m := 0; m < 24*60; m += 7 {
@@ -550,12 +557,12 @@ func TestInactiveQuietWindow(t *testing.T) {
t.Fatalf("%s: %v", now.Format("15:04"), err)
}
if q.Active(now) {
t.Errorf("um %s ist Fenster %s aktiv", now.Format("15:04"), w)
t.Errorf("at %s the window %s is active", now.Format("15:04"), w)
}
}
}
// syncLog ist ein nebenläufigkeitssicherer Log-Puffer für Worker-Zeilen.
// syncLog is a concurrency-safe log buffer for worker lines.
type syncLog struct {
mu sync.Mutex
b strings.Builder
@@ -573,37 +580,36 @@ func (l *syncLog) String() string {
return l.b.String()
}
// TestProtokollFertigNichtAbgebrochen: Eine normal beendete Ansage wird als
// „fertig" protokolliert, nicht als „abgebrochen" — der Worker darf seinen
// eigenen cancel() nicht als Abbruch deuten. Ein echter Cancel heißt
// dagegen „abgebrochen".
func TestProtokollFertigNichtAbgebrochen(t *testing.T) {
// TestLogFinishedNotCancelled: a normally finished announcement is logged as
// "finished", not "cancelled"; the worker must not read its own cancel() as
// a cancellation. A real Cancel does log "cancelled".
func TestLogFinishedNotCancelled(t *testing.T) {
logb := &syncLog{}
g := neueSpielerin()
g := newSpeaker()
q := New(Laws{}, g.speak, logb)
defer q.Close()
q.Enqueue(Item{Text: "normal zu ende"})
g.warteBis(t, 1)
q.Enqueue(Item{Text: "runs to the end"})
g.waitUntil(t, 1)
g.step <- struct{}{}
waitFor(t, func() bool { return strings.Contains(logb.String(), "fertig nach") })
if strings.Contains(logb.String(), "abgebrochen") {
t.Errorf("fertige Ansage als abgebrochen protokolliert:\n%s", logb.String())
waitFor(t, func() bool { return strings.Contains(logb.String(), "finished after") })
if strings.Contains(logb.String(), "cancelled") {
t.Errorf("finished announcement logged as cancelled:\n%s", logb.String())
}
q.Enqueue(Item{Text: "wird abgebrochen"})
g.warteBis(t, 2)
q.Enqueue(Item{Text: "gets cancelled"})
g.waitUntil(t, 2)
q.Cancel()
waitFor(t, func() bool { return strings.Contains(logb.String(), "abgebrochen") })
waitFor(t, func() bool { return strings.Contains(logb.String(), "cancelled") })
}
// waitFor pollt cond bis zu zwei Sekunden.
// waitFor polls cond for up to two seconds.
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatal("Bedingung nicht binnen 2 s erfüllt")
t.Fatal("condition not met within 2 s")
}
time.Sleep(5 * time.Millisecond)
}
+24 -24
View File
@@ -7,29 +7,29 @@ import (
"time"
)
// Erlaubte Werte für quiet_mode (öffentlicher Config-Wert).
// Allowed values for quiet_mode (public config value).
const (
QuietModeDiscard = "discard"
QuietModeToast = "toast"
)
// clockTime ist eine Tageszeit in Minuten-Auflösung (lokale Zeit, 24 h).
// clockTime is a time of day at minute resolution (local time, 24 h).
type clockTime struct{ hour, minute int }
// minutes liefert Minuten seit Mitternacht.
// minutes returns minutes since midnight.
func (c clockTime) minutes() int { return c.hour*60 + c.minute }
// String rendert die Uhrzeit als "HH:MM".
// String renders the time as "HH:MM".
func (c clockTime) String() string { return fmt.Sprintf("%02d:%02d", c.hour, c.minute) }
// QuietHours ist ein Ruhezeit-Fenster in lokaler Zeit, z. B. "18:00-08:00"
// (überspannt Mitternacht). Der Nullwert (Start == End) ist deaktiviert —
// rund-um-die-Uhr-Stumm ist mute, nicht quiet_hours.
// QuietHours is a quiet window in local time, for example "18:00-08:00"
// (spanning midnight). The zero value (Start == End) is disabled; silence
// around the clock is mute, not quiet hours.
type QuietHours struct {
Start, End clockTime
}
// String rendert das Fenster als "HH:MM-HH:MM" (Nullwert: "").
// String renders the window as "HH:MM-HH:MM" (zero value: "").
func (q QuietHours) String() string {
if q.Start == q.End {
return ""
@@ -37,17 +37,17 @@ func (q QuietHours) String() string {
return q.Start.String() + "-" + q.End.String()
}
// ParseQuietHours parst ein Ruhezeit-Fenster "HH:MM-HH:MM" (lokale Zeit,
// 24 h; Stunden ein- oder zweistellig). Fenster über Mitternacht
// (Start > End) sind erlaubt; Start == End deaktiviert das Fenster. Der
// Leerstring ist das deaktivierte Fenster (kein Fehler).
// ParseQuietHours parses a quiet window "HH:MM-HH:MM" (local time, 24 h;
// hours with one or two digits). Windows across midnight (Start > End) are
// allowed; Start == End disables the window. The empty string is the
// disabled window (no error).
func ParseQuietHours(s string) (QuietHours, error) {
if s == "" {
return QuietHours{}, nil
}
startS, endS, ok := strings.Cut(s, "-")
if !ok {
return QuietHours{}, fmt.Errorf("quiet_hours: %q ist nicht HH:MM-HH:MM", s)
return QuietHours{}, fmt.Errorf("quiet_hours: %q is not HH:MM-HH:MM", s)
}
start, err := parseClock(startS)
if err != nil {
@@ -60,29 +60,29 @@ func ParseQuietHours(s string) (QuietHours, error) {
return QuietHours{Start: start, End: end}, nil
}
// parseClock parst "H[H]:MM" in Uhrzeit-Bestandteile.
// parseClock parses "H[H]:MM" into its parts.
func parseClock(s string) (clockTime, error) {
hourS, minuteS, ok := strings.Cut(s, ":")
if !ok || len(hourS) < 1 || len(hourS) > 2 || len(minuteS) != 2 {
return clockTime{}, fmt.Errorf("%q ist nicht HH:MM", s)
return clockTime{}, fmt.Errorf("%q is not HH:MM", s)
}
hour, err := strconv.Atoi(hourS)
if err != nil {
return clockTime{}, fmt.Errorf("%q ist nicht HH:MM", s)
return clockTime{}, fmt.Errorf("%q is not HH:MM", s)
}
minute, err := strconv.Atoi(minuteS)
if err != nil {
return clockTime{}, fmt.Errorf("%q ist nicht HH:MM", s)
return clockTime{}, fmt.Errorf("%q is not HH:MM", s)
}
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
return clockTime{}, fmt.Errorf("%q ist keine gültige Uhrzeit (24 h)", s)
return clockTime{}, fmt.Errorf("%q is not a valid time of day (24 h)", s)
}
return clockTime{hour: hour, minute: minute}, nil
}
// Active meldet, ob der lokale Zeitpunkt t im Ruhefenster liegt.
// Tagfenster (Start < End) gelten ab Start bis ausschließlich End;
// Nachtfenster (Start > End) überspannen Mitternacht.
// Active reports whether the local time t lies inside the window. Day
// windows (Start < End) run from Start up to but excluding End; night
// windows (Start > End) span midnight.
func (q QuietHours) Active(t time.Time) bool {
if q.Start == q.End {
return false
@@ -95,15 +95,15 @@ func (q QuietHours) Active(t time.Time) bool {
return m >= s || m < e
}
// Until liefert das lokale Ende des gerade aktiven Fensters — Nullzeit,
// wenn gerade kein Ruhefenster aktiv ist. Basis für GET /status.
// Until returns the local end of the currently active window; the zero time
// when no window is active. Basis for GET /status.
func (q QuietHours) Until(t time.Time) time.Time {
if !q.Active(t) {
return time.Time{}
}
end := time.Date(t.Year(), t.Month(), t.Day(), q.End.hour, q.End.minute, 0, 0, t.Location())
if q.Start.minutes() > q.End.minutes() && t.Hour()*60+t.Minute() >= q.Start.minutes() {
end = end.AddDate(0, 0, 1) // Nachtfenster: das Ende ist am Folgetag
end = end.AddDate(0, 0, 1) // night window: the end is tomorrow
}
return end
}
+41 -40
View File
@@ -5,14 +5,14 @@ import (
"time"
)
// lokal baut eine lokale Test-Zeit (Stunde/Minute reichen — Ruhefenster
// arbeiten auf Minuten-Auflösung).
func lokal(hour, minute int) time.Time {
// local builds a local test time (hour and minute suffice; quiet windows
// work at minute resolution).
func local(hour, minute int) time.Time {
return time.Date(2026, 10, 18, hour, minute, 0, 0, time.Local)
}
// TestParseQuietHours: Format HH:MM-HH:MM (überspannt Mitternacht),
// klare Fehler für Müll.
// TestParseQuietHours: format HH:MM-HH:MM (may span midnight), clear errors
// for garbage.
func TestParseQuietHours(t *testing.T) {
cases := []struct {
in string
@@ -23,8 +23,8 @@ func TestParseQuietHours(t *testing.T) {
{in: "18:00-08:00", start: clockTime{18, 0}, end: clockTime{8, 0}},
{in: "9:30-17:45", start: clockTime{9, 30}, end: clockTime{17, 45}},
{in: "22:00-06:00", start: clockTime{22, 0}, end: clockTime{6, 0}},
{in: "08:00-08:00", start: clockTime{8, 0}, end: clockTime{8, 0}}, // deaktiviert, aber gültig
{in: ""}, // Leerstring = deaktiviert, kein Fehler
{in: "08:00-08:00", start: clockTime{8, 0}, end: clockTime{8, 0}}, // disabled but valid
{in: ""}, // empty = disabled, no error
{in: "18:00", bad: true},
{in: "18:00-", bad: true},
{in: "-08:00", bad: true},
@@ -37,12 +37,12 @@ func TestParseQuietHours(t *testing.T) {
q, err := ParseQuietHours(tc.in)
if tc.bad {
if err == nil {
t.Errorf("ParseQuietHours(%q) = %+v, want Fehler", tc.in, q)
t.Errorf("ParseQuietHours(%q) = %+v, want error", tc.in, q)
}
continue
}
if err != nil {
t.Errorf("ParseQuietHours(%q): unerwarteter Fehler %v", tc.in, err)
t.Errorf("ParseQuietHours(%q): unexpected error %v", tc.in, err)
continue
}
if q.Start != tc.start || q.End != tc.end {
@@ -51,69 +51,70 @@ func TestParseQuietHours(t *testing.T) {
}
}
// TestQuietHoursActive: Tagfenster, Nachtfenster über Mitternacht und der
// deaktivierte Nullwert über alle Grenzen (Start inklusiv, End exklusiv).
// TestQuietHoursActive: day window, night window across midnight and the
// disabled zero value across all boundaries (start inclusive, end
// exclusive).
func TestQuietHoursActive(t *testing.T) {
cases := []struct {
fenster string
window string
hour, minute int
want bool
}{
// Tagfenster 09:00-17:00
// day window 09:00-17:00
{"09:00-17:00", 8, 59, false},
{"09:00-17:00", 9, 0, true}, // Start inklusiv
{"09:00-17:00", 9, 0, true}, // start inclusive
{"09:00-17:00", 12, 30, true},
{"09:00-17:00", 16, 59, true},
{"09:00-17:00", 17, 0, false}, // End exklusiv
{"09:00-17:00", 17, 0, false}, // end exclusive
{"09:00-17:00", 23, 0, false},
// Nachtfenster 18:00-08:00 (überspannt Mitternacht)
// night window 18:00-08:00 (spans midnight)
{"18:00-08:00", 17, 59, false},
{"18:00-08:00", 18, 0, true},
{"18:00-08:00", 23, 59, true},
{"18:00-08:00", 0, 0, true}, // über Mitternacht
{"18:00-08:00", 0, 0, true}, // past midnight
{"18:00-08:00", 7, 59, true},
{"18:00-08:00", 8, 0, false}, // End exklusiv
{"18:00-08:00", 8, 0, false}, // end exclusive
{"18:00-08:00", 12, 0, false},
// Zweites Nachtfenster
// another night window
{"22:00-06:00", 3, 0, true},
{"22:00-06:00", 21, 59, false},
// Start == End: deaktiviert
// start == end: disabled
{"08:00-08:00", 8, 0, false},
{"08:00-08:00", 20, 0, false},
}
for _, tc := range cases {
q := mustQuiet(t, tc.fenster)
if got := q.Active(lokal(tc.hour, tc.minute)); got != tc.want {
t.Errorf("%s um %02d:%02d: Active = %v, want %v", tc.fenster, tc.hour, tc.minute, got, tc.want)
q := mustQuiet(t, tc.window)
if got := q.Active(local(tc.hour, tc.minute)); got != tc.want {
t.Errorf("%s at %02d:%02d: Active = %v, want %v", tc.window, tc.hour, tc.minute, got, tc.want)
}
}
}
// TestQuietHoursUntil: Fenster-Ende (heute bzw. morgen bei Nachtfenstern),
// Nullzeit wenn inaktiv.
// TestQuietHoursUntil: window end (today, or tomorrow for night windows),
// zero time when inactive.
func TestQuietHoursUntil(t *testing.T) {
// Tagfenster: Ende heute.
// Day window: ends today.
q := mustQuiet(t, "09:00-17:00")
if want := lokal(17, 0); !q.Until(lokal(12, 0)).Equal(want) {
t.Errorf("Until(12:00) = %v, want %v (heute 17:00)", q.Until(lokal(12, 0)), want)
if want := local(17, 0); !q.Until(local(12, 0)).Equal(want) {
t.Errorf("Until(12:00) = %v, want %v (today 17:00)", q.Until(local(12, 0)), want)
}
if !q.Until(lokal(3, 0)).IsZero() {
t.Error("Until außerhalb des Fensters: want Nullzeit")
if !q.Until(local(3, 0)).IsZero() {
t.Error("Until outside the window: want zero time")
}
// Nachtfenster: ab 18:00 ist das Ende morgen früh.
// Night window: from 18:00 the end is tomorrow morning.
n := mustQuiet(t, "18:00-08:00")
if want := lokal(0, 0).Add(32 * time.Hour); !n.Until(lokal(20, 0)).Equal(want) {
t.Errorf("Until(20:00) = %v, want %v (morgen 08:00)", n.Until(lokal(20, 0)), want)
if want := local(0, 0).Add(32 * time.Hour); !n.Until(local(20, 0)).Equal(want) {
t.Errorf("Until(20:00) = %v, want %v (tomorrow 08:00)", n.Until(local(20, 0)), want)
}
// Nach Mitternacht (00:30) ist das Ende heute 08:00.
if want := lokal(8, 0); !n.Until(lokal(0, 30)).Equal(want) {
t.Errorf("Until(00:30) = %v, want %v (heute 08:00)", n.Until(lokal(0, 30)), want)
// Past midnight (00:30) the end is today 08:00.
if want := local(8, 0); !n.Until(local(0, 30)).Equal(want) {
t.Errorf("Until(00:30) = %v, want %v (today 08:00)", n.Until(local(0, 30)), want)
}
}
// TestQuietHoursStringRoundtrip: String liefert die konfigurierte Form
// zurück und parst identisch; der Nullwert rendert leer.
// TestQuietHoursStringRoundtrip: String returns the configured form and
// parses back identically; the zero value renders empty.
func TestQuietHoursStringRoundtrip(t *testing.T) {
for _, s := range []string{"18:00-08:00", "09:00-17:00", "22:00-06:00"} {
q := mustQuiet(t, s)
@@ -122,10 +123,10 @@ func TestQuietHoursStringRoundtrip(t *testing.T) {
}
q2 := mustQuiet(t, q.String())
if q2 != q {
t.Errorf("Roundtrip von %q: %+v ≠ %+v", s, q2, q)
t.Errorf("round trip of %q: %+v != %+v", s, q2, q)
}
}
if (QuietHours{}).String() != "" {
t.Error("Nullwert-String nicht leer")
t.Error("zero value String is not empty")
}
}
+79 -82
View File
@@ -7,32 +7,32 @@ import (
"unicode"
)
// Verwurfs-Gründe im HTTP-Vertrag von POST /speak
// Discard reasons in the HTTP contract of POST /speak
// (202 {"id":"","discarded":"…"}).
const (
DiscardRateLimit = "rate_limit" // priority=low über dem Fenster-Budget
DiscardQuietHours = "quiet_hours" // Ruhezeit aktiv (discard oder toast)
DiscardExpired = "expired" // länger gewartet als queue.max_age (Ansage wäre veraltet)
DiscardRateLimit = "rate_limit" // priority=low over the window budget
DiscardQuietHours = "quiet_hours" // quiet hours active (discard or toast)
DiscardExpired = "expired" // waited longer than queue.max_age (stale)
)
// RateLimiter ist das Zeitfenster des Daemon-Gesetzes gegen Ansage-Spam:
// Spec-Default ca. 1 Ansage / 30 s (queue.rate_limit), 0 = unbegrenzt.
// Jede gestartete Ansage bucht das Fenster neu (Reserve) — auch eine per
// now oder high-burst ausgebrochene: Das Fenster zählt Abspiel-Starts,
// nicht deren Legitimität.
// RateLimiter is the time window of the daemon's anti-spam law: default
// about one announcement per 30 s (queue.rate_limit), 0 = unlimited. Every
// started announcement books the window anew (Reserve), including ones that
// broke out via now or high burst: the window counts playback starts, not
// their legitimacy.
//
// Lock-Ordnung: RateLimiter.mu ist immer die innere Sperre — Aufrufer
// halten beim Allow/Reserve/Next die Queue-sperre (niemals umgekehrt).
// Lock order: RateLimiter.mu is always the inner lock; callers hold the
// queue lock during Allow/Reserve/Next, never the other way round.
type RateLimiter struct {
// Interval ist der Mindestabstand zwischen zwei Ansage-Starten.
// 0 oder negativ = unbegrenzt.
// Interval is the minimum gap between two announcement starts. 0 or
// negative = unlimited.
Interval time.Duration
mu sync.Mutex
last time.Time // Start der zuletzt per Reserve gebuchten Ansage
last time.Time // start of the announcement last booked via Reserve
}
// Allow meldet, ob sofort eine Ansage starten dürfte.
// Allow reports whether an announcement could start right now.
func (l *RateLimiter) Allow() bool {
if l == nil || l.Interval <= 0 {
return true
@@ -42,10 +42,9 @@ func (l *RateLimiter) Allow() bool {
return time.Since(l.last) >= l.Interval
}
// Reserve bucht einen Ansage-Slot (direkt nach der Play-Entscheidung
// aufrufen, vor dem eigentlichen Abspielen — auch fehlgeschlagene Ansagen
// verbrauchen das Fenster: Ein kaputter Provider darf nicht zum Maschinengewehr
// werden).
// Reserve books a slot. Call it right after the play decision, before actual
// playback: failed announcements consume the window too, so a broken
// provider cannot turn into a machine gun.
func (l *RateLimiter) Reserve() {
if l == nil || l.Interval <= 0 {
return
@@ -55,9 +54,9 @@ func (l *RateLimiter) Reserve() {
l.last = time.Now()
}
// Next liefert, wann der nächste Ansage-Slot frei wird — Nullzeit, wenn
// Allow sofort true liefern würde (unbegrenzt oder Fenster abgelaufen).
// Basis für das Parken wartender Items und den Status-Endpoint.
// Next returns when the next slot opens; the zero time when Allow would
// return true right away (unlimited or window elapsed). Basis for parking
// waiting items and for the status endpoint.
func (l *RateLimiter) Next() time.Time {
if l == nil || l.Interval <= 0 {
return time.Time{}
@@ -70,68 +69,65 @@ func (l *RateLimiter) Next() time.Time {
return l.last.Add(l.Interval)
}
// Laws sind die Anti-Spam-Gesetze des Daemons (Task 05): Rate-Limit mit
// verkonfigurierbarem Verstoß-Recht für high, plus Ruhezeiten. Der Nullwert
// erlaubt alles (unbegrenzt, kein Ruhefenster) — der Daemon liefert die
// Gesetze aus der Config; Default siehe internal/config.Defaults.
// Laws are the daemon's anti-spam laws: rate limit with configurable burst
// rights for high, quiet hours, text length cap and maximum queue age. The
// zero value allows everything; the daemon supplies the laws from the
// config (defaults in internal/config.Defaults).
type Laws struct {
// RateLimit ist der Mindestabstand zwischen zwei Ansage-Starten
// (0 = unbegrenzt).
// RateLimit is the minimum gap between two announcement starts
// (0 = unlimited).
RateLimit time.Duration
// AllowHighBurst erlaubt priority=high, das Rate-Limit bewusst zu
// verstoßen (Default true). Ohne Burst-Recht wartet high wie normal.
// Die Ruhezeit gilt davon unberührt auch für high.
// AllowHighBurst lets priority=high deliberately break the rate limit
// (default true). Without burst rights, high waits like normal. Quiet
// hours apply to high regardless.
AllowHighBurst bool
// Quiet ist das Ruhezeit-Fenster (lokale Zeit; Nullwert = deaktiviert).
// Quiet is the quiet window (local time; zero value = disabled).
Quiet QuietHours
// QuietMode bestimmt das Verhalten in der Ruhezeit: "discard" verwirft
// still, "toast" ersetzt Audio durch eine Benachrichtigung — aktuell
// eine Protokoll-Zeile über den Notification-Pfad des Loggers; der
// echte OS-Toast folgt mit der Notification-Anbindung (dokumentierte
// Einschränkung, siehe Task-05-Report).
// QuietMode is the behaviour during quiet hours: "discard" drops
// silently, "toast" replaces audio with a desktop notification (the
// queue fires the OnToast hook; the daemon wires the notification).
QuietMode string
// MaxText ist die Obergrenze der Ansage-Länge in Zeichen (Runen).
// Längere Texte werden beim Eintritt an einer Wortgrenze gekürzt und
// als gekürzt gemeldet — ein Agent, der die Langantwort vorliest,
// kostet so weder Minuten noch API-Budget; der Nutzer hört den Anfang.
// 0 = unbegrenzt.
// MaxText caps announcement length in characters (runes). Longer texts
// are cut at a word boundary on admission and reported as truncated: an
// agent reading out its long answer costs neither minutes nor API
// budget, and the user still hears the beginning. 0 = unlimited.
MaxText int
// MaxAge ist die längste Wartezeit einer Ansage in der Queue. Was
// länger parkt (Rate-Limit, Stau), ist veraltet und wird beim
// Fälligwerden verworfen (DiscardExpired) statt gesprochen. 0 =
// unbegrenzt. now:true wartet nie lange und ist davon ausgenommen.
// MaxAge is the longest an announcement may wait in the queue. Whatever
// parks longer (rate limit, backlog) is stale and gets discarded when it
// comes due (DiscardExpired) instead of spoken. 0 = unlimited. now:true
// never waits long and is exempt.
MaxAge time.Duration
}
// verdict ist die Gesetzes-Entscheidung über ein fälliges Item.
// verdict is the laws' decision about a due item.
type verdict int
const (
verdictPlay verdict = iota // abspielen
verdictWait // parken bis zum freien Slot
verdictDiscardRate // verwerfen: low über dem Budget
verdictDiscardQuiet // verwerfen: Ruhezeit (discard)
verdictToast // Toast statt Audio (Ruhezeit)
verdictDiscardExpired // verwerfen: länger gewartet als MaxAge
verdictPlay verdict = iota // play
verdictWait // park until the slot opens
verdictDiscardRate // discard: low over budget
verdictDiscardQuiet // discard: quiet hours (discard)
verdictToast // toast instead of audio (quiet hours)
verdictDiscardExpired // discard: waited longer than MaxAge
)
// decide wendet die Gesetze auf ein fälliges Item an (rein, ohne Zustand):
// slotFree sagt, ob der Rate-Limit-Slot gerade frei ist. Rangordnung:
// decide applies the laws to a due item (pure, no state): slotFree says
// whether the rate-limit slot is open right now. Precedence:
//
// 1. now:true bricht bewusst aus — überspringt Rate-Limit UND Ruhezeit
// (dokumentierter Ausbruch für Dringendes; startet trotzdem ein neues
// Rate-Fenster).
// 2. Veraltet: Hat das Item länger als MaxAge gewartet, wird es verworfen
// — eine Ansage von vor fünf Minuten ist keine Nachricht mehr.
// 3. Ruhezeit: discard verwirft, toast ersetzt — gilt auch für high,
// denn Burst ist ein Rate-Recht, kein Ruhe-Recht.
// 4. Rate-Limit: freier Slot spielt; high spielt mit Burst-Recht; low
// wird verworfen; normal (und high ohne Recht) parken bis zum Slot.
// 1. now:true deliberately breaks out: it skips rate limit AND quiet hours
// (the documented escape for urgent matters; it still books a new rate
// window).
// 2. Stale: an item that waited longer than MaxAge is discarded; an
// announcement from five minutes ago is no longer news.
// 3. Quiet hours: discard drops, toast replaces; this applies to high too,
// because burst is a rate right, not a quiet right.
// 4. Rate limit: an open slot plays; high plays with burst rights; low is
// discarded; normal (and high without rights) park until the slot.
func (l Laws) decide(item Item, now time.Time, slotFree bool) verdict {
if item.Now {
return verdictPlay
@@ -157,42 +153,43 @@ func (l Laws) decide(item Item, now time.Time, slotFree bool) verdict {
return verdictWait
}
// Admission ist das Ergebnis von Enqueue: Aufgenommene tragen ihre ID,
// verworfene nennen den Grund (die 202-Antwort von POST /speak führt ihn).
// Admission is the result of Enqueue: admitted items carry their ID,
// discarded ones name the reason (the 202 response of POST /speak carries
// it).
type Admission struct {
// ID der aufgenommenen Ansage (leer, wenn verworfen).
// ID of the admitted announcement (empty when discarded).
ID string
// Discarded ist der Verwurfs-Grund (DiscardRateLimit bzw.
// DiscardQuietHours); leer, wenn die Ansage aufgenommen wurde.
// Discarded is the discard reason (DiscardRateLimit or
// DiscardQuietHours); empty when the announcement was admitted.
Discarded string
// Toast meldet, dass die Ansage in der Ruhezeit durch eine
// Benachrichtigung ersetzt wurde (quiet_mode = "toast").
// Toast reports that the announcement was replaced by a notification
// during quiet hours (quiet_mode = "toast").
Toast bool
// Truncated meldet, dass der Text auf MaxText gekürzt wurde (die
// gekürzte Fassung ist aufgenommen).
// Truncated reports that the text was cut to MaxText (the shortened
// version was admitted).
Truncated bool
}
// LawState ist die /status-Sicht auf die Anti-Spam-Gesetze: konfiguriertes
// Rate-Limit mit dem nächsten freien Slot sowie das Ruhezeit-Fenster.
// LawState is the /status view of the laws: the configured rate limit with
// the next open slot, the quiet window, and the length and age limits.
type LawState struct {
RateLimit time.Duration
AllowHighBurst bool
NextSlot time.Time // Nullzeit = Slot frei (oder unbegrenzt)
NextSlot time.Time // zero = slot open (or unlimited)
Quiet QuietHours
QuietActive bool
QuietMode string
QuietUntil time.Time // Nullzeit = Ruhezeit inaktiv
MaxText int // 0 = unbegrenzt
MaxAge time.Duration // 0 = unbegrenzt
QuietUntil time.Time // zero = quiet hours inactive
MaxText int // 0 = unlimited
MaxAge time.Duration // 0 = unlimited
}
// truncateText kürzt s auf höchstens max Runen, bevorzugt an einer
// Wortgrenze (sofern die nicht in der ersten Hälfte liegt), und meldet, ob
// gekürzt wurde. max <= 0 = unbegrenzt.
// truncateText cuts s to at most max runes, preferably at a word boundary
// (unless that lies in the first half), and reports whether it cut.
// max <= 0 = unlimited.
func truncateText(s string, max int) (string, bool) {
if max <= 0 {
return s, false
+38 -38
View File
@@ -1,8 +1,8 @@
// Package speak ist der eine Sprech-Kern von talk: Er setzt Config, Audio-
// Sink und Provider-Kette zu einer Ansage zusammen. Der Daemon ruft ihn aus
// dem Queue-Worker, die CLI nur im Notfall ohne Daemon (direkt, ohne
// Warteschlange) — beide über genau diesen Weg, damit es keine zweite
// Zusammensetzung der Kette gibt, die auseinanderlaufen könnte.
// Package speak is talk's single speech core: it assembles config, audio
// sink and provider chain into an announcement. The daemon calls it from
// the queue worker, the CLI only in the emergency path without a daemon
// (directly, without a queue); both go through exactly this path so there
// is no second assembly of the chain that could drift apart.
package speak
import (
@@ -16,37 +16,37 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/provider"
)
// NoProvider ist der Provider-Name, den ActiveProvider meldet, wenn keine
// Stufe der Kette verfügbar ist (Status-Anzeige).
const NoProvider = "keine"
// NoProvider is the provider name ActiveProvider reports when no tier of
// the chain is available (status display).
const NoProvider = "none"
// Request ist eine Ansage: Text plus optionale Pins. Leere Pins heißen
// „Default": Sprache wird erkannt, die Kette läuft in Config-Reihenfolge,
// die Stimme kommt aus dem Voice-Mapping.
// Request is an announcement: text plus optional pins. Empty pins mean
// "default": the language is detected, the chain runs in config order, the
// voice comes from the voice mapping.
type Request struct {
Text string
Lang string // de | en; leer = langdetect
Provider string // Provider-Pin; leer = Config-Pin bzw. Default-Kette
Voice string // Voice-ID-Pin; leer = Mapping/Defaults
Lang string // de | en; empty = langdetect
Provider string // provider pin; empty = config pin or default chain
Voice string // voice ID pin; empty = mapping/defaults
}
// Engine hält Sink und Konfiguration und baut pro Ansage die Kette (Pin und
// Stimme sind Eigenschaften der einzelnen Anfrage). Sie ist nebenläufig
// nutzbar; der Daemon spricht ohnehin seriell.
// Engine holds sink and configuration and builds the chain per announcement
// (pin and voice are properties of the individual request). It is safe for
// concurrent use; the daemon speaks serially anyway.
type Engine struct {
cfg config.Config
sink audio.Sink
getenv func(string) string
log io.Writer
base *provider.Chain // ungepinnte Kette: Verfügbarkeits-Auflösung für Status
base *provider.Chain // unpinned chain: availability resolution for status
mu sync.Mutex
last string // zuletzt verwendeter Provider (Status im Leerlauf)
last string // most recently used provider (status when idle)
}
// New baut den Sprech-Kern: Sink nach cfg.Audio.Sink, Basiskette nach
// Config. getenv liefert Keys und Endpunkt-Overrides (nil = leer), log
// empfängt Ketten- und Sink-Protokoll (nil = still).
// New builds the speech core: sink per cfg.Audio.Sink, base chain per
// config. getenv supplies keys and endpoint overrides (nil = empty), log
// receives chain and sink output (nil = silent).
func New(cfg config.Config, getenv func(string) string, log io.Writer) (*Engine, error) {
if getenv == nil {
getenv = func(string) string { return "" }
@@ -62,14 +62,14 @@ func New(cfg config.Config, getenv func(string) string, log io.Writer) (*Engine,
return &Engine{cfg: cfg, sink: sink, getenv: getenv, log: log, base: base}, nil
}
// Speak spricht eine Anfrage: Sprache erkennen (falls nicht gepinnt), Kette
// bauen, synthetisieren mit Fallback, über den Sink abspielen. Blockiert
// bis zum Ende der Wiedergabe oder bis ctx abgebrochen wird. Das Outcome
// nennt die Stufe, die wirklich gesprochen hat, und die Zeiten.
// Speak speaks a request: detect the language (unless pinned), build the
// chain, synthesize with fallback, play through the sink. Blocks until
// playback ends or ctx is cancelled. The outcome names the tier that
// actually spoke and the timings.
func (e *Engine) Speak(ctx context.Context, req Request) (provider.Outcome, error) {
lang := req.Lang
if lang == "" {
lang = langdetect.Detect(req.Text)
lang = langdetect.Detect(req.Text, e.cfg.Lang)
}
chain, err := provider.NewChain(chainConfig(e.cfg, req.Provider, req.Voice, e.getenv, e.sink, e.log))
if err != nil {
@@ -82,13 +82,13 @@ func (e *Engine) Speak(ctx context.Context, req Request) (provider.Outcome, erro
return out, err
}
// Chain liefert die ungepinnte Basiskette (Diagnose: Stufen und ihre
// Verfügbarkeit).
// Chain returns the unpinned base chain (diagnostics: tiers and their
// availability).
func (e *Engine) Chain() *provider.Chain { return e.base }
// ActiveProvider löst den „aktiven Provider" für eine Anfrage auf: der Pin,
// sonst die erste verfügbare Stufe der Basiskette (Available ist billig —
// Key-/Binary-Prüfung ohne Netz), sonst NoProvider.
// ActiveProvider resolves the "active provider" for a request: the pin,
// otherwise the first available tier of the base chain (Available is cheap:
// key/binary check without network), otherwise NoProvider.
func (e *Engine) ActiveProvider(pin string) string {
if pin != "" {
return pin
@@ -101,15 +101,15 @@ func (e *Engine) ActiveProvider(pin string) string {
return NoProvider
}
// LastProvider liefert den Provider der zuletzt gestarteten Ansage; leer,
// wenn noch nichts gesprochen wurde.
// LastProvider returns the provider of the most recently started
// announcement; empty if nothing has been spoken yet.
func (e *Engine) LastProvider() string {
e.mu.Lock()
defer e.mu.Unlock()
return e.last
}
// SinkName liefert den Namen des Audio-Sinks (oto | null).
// SinkName returns the name of the audio sink (oto | null).
func (e *Engine) SinkName() string { return e.sink.Name() }
func (e *Engine) setLast(name string) {
@@ -118,9 +118,9 @@ func (e *Engine) setLast(name string) {
e.mu.Unlock()
}
// chainConfig überführt Config + Anfrage-Pins in die ChainConfig: Anfrage-
// Pin schlägt den Config-Pin `provider = "…"`; Endpunkte und das OpenAI-
// Modell kommen aus [providers.<name>].
// chainConfig turns config plus request pins into the ChainConfig: the
// request pin beats the config pin `provider = "..."`; endpoints and the
// OpenAI model come from [providers.<name>].
func chainConfig(cfg config.Config, pin, voice string, getenv func(string) string, player provider.Player, log io.Writer) provider.ChainConfig {
if pin == "" {
pin = cfg.Provider
+8 -8
View File
@@ -1,14 +1,14 @@
#!/usr/bin/env sh
# Baut die Release-Artefakte von talk für alle Zielplattformen — reproduzierbar
# mit nichts als dem Go-Toolchain (kein GoReleaser nötig; so läuft derselbe
# Schritt lokal, in Gitea Actions und in nix identisch).
# Builds the talk release artifacts for all target platforms, reproducibly
# with nothing but the Go toolchain (no GoReleaser needed; this way the same
# step runs identically locally, in Gitea Actions and in nix).
#
# Asset-Vertrag (agent-lib-Muster, Fleet-Rollout pinnt darauf):
# dist/talk_<os>_<arch>[.exe] statisches Binary, CGO_ENABLED=0
# dist/checksums.txt sha256 je Asset (sha256sum-Format)
# Asset contract (agent-lib pattern, the fleet rollout pins to it):
# dist/talk_<os>_<arch>[.exe] static binary, CGO_ENABLED=0
# dist/checksums.txt sha256 per asset (sha256sum format)
#
# Aufruf: scripts/release.sh [VERSION] (Default: git describe, sonst "dev")
# Env: TARGETS="linux/amd64 …" überschreibt die Matrix.
# Usage: scripts/release.sh [VERSION] (default: git describe, else "dev")
# Env: TARGETS="linux/amd64 ..." overrides the matrix.
set -eu
cd "$(dirname "$0")/.."
+8 -8
View File
@@ -1,13 +1,13 @@
# Standing-Order für talk (in die AGENTS.md des Projekts kopieren)
# Standing order for talk (copy into the project's AGENTS.md)
```markdown
## Voice notifications (talk)
Sprich per `talk "<1–2 Sätze>"`, wenn eine Aufgabe länger als ~30 s lief und
fertig ist, wenn du blockiert bist und mich brauchst (dann `talk --now`),
oder wenn etwas fehlgeschlagen ist. Inhalt: Status, nächster Schritt, wofür
du mich brauchst — nie die Antwort selbst. `muted` und `discarded` sind
meine Entscheidung: respektieren, nicht umgehen.
Speak via `talk "<1-2 sentences>"` when a task ran longer than ~30 s and is
done, when you are blocked and need me (then `talk --now`), or when something
failed. Content: status, next step, what you need me for; never the answer
itself. `muted` and `discarded` are my decision: respect them, do not work
around them.
```
Ohne diesen Block (und ohne Aufforderung in der Session) spricht ein Agent
nicht — talk ist striktes Opt-in.
Without this block (and without a request in the session) an agent does not
speak; talk is strict opt-in.
+53 -56
View File
@@ -1,86 +1,83 @@
---
name: talk
description: "Use when the user has asked for voice notifications — explicitly in this session or as a standing order in the project's AGENTS.md — and (1) a long-running task finishes (build, tests, deploy, data job), (2) the agent is blocked and needs the user's decision or approval, (3) something failed that the user should know about now. Triggers: talk, say it, sag Bescheid, melde dich, notify me, let me know when done, voice, Ansage. Strict opt-in: never speak without the user's trigger or standing order."
description: "Use when the user has asked for voice notifications, either explicitly in this session or as a standing order in the project's AGENTS.md, and (1) a long-running task finishes (build, tests, deploy, data job), (2) the agent is blocked and needs the user's decision or approval, or (3) something failed that the user should know about now. Triggers: talk, say it, sag Bescheid, melde dich, notify me, let me know when done, voice, Ansage. Strict opt-in: never speak without the user's trigger or standing order."
compatibility: opencode, claude-code, codex
---
# talk — Voice-Notifications für Agenten
# talk: voice notifications for agents
`talk "Build fertig"` spricht eine kurze Ansage über den lokalen talk-Daemon
(Queue, Rate-Limit, Mute, Ruhezeiten). Ein Binary, startet den Daemon beim
ersten Aufruf selbst. Nichts zu installieren außer `talk` im PATH.
`talk "Build done"` speaks a short announcement through the local talk daemon
(queue, rate limit, mute, quiet hours). One binary; it starts the daemon
itself on the first call. Nothing to install except `talk` in the PATH.
## When to Use — striktes Opt-in
## When to Use: strict opt-in
Sprich **nur**, wenn eines davon gilt:
Speak **only** if one of these holds:
1. Der Nutzer hat es in dieser Session verlangt („sag Bescheid, wenn der
Build durch ist", „melde dich, wenn du mich brauchst"). Das gilt dann
**session-sticky bis Widerruf** — nicht bei jeder Antwort neu fragen.
2. Die AGENTS.md des Projekts enthält eine Standing-Order (siehe
1. The user asked for it in this session ("let me know when the build is
through", "tell me when you need me"). That then applies
**session-sticky until revoked**; do not ask again with every reply.
2. The project's AGENTS.md contains a standing order (see
`AGENTS-snippet.md`).
Dann sprich bei: Abschluss langer Aufgaben (> ~30 s), Blockern, die den
Nutzer brauchen (Entscheidung, Freigabe, Passwort), Fehlern, die sofort
zählen.
Then speak on: completion of long tasks (> ~30 s), blockers that need the
user (decision, approval, password), failures that matter right now.
## When NOT to Use
- Ohne Trigger oder Standing-Order. Nie „von sich aus plappern".
- Für kurze Antworten, Zwischenstände, jede Tool-Ausgabe.
- Um die Langantwort vorzulesen. Der Daemon kürzt bei 400 Zeichen —
aber Ziel sind 1–2 Sätze.
- Wenn `talk` `muted` oder `discarded: quiet_hours` zurückgibt: Das ist
die Entscheidung des Nutzers. Nicht umgehen, nicht `--now` nachschieben.
- Without a trigger or standing order. Never chatter on your own.
- For short replies, intermediate status, or any tool output.
- To read the long answer aloud. The daemon truncates at 400 characters,
but the goal is 1 to 2 sentences.
- When `talk` returns `muted` or `discarded: quiet_hours`: that is the
user's decision. Do not work around it, do not follow up with `--now`.
## Was eine gute Ansage ist
## What makes a good announcement
**Status · nächster Schritt · wofür der Nutzer gebraucht wird.** Nie die
Antwort selbst.
**Status, next step, what the user is needed for.** Never the answer
itself.
| gut | schlecht |
| good | bad |
| --- | --- |
| „Tests grün, PR ist bereit zum Review." | „Ich habe die Tests ausgeführt und alle 247 Tests sind erfolgreich durchgelaufen, außerdem habe ich …" |
| „Deploy fehlgeschlagen, Code 1. Ich brauche deine Entscheidung zur Migration." | „Fehler." |
| „Brauche dein sudo-Passwort im Terminal." | (stumm warten) |
| "Tests green, PR is ready for review." | "I ran the tests and all 247 tests passed successfully, and I also ..." |
| "Deploy failed, code 1. I need your decision on the migration." | "Error." |
| "I need your sudo password in the terminal." | (waiting silently) |
Sprache: in der Sprache des Nutzers schreiben; talk erkennt de/en selbst
(`--lang` erzwingt).
Language: write in the user's language. talk speaks English by default and
German when the configured language is `de` or when detection says so
(`--lang` forces it).
## Usage
```bash
talk "Tests grün, PR bereit zum Review." # non-blocking, Item-ID auf stdout
talk --wait "Deploy fertig." # erst zurückkehren, wenn gesprochen
talk --priority high "Build rot, Code 2." # darf das Rate-Limit verletzen
talk --now "Ich brauche dich: Freigabe im Terminal." # vor die Queue, auch in der Ruhezeit — nur für Blocker
echo "Import fertig" | talk # stdin geht auch
talk wrap -- make test # Befehl ausführen, Erfolg/Misserfolg + Dauer ansagen
talk "Tests green, PR ready for review." # non-blocking, item ID on stdout
talk --wait "Deploy finished." # return only once spoken
talk --priority high "Build red, code 2." # may break the rate limit
talk --now "I need you: approval in the terminal." # jumps the queue, also in quiet hours; blockers only
echo "Import finished" | talk # stdin works too
talk wrap -- make test # run a command, announce success/failure and duration
```
Antworten auf stdout: eine Item-ID (angenommen), `muted`, oder
`discarded: rate_limit|quiet_hours`. Exit 0 in allen drei Fällen — ein
Verwurf ist kein Fehler. Exit ≠ 0 nur bei echten Problemen (kein Text,
ungültige Flags, Config kaputt); dann nicht wiederholen, sondern dem
Nutzer sagen.
Replies on stdout: an item ID (accepted), `muted`, or
`discarded: rate_limit|quiet_hours`. Exit 0 in all three cases; a discard
is not an error. Non-zero exit only on real problems (no text, invalid
flags, broken config); then do not retry, tell the user instead.
## Etikette gegenüber dem Daemon
## Etiquette towards the daemon
- **Rate-Limit** (Default 1 Ansage / 30 s): `normal` parkt, `low` wird
verworfen, `high` darf durch. Nicht alles ist `high`.
- **`--now`** ist für Blocker, die den Nutzer brauchen — nicht für
„fertig". Es bricht aus Ruhezeit und Rate-Limit aus; das ist eine
bewusste Störung.
- **Mute und Ruhezeit** sind Nutzerentscheidungen. Respektieren, nicht
umgehen. Der Daemon erzwingt sie ohnehin.
- Mehrere Dinge gleichzeitig? **Eine** Ansage, die zusammenfasst — nicht
fünf.
- **Rate limit** (default 1 announcement / 30 s): `normal` waits, `low` is
discarded, `high` may pass. Not everything is `high`.
- **`--now`** is for blockers that need the user, not for "done". It breaks
out of quiet hours and the rate limit; that is a deliberate interruption.
- **Mute and quiet hours** are user decisions. Respect them, do not work
around them. The daemon enforces them anyway.
- Several things at once? **One** announcement that summarises, not five.
## Pitfalls
- `talk` nicht gefunden → nicht improvisieren (kein `say`, `espeak`,
`notify-send`). Dem Nutzer sagen, dass talk fehlt.
- Kein Ton beim Nutzer → `talk doctor` schlagen vorschlagen; nicht selbst
an Keys oder Config drehen.
- Keys kommen aus `TALK_ELEVENLABS_KEY` / `TALK_OPENAI_KEY` (Env). Nie in
Dateien schreiben, nie in Ansagen oder Logs ausgeben.
- `talk` not found: do not improvise (no `say`, `espeak`, `notify-send`).
Tell the user that talk is missing.
- No sound on the user's side: suggest `talk doctor`; do not fiddle with
keys or config yourself.
- Keys come from `TALK_ELEVENLABS_KEY` / `TALK_OPENAI_KEY` (env). Never
write them to files, never print them in announcements or logs.
+76 -76
View File
@@ -1,10 +1,10 @@
package test
// Prozessgrenzen-Tests für das Ein-Binary-Modell: talk startet den Daemon
// beim ersten Sprechbefehl selbst (daemon.autostart), parallele Aufrufe
// landen in EINER Queue, `talk daemon start|stop` steuern ihn explizit, und
// ein nicht startbarer Daemon (Port von einem fremden Dienst belegt) führt
// in den Direkt-Pfad statt ins Schweigen.
// Process-boundary tests for the single-binary model: talk starts the daemon
// itself on the first speak command (daemon.autostart), parallel calls land
// in ONE queue, `talk daemon start|stop` control it explicitly, and a daemon
// that cannot start (port occupied by a foreign service) leads to the direct
// path instead of silence.
import (
"context"
@@ -24,10 +24,10 @@ import (
var itemID = regexp.MustCompile(`^[0-9a-f]{6}\n$`)
// autostartEnv ist daemonEnv mit eingeschaltetem Autostart auf einem
// freien Port — und einem Cleanup, das den gespawnten Daemon garantiert
// wieder einsammelt (POST /shutdown; Notbremse: PID-Datei + Kill), damit
// kein Testlauf Hintergrundprozesse hinterlässt.
// autostartEnv is daemonEnv with autostart switched on, on a free port — and
// a cleanup that is guaranteed to collect the spawned daemon again (POST
// /shutdown; emergency brake: PID file + kill), so that no test run leaves
// background processes behind.
func autostartEnv(t *testing.T, root, fakeURL string, extra map[string]string) ([]string, *api.Client) {
t.Helper()
port := freePort(t)
@@ -57,81 +57,81 @@ func autostartEnv(t *testing.T, root, fakeURL string, extra map[string]string) (
return env, client
}
// TestAutostartStartetDaemon (Abnahmekriterium Ein-Binary): ohne laufenden
// Daemon startet `talk say` ihn selbst, meldet das auf stderr, liefert eine
// Item-ID auf stdout, und die Ansage läuft durch Queue und Fake-Provider.
// Danach ist der Daemon da (status), schreibt Log und PID, und `talk daemon
// stop` beendet ihn wieder.
func TestAutostartStartetDaemon(t *testing.T) {
// TestAutostartStartsDaemon (acceptance criterion single binary): without a
// running daemon `talk say` starts it itself, reports that on stderr,
// returns an item ID on stdout, and the announcement runs through the queue
// and the fake provider. Afterwards the daemon is there (status), writes log
// and PID, and `talk daemon stop` ends it again.
func TestAutostartStartsDaemon(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env, client := autostartEnv(t, root, fake.srv.URL, nil)
res := runTalk(t, env, "say", "erster aufruf startet")
res := runTalk(t, env, "say", "first call starts")
if res.code != 0 {
t.Fatalf("talk say: Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say: exit %d, stderr: %s", res.code, res.stderr)
}
if !itemID.MatchString(res.stdout) {
t.Errorf("stdout = %q, want Item-ID (Daemon-Pfad, nicht direkt)", res.stdout)
t.Errorf("stdout = %q, want item ID (daemon path, not direct)", res.stdout)
}
if !strings.Contains(res.stderr, "Daemon gestartet") {
t.Errorf("stderr = %q, will Autostart-Hinweis", res.stderr)
if !strings.Contains(res.stderr, "daemon started") {
t.Errorf("stderr = %q, want the autostart note", res.stderr)
}
if hits := fake.waitCompleted(t, 1); hits[0].Text != "erster aufruf startet" {
t.Errorf("Treffer = %q", hits[0].Text)
if hits := fake.waitCompleted(t, 1); hits[0].Text != "first call starts" {
t.Errorf("hit = %q", hits[0].Text)
}
// Zweiter Aufruf: Daemon läuft schon — kein zweiter Start.
res = runTalk(t, env, "say", "zweiter aufruf")
if res.code != 0 || strings.Contains(res.stderr, "Daemon gestartet") {
t.Errorf("zweiter Aufruf: Exit %d, stderr %q — darf nicht erneut starten", res.code, res.stderr)
// Second call: the daemon is already running — no second start.
res = runTalk(t, env, "say", "second call")
if res.code != 0 || strings.Contains(res.stderr, "daemon started") {
t.Errorf("second call: exit %d, stderr %q — must not start again", res.code, res.stderr)
}
fake.waitCompleted(t, 2)
// Der Daemon ist sichtbar: status, Log- und PID-Datei.
// The daemon is visible: status, log and PID file.
res = runTalk(t, env, "status")
if res.code != 0 || !strings.Contains(res.stdout, "daemon: http://127.0.0.1:") {
t.Errorf("talk status: Exit %d, stdout %q", res.code, res.stdout)
if res.code != 0 || !strings.Contains(res.stdout, "daemon: http://127.0.0.1:") {
t.Errorf("talk status: exit %d, stdout %q", res.code, res.stdout)
}
if _, err := os.Stat(configDir(root) + "/" + daemon.LogFileName); err != nil {
t.Errorf("daemon.log fehlt: %v", err)
t.Errorf("daemon.log missing: %v", err)
}
if pid := daemon.ReadPID(configDir(root)); pid <= 0 {
t.Errorf("daemon.pid fehlt oder leer")
t.Errorf("daemon.pid missing or empty")
}
// stop fährt ihn herunter; danach läuft keiner mehr, PID-Datei weg.
// stop shuts it down; afterwards none is running, the PID file is gone.
res = runTalk(t, env, "daemon", "stop")
if res.code != 0 || res.stdout != "daemon: gestoppt\n" {
t.Fatalf("talk daemon stop: Exit %d, stdout %q, stderr %s", res.code, res.stdout, res.stderr)
if res.code != 0 || res.stdout != "daemon: stopped\n" {
t.Fatalf("talk daemon stop: exit %d, stdout %q, stderr %s", res.code, res.stdout, res.stderr)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if _, err := client.Status(ctx); err == nil {
t.Errorf("Daemon antwortet nach stop noch")
t.Errorf("daemon still answers after stop")
}
if pid := daemon.ReadPID(configDir(root)); pid != 0 {
t.Errorf("daemon.pid nach stop noch da (%d)", pid)
t.Errorf("daemon.pid still there after stop (%d)", pid)
}
// Nochmal stop: idempotent.
// stop once more: idempotent.
res = runTalk(t, env, "daemon", "stop")
if res.code != 0 || !strings.Contains(res.stdout, "läuft nicht") {
t.Errorf("zweites stop: Exit %d, stdout %q", res.code, res.stdout)
if res.code != 0 || !strings.Contains(res.stdout, "not running") {
t.Errorf("second stop: exit %d, stdout %q", res.code, res.stdout)
}
}
// TestAutostartSerialisiertParallele: zwei gleichzeitige talk-Aufrufe ohne
// Daemon — einer startet ihn, der andere findet ihn (oder verliert das
// Port-Rennen und findet ihn trotzdem). Beide Ansagen laufen durch EINE
// Queue: nacheinander, nicht überlappend.
func TestAutostartSerialisiertParallele(t *testing.T) {
// TestAutostartSerializesParallel: two simultaneous talk calls without a
// daemon — one starts it, the other finds it (or loses the port race and
// finds it anyway). Both announcements run through ONE queue: one after
// another, not overlapping.
func TestAutostartSerializesParallel(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 250*time.Millisecond)
env, _ := autostartEnv(t, root, fake.srv.URL, nil)
var wg sync.WaitGroup
results := make([]talkResult, 2)
for i, text := range []string{"eins", "zwei"} {
for i, text := range []string{"one", "two"} {
wg.Add(1)
go func(i int, text string) {
defer wg.Done()
@@ -141,75 +141,75 @@ func TestAutostartSerialisiertParallele(t *testing.T) {
wg.Wait()
for i, res := range results {
if res.code != 0 {
t.Fatalf("talk %d (parallel, autostart): Exit %d, stderr: %s", i, res.code, res.stderr)
t.Fatalf("talk %d (parallel, autostart): exit %d, stderr: %s", i, res.code, res.stderr)
}
if !itemID.MatchString(res.stdout) {
t.Errorf("talk %d stdout = %q, want Item-ID (beide über den Daemon)", i, res.stdout)
t.Errorf("talk %d stdout = %q, want item ID (both via the daemon)", i, res.stdout)
}
}
hits := fake.waitHits(t, 2)
const slack = 25 * time.Millisecond
if hits[1].Start.Before(hits[0].End.Add(-slack)) {
t.Errorf("Ansagen überlappten: Start(2)=%v < Ende(1)=%v — nicht EINE Queue", hits[1].Start, hits[0].End)
t.Errorf("announcements overlapped: start(2)=%v < end(1)=%v — not ONE queue", hits[1].Start, hits[0].End)
}
}
// TestDaemonStartExplizit: `talk daemon start` startet im Hintergrund und
// ist idempotent; der Port steht in der Meldung.
func TestDaemonStartExplizit(t *testing.T) {
// TestDaemonStartExplicit: `talk daemon start` starts in the background and
// is idempotent; the port is in the message.
func TestDaemonStartExplicit(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env, client := autostartEnv(t, root, fake.srv.URL, map[string]string{"TALK_AUTOSTART": "false"})
res := runTalk(t, env, "daemon", "start")
if res.code != 0 || res.stdout != "daemon: gestartet unter "+client.BaseURL+"\n" {
t.Fatalf("talk daemon start: Exit %d, stdout %q, stderr %s", res.code, res.stdout, res.stderr)
if res.code != 0 || res.stdout != "daemon: started at "+client.BaseURL+"\n" {
t.Fatalf("talk daemon start: exit %d, stdout %q, stderr %s", res.code, res.stdout, res.stderr)
}
res = runTalk(t, env, "daemon", "start")
if res.code != 0 || !strings.Contains(res.stdout, "läuft bereits") {
t.Errorf("zweites start: Exit %d, stdout %q", res.code, res.stdout)
if res.code != 0 || !strings.Contains(res.stdout, "already running") {
t.Errorf("second start: exit %d, stdout %q", res.code, res.stdout)
}
// Autostart aus, aber Daemon da: say nutzt ihn.
res = runTalk(t, env, "say", "über explizit gestarteten daemon")
// Autostart off, but the daemon is there: say uses it.
res = runTalk(t, env, "say", "via explicitly started daemon")
if res.code != 0 || !itemID.MatchString(res.stdout) {
t.Errorf("say: Exit %d, stdout %q", res.code, res.stdout)
t.Errorf("say: exit %d, stdout %q", res.code, res.stdout)
}
fake.waitCompleted(t, 1)
}
// TestAutostartScheitertSprichtDirekt: Der Port gehört einem fremden Dienst
// (antwortet nicht wie talk) — der gespawnte Daemon kann nicht binden und
// endet sofort; talk erkennt das am Kind-Ende (kein Timeout-Warten) und
// spricht direkt. Der Benutzer sieht den Grund und den Log-Pfad.
func TestAutostartScheitertSprichtDirekt(t *testing.T) {
// TestAutostartFailsSpeaksDirectly: the port belongs to a foreign service
// (does not answer like talk) — the spawned daemon cannot bind and ends
// immediately; talk notices that from the child's exit (no timeout wait)
// and speaks directly. The user sees the reason and the log path.
func TestAutostartFailsSpeaksDirectly(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
fremd := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(fremd.Close)
port, _ := strconv.Atoi(fremd.URL[strings.LastIndex(fremd.URL, ":")+1:])
t.Cleanup(foreign.Close)
port, _ := strconv.Atoi(foreign.URL[strings.LastIndex(foreign.URL, ":")+1:])
env := daemonEnv(root, port, fake.srv.URL, map[string]string{"TALK_AUTOSTART": "true"})
start := time.Now()
res := runTalk(t, env, "say", "port ist fremd belegt")
res := runTalk(t, env, "say", "port is occupied by a foreign service")
if res.code != 0 {
t.Fatalf("talk say: Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say: exit %d, stderr: %s", res.code, res.stderr)
}
if d := time.Since(start); d > 4*time.Second {
t.Errorf("Dauer %v — Start-Fehlschlag soll am Kind-Ende erkannt werden, nicht per Timeout", d)
t.Errorf("duration %v — the start failure should be detected from the child's exit, not via timeout", d)
}
if !strings.Contains(res.stderr, "Daemon-Start fehlgeschlagen") || !strings.Contains(res.stderr, "spreche direkt") {
t.Errorf("stderr = %q, will Fehlschlag-Grund und Direkt-Hinweis", res.stderr)
if !strings.Contains(res.stderr, "daemon start failed") || !strings.Contains(res.stderr, "speaking directly") {
t.Errorf("stderr = %q, want the failure reason and the direct-path note", res.stderr)
}
if !strings.Contains(res.stderr, daemon.LogFileName) {
t.Errorf("stderr = %q, will Log-Pfad-Hinweis", res.stderr)
t.Errorf("stderr = %q, want the log path hint", res.stderr)
}
if hits := fake.waitHits(t, 1); hits[0].Text != "port ist fremd belegt" {
t.Errorf("Treffer = %q", hits[0].Text)
if hits := fake.waitHits(t, 1); hits[0].Text != "port is occupied by a foreign service" {
t.Errorf("hit = %q", hits[0].Text)
}
logBytes, err := os.ReadFile(configDir(root) + "/" + daemon.LogFileName)
if err != nil || !strings.Contains(string(logBytes), "Port belegen") {
t.Errorf("daemon.log = %q (%v), will Port-belegt-Fehler", logBytes, err)
if err != nil || !strings.Contains(string(logBytes), "bind") {
t.Errorf("daemon.log = %q (%v), want the bind error", logBytes, err)
}
}
+90 -87
View File
@@ -9,9 +9,9 @@ import (
"time"
)
// TestSayNonBlocking (Abnahmekriterium non-blocking): `talk say` kehrt
// deutlich vor Ende der Ansage zurück (Exit 0, Item-ID auf stdout), die
// Ansage läuft im Daemon weiter zu Ende.
// TestSayNonBlocking (acceptance criterion non-blocking): `talk say` returns
// well before the announcement ends (exit 0, item ID on stdout); the
// announcement keeps running to completion in the daemon.
func TestSayNonBlocking(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 600*time.Millisecond)
@@ -19,29 +19,29 @@ func TestSayNonBlocking(t *testing.T) {
env := daemonEnv(root, d.port, fake.srv.URL, nil)
start := time.Now()
res := runTalk(t, env, "say", "langlaufende ansage")
res := runTalk(t, env, "say", "long-running announcement")
elapsed := time.Since(start)
if res.code != 0 {
t.Fatalf("talk say: Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say: exit %d, stderr: %s", res.code, res.stderr)
}
if elapsed >= 500*time.Millisecond {
t.Fatalf("talk say war blockierend (%s; Ansage dauert 600 ms) — Default muss non-blocking sein", elapsed)
t.Fatalf("talk say was blocking (%s; announcement takes 600 ms) — the default must be non-blocking", elapsed)
}
id := strings.TrimSpace(res.stdout)
if id == "" || strings.Contains(id, "\n") {
t.Errorf("stdout = %q, will genau eine Zeile mit der Item-ID", res.stdout)
t.Errorf("stdout = %q, want exactly one line with the item ID", res.stdout)
}
// Die Ansage läuft trotzdem zu Ende (Daemon besitzt sie jetzt).
// The announcement still runs to completion (the daemon owns it now).
hits := fake.waitHits(t, 1)
if hits[0].Text != "langlaufende ansage" {
t.Errorf("Treffer = %q", hits[0].Text)
if hits[0].Text != "long-running announcement" {
t.Errorf("hit = %q", hits[0].Text)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestSayWait: `talk say --wait` kehrt erst nach Ende der Ansage zurück.
// TestSayWait: `talk say --wait` returns only after the announcement ends.
func TestSayWait(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 400*time.Millisecond)
@@ -49,52 +49,54 @@ func TestSayWait(t *testing.T) {
env := daemonEnv(root, d.port, fake.srv.URL, nil)
start := time.Now()
res := runTalk(t, env, "say", "--wait", "warten auf mich")
res := runTalk(t, env, "say", "--wait", "wait for me")
elapsed := time.Since(start)
if res.code != 0 {
t.Fatalf("talk say --wait: Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say --wait: exit %d, stderr: %s", res.code, res.stderr)
}
if elapsed < 350*time.Millisecond {
t.Fatalf("talk say --wait kam zu früh zurück (%s; Ansage dauert 400 ms)", elapsed)
t.Fatalf("talk say --wait returned too early (%s; announcement takes 400 ms)", elapsed)
}
if hits := fake.waitHits(t, 1); hits[0].Text != "warten auf mich" {
t.Errorf("Treffer = %q", hits[0].Text)
if hits := fake.waitHits(t, 1); hits[0].Text != "wait for me" {
t.Errorf("hit = %q", hits[0].Text)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestSayOhneDaemonSprichtDirekt: Daemon tot und Autostart aus → talk
// spricht direkt im eigenen Prozess (hier: gegen den Fake-Provider) und
// sagt das auf stderr — nichts bricht still.
func TestSayOhneDaemonSprichtDirekt(t *testing.T) {
// TestSayWithoutDaemonSpeaksDirectly: daemon dead and autostart off → talk
// speaks directly in its own process (here: against the fake provider) and
// says so on stderr — nothing fails silently.
func TestSayWithoutDaemonSpeaksDirectly(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot
env := daemonEnv(root, 1, fake.srv.URL, nil) // port 1: daemon guaranteed dead
res := runTalk(t, env, "say", "offline ansage")
res := runTalk(t, env, "say", "offline announcement")
if res.code != 0 {
t.Fatalf("talk say (direkt): Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say (direct): exit %d, stderr: %s", res.code, res.stderr)
}
if hits := fake.waitHits(t, 1); hits[0].Text != "offline ansage" {
t.Errorf("Treffer = %q", hits[0].Text)
if hits := fake.waitHits(t, 1); hits[0].Text != "offline announcement" {
t.Errorf("hit = %q", hits[0].Text)
}
if !strings.Contains(res.stderr, "spreche direkt") {
t.Errorf("stderr = %q, will Direkt-Hinweis (nichts bricht still)", res.stderr)
if !strings.Contains(res.stderr, "speaking directly") {
t.Errorf("stderr = %q, want the direct-path note (nothing fails silently)", res.stderr)
}
if strings.Contains(res.stderr, "Daemon gestartet") {
t.Errorf("stderr = %q — Autostart war aus, es darf kein Daemon gestartet werden", res.stderr)
if strings.Contains(res.stderr, "daemon started") {
t.Errorf("stderr = %q — autostart was off, no daemon may be started", res.stderr)
}
}
// TestSayOhneDaemonRespektiertMute: ohne Daemon verwirft der Direkt-Pfad
// Ansagen laut Mute-Datei tonlos (und spielt nach Unmute wieder).
func TestSayOhneDaemonRespektiertMute(t *testing.T) {
// TestSayWithoutDaemonRespectsMute: without a daemon the direct path
// discards announcements silently according to the mute file (and plays
// again after unmute).
func TestSayWithoutDaemonRespectsMute(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env := daemonEnv(root, 1, fake.srv.URL, nil)
// Mute-Datei direkt ins Config-Dir (Zustand „Daemon war mal da").
// Mute file written straight into the config dir (state "a daemon was
// here once").
if err := os.MkdirAll(configDir(root), 0o755); err != nil {
t.Fatal(err)
}
@@ -102,31 +104,32 @@ func TestSayOhneDaemonRespektiertMute(t *testing.T) {
t.Fatal(err)
}
res := runTalk(t, env, "say", "sollte stumm bleiben")
res := runTalk(t, env, "say", "should stay silent")
if res.code != 0 {
t.Fatalf("talk say (mute an): Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say (mute on): exit %d, stderr: %s", res.code, res.stderr)
}
if res.stdout != "muted\n" {
t.Errorf("stdout = %q, want muted", res.stdout)
}
fake.assertNoHits(t, 300*time.Millisecond)
// Unmute (offline) → spielt wieder.
// Unmute (offline) → plays again.
res = runTalk(t, env, "unmute")
if res.code != 0 {
t.Fatalf("talk unmute (offline): Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk unmute (offline): exit %d, stderr: %s", res.code, res.stderr)
}
res = runTalk(t, env, "say", "jetzt wieder")
res = runTalk(t, env, "say", "back again")
if res.code != 0 {
t.Fatalf("talk say (mute aus): Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say (mute off): exit %d, stderr: %s", res.code, res.stderr)
}
if hits := fake.waitHits(t, 1); hits[0].Text != "jetzt wieder" {
t.Errorf("Treffer = %q", hits[0].Text)
if hits := fake.waitHits(t, 1); hits[0].Text != "back again" {
t.Errorf("hit = %q", hits[0].Text)
}
}
// TestCliMuteUnmuteStatusCancel: die Steuer-Befehle als Daemon-Aufrufe mit
// plain Output — mute/unmute als Token, status als Block, cancel mit Zahl.
// TestCliMuteUnmuteStatusCancel: the control commands as daemon calls with
// plain output — mute/unmute as a token, status as a block, cancel with a
// number.
func TestCliMuteUnmuteStatusCancel(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
@@ -138,20 +141,20 @@ func TestCliMuteUnmuteStatusCancel(t *testing.T) {
t.Fatalf("talk mute: %d %q — stderr: %s", res.code, res.stdout, res.stderr)
}
// say bei Mute: verworfen, plain gemeldet, kein Provider-Kontakt.
res = runTalk(t, env, "say", "stille")
// say while muted: discarded, reported plainly, no provider contact.
res = runTalk(t, env, "say", "silence")
if res.code != 0 || res.stdout != "muted\n" {
t.Fatalf("talk say (mute an): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
t.Fatalf("talk say (mute on): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
}
fake.assertNoHits(t, 300*time.Millisecond)
res = runTalk(t, env, "status")
if res.code != 0 {
t.Fatalf("talk status: Exit %d — stderr: %s", res.code, res.stderr)
t.Fatalf("talk status: exit %d — stderr: %s", res.code, res.stderr)
}
for _, want := range []string{"version dev", "queue: 0", "mute: an", "provider: elevenlabs"} {
for _, want := range []string{"version dev", "queue: 0", "mute: on", "provider: elevenlabs"} {
if !strings.Contains(res.stdout, want) {
t.Errorf("talk status stdout = %q, will %q", res.stdout, want)
t.Errorf("talk status stdout = %q, want %q", res.stdout, want)
}
}
@@ -162,18 +165,18 @@ func TestCliMuteUnmuteStatusCancel(t *testing.T) {
res = runTalk(t, env, "cancel")
if res.code != 0 || res.stdout != "cancelled: 0\n" {
t.Fatalf("talk cancel (leer): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
t.Fatalf("talk cancel (empty): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
}
res = runTalk(t, env, "status")
if !strings.Contains(res.stdout, "mute: aus") {
t.Errorf("talk status nach unmute = %q, will mute aus", res.stdout)
if !strings.Contains(res.stdout, "mute: off") {
t.Errorf("talk status after unmute = %q, want mute off", res.stdout)
}
}
// TestCliCancelMitQueue: cancel über die CLI bricht laufende + wartende
// Ansage ab und meldet die Zahl.
func TestCliCancelMitQueue(t *testing.T) {
// TestCliCancelWithQueue: cancel via the CLI aborts the running + the
// waiting announcement and reports the count.
func TestCliCancelWithQueue(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 5*time.Second)
d := startDaemon(t, root, fake.srv.URL, nil)
@@ -181,7 +184,7 @@ func TestCliCancelMitQueue(t *testing.T) {
speak(t, d, `{"text":"blocker"}`)
fake.waitArrived(t, 1)
runTalk(t, env, "say", "wartet auf cancel") // non-blocking enqueue
runTalk(t, env, "say", "waiting for cancel") // non-blocking enqueue
res := runTalk(t, env, "cancel")
if res.code != 0 || res.stdout != "cancelled: 2\n" {
@@ -190,18 +193,18 @@ func TestCliCancelMitQueue(t *testing.T) {
fake.setDelay(50 * time.Millisecond)
waitStatusIdle(t, d, 2*time.Second)
speak(t, d, `{"text":"danach"}`)
speak(t, d, `{"text":"afterwards"}`)
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "danach" {
t.Errorf("Treffer = %q, want danach", hits[0].Text)
if hits[0].Text != "afterwards" {
t.Errorf("hit = %q, want afterwards", hits[0].Text)
}
}
// TestDreiParalleleTalkAufrufe (Abnahmekriterium Task 04): `talkd &` +
// drei parallele `talk`-Aufrufe → drei Ansagen nacheinander. Die Enqueue-
// Reihenfolge der parallelen Prozesse ist offen — geprüft wird, dass alle
// drei abgespielt werden und KEINE zwei überlappen.
func TestDreiParalleleTalkAufrufe(t *testing.T) {
// TestThreeParallelTalkCalls (acceptance criterion Task 04): `talkd &` +
// three parallel `talk` calls → three announcements one after another. The
// enqueue order of the parallel processes is open — what is checked is that
// all three are played and NO two overlap.
func TestThreeParallelTalkCalls(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 200*time.Millisecond)
d := startDaemon(t, root, fake.srv.URL, nil)
@@ -209,7 +212,7 @@ func TestDreiParalleleTalkAufrufe(t *testing.T) {
results := make([]talkResult, 3)
var wg sync.WaitGroup
for i, text := range []string{"erste", "zweite", "dritte"} {
for i, text := range []string{"first", "second", "third"} {
wg.Add(1)
go func(i int, text string) {
defer wg.Done()
@@ -219,7 +222,7 @@ func TestDreiParalleleTalkAufrufe(t *testing.T) {
wg.Wait()
for i, res := range results {
if res.code != 0 {
t.Fatalf("paralleler talk-Aufruf %d: Exit %d — stderr: %s", i, res.code, res.stderr)
t.Fatalf("parallel talk call %d: exit %d — stderr: %s", i, res.code, res.stderr)
}
}
@@ -228,60 +231,60 @@ func TestDreiParalleleTalkAufrufe(t *testing.T) {
for _, h := range hits {
played[h.Text] = true
}
for _, text := range []string{"erste", "zweite", "dritte"} {
for _, text := range []string{"first", "second", "third"} {
if !played[text] {
t.Errorf("Ansage %q fehlt (Treffer: %+v)", text, hits)
t.Errorf("announcement %q missing (hits: %+v)", text, hits)
}
}
const slack = 25 * time.Millisecond
for i := 1; i < len(hits); i++ {
if hits[i].Start.Before(hits[i-1].End.Add(-slack)) {
t.Errorf("Ansagen %d/%d überlappten: Start %v < Ende %v", i-1, i, hits[i].Start, hits[i-1].End)
t.Errorf("announcements %d/%d overlapped: start %v < end %v", i-1, i, hits[i].Start, hits[i-1].End)
}
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestCliSteuerbefehleOhneDaemon: status/cancel ohne Daemon sind klare
// Fehler (kein Stillversagen) — say spricht dagegen direkt.
func TestCliSteuerbefehleOhneDaemon(t *testing.T) {
// TestCliControlCommandsWithoutDaemon: status/cancel without a daemon are
// clear errors (no silent failure) — say, by contrast, speaks directly.
func TestCliControlCommandsWithoutDaemon(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env := daemonEnv(root, 1, fake.srv.URL, nil)
if res := runTalk(t, env, "status"); res.code != 1 || !strings.Contains(res.stderr, "kein Daemon") || !strings.Contains(res.stdout, "läuft nicht") {
t.Errorf("talk status ohne Daemon: %d stdout %q stderr %q", res.code, res.stdout, res.stderr)
if res := runTalk(t, env, "status"); res.code != 1 || !strings.Contains(res.stderr, "no daemon") || !strings.Contains(res.stdout, "not running") {
t.Errorf("talk status without daemon: %d stdout %q stderr %q", res.code, res.stdout, res.stderr)
}
if res := runTalk(t, env, "cancel"); res.code != 1 || !strings.Contains(res.stderr, "nichts zu abbrechen") {
t.Errorf("talk cancel ohne Daemon: %d %q", res.code, res.stderr)
if res := runTalk(t, env, "cancel"); res.code != 1 || !strings.Contains(res.stderr, "nothing to cancel") {
t.Errorf("talk cancel without daemon: %d %q", res.code, res.stderr)
}
}
// TestSayDefaultPfadWieSay: `talk "Text"` nimmt denselben Daemon-Pfad wie
// `talk say "Text"` (Flags vor und nach dem Text).
func TestSayDefaultPfadWieSay(t *testing.T) {
// TestSayDefaultPathLikeSay: `talk "text"` takes the same daemon path as
// `talk say "text"` (flags before and after the text).
func TestSayDefaultPathLikeSay(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
env := daemonEnv(root, d.port, fake.srv.URL, nil)
res := runTalk(t, env, "default aufruf")
res := runTalk(t, env, "default call")
if res.code != 0 {
t.Fatalf("talk \"Text\": Exit %d — stderr: %s", res.code, res.stderr)
t.Fatalf("talk \"text\": exit %d — stderr: %s", res.code, res.stderr)
}
if id := strings.TrimSpace(res.stdout); id == "" {
t.Errorf("stdout = %q, will Item-ID", res.stdout)
t.Errorf("stdout = %q, want item ID", res.stdout)
}
// Flag nach dem Text (parseInterspersed) auf dem Daemon-Pfad.
res = runTalk(t, env, "noch ein text", "--lang=en")
// Flag after the text (parseInterspersed) on the daemon path.
res = runTalk(t, env, "another text", "--lang=en")
if res.code != 0 {
t.Fatalf("talk Text --lang=en: Exit %d — stderr: %s", res.code, res.stderr)
t.Fatalf("talk text --lang=en: exit %d — stderr: %s", res.code, res.stderr)
}
hits := fake.waitHits(t, 2)
if !strings.Contains(hits[1].Text, "noch ein text") {
t.Errorf("zweiter Treffer = %q", hits[1].Text)
if !strings.Contains(hits[1].Text, "another text") {
t.Errorf("second hit = %q", hits[1].Text)
}
waitStatusIdle(t, d, 3*time.Second)
}
+79 -80
View File
@@ -1,13 +1,13 @@
package test
// Prozessgrenzen-Tests für die CORS-Tür (Task 06): OPTIONS-Preflight und
// echter POST auf /speak mit Headern aus der Config-Liste
// TALK_ALLOWED_ORIGINS — beobachtet als Black Box über rohe HTTP-Requests.
// Bindend: exakte Origins; "*" nur explizit; leere Liste = gar keine
// CORS-Header; CORS nur auf /speak. Die Tür ist SERVERSEITIG zu: Jede
// Anfrage mit fremdem Origin-Header außerhalb des erlaubten Wegs (OPTIONS
// /speak, POST /speak von gelisteter Origin) wird mit 403 abgelehnt, bevor
// sie Wirkung hat — CORS allein verhindert nur das Lesen der Antwort.
// Process-boundary tests for the CORS door (Task 06): OPTIONS preflight and
// real POST to /speak with headers from the config list
// TALK_ALLOWED_ORIGINS — observed as a black box via raw HTTP requests.
// Binding: exact origins; "*" only explicitly; empty list = no CORS headers
// at all; CORS only on /speak. The door is closed SERVER-SIDE: every request
// with a foreign Origin header outside the permitted path (OPTIONS /speak,
// POST /speak from a listed origin) is rejected with 403 before it has any
// effect — CORS alone only prevents reading the response.
import (
"net/http"
@@ -17,10 +17,10 @@ import (
const azOrigin = "https://chat.az-gruppe.com"
// TestCORSPraeflightGelisted (Abnahmekriterium): Preflight von gelisteter
// Origin → 204 mit Freigabe-Headern; ungeliste Origin → 204 OHNE
// TestCORSPreflightListed (acceptance criterion): preflight from a listed
// origin → 204 with allow headers; unlisted origin → 204 WITHOUT
// Access-Control-Allow-Origin.
func TestCORSPraeflightGelisted(t *testing.T) {
func TestCORSPreflightListed(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
@@ -29,7 +29,7 @@ func TestCORSPraeflightGelisted(t *testing.T) {
raw := preflight(t, d, azOrigin)
if raw.status != http.StatusNoContent {
t.Fatalf("Preflight-Status = %d, want 204", raw.status)
t.Fatalf("preflight status = %d, want 204", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != azOrigin {
t.Errorf("ACAO = %q, want %q", got, azOrigin)
@@ -41,23 +41,23 @@ func TestCORSPraeflightGelisted(t *testing.T) {
t.Errorf("Access-Control-Allow-Headers = %q, want %q", headers, "Content-Type")
}
if vary := raw.header.Get("Vary"); vary != "Origin" {
t.Errorf("Vary = %q, want Origin (Cache-Korrektheit)", vary)
t.Errorf("Vary = %q, want Origin (cache correctness)", vary)
}
// Ungelistete Origin: sauberer 204, aber bewusst ohne CORS-Header —
// der Browser verwirft den geplanten POST, der Daemon bleibt erreichbar.
// Unlisted origin: a clean 204, but deliberately without CORS headers —
// the browser drops the planned POST, the daemon stays reachable.
raw = preflight(t, d, "https://evil.example")
if raw.status != http.StatusNoContent {
t.Fatalf("Preflight (ungelisted) Status = %d, want 204", raw.status)
t.Fatalf("preflight (unlisted) status = %d, want 204", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("ACAO für ungeliste Origin = %q, want leer", got)
t.Errorf("ACAO for unlisted origin = %q, want empty", got)
}
}
// TestCORSPostGelisted: Auch der echte (non-preflight) POST trägt ACAO,
// wenn die Anfrage-Origin gelistet ist — sonst nicht.
func TestCORSPostGelisted(t *testing.T) {
// TestCORSPostListed: the real (non-preflight) POST also carries ACAO when
// the request origin is listed — otherwise not.
func TestCORSPostListed(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
@@ -67,41 +67,41 @@ func TestCORSPostGelisted(t *testing.T) {
raw := doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Origin": azOrigin,
"Content-Type": "application/json",
}, `{"text":"browser sagt hallo"}`)
}, `{"text":"browser says hello"}`)
if raw.status != http.StatusAccepted {
t.Fatalf("POST /speak Status = %d, want 202", raw.status)
t.Fatalf("POST /speak status = %d, want 202", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != azOrigin {
t.Errorf("ACAO auf POST = %q, want %q", got, azOrigin)
t.Errorf("ACAO on POST = %q, want %q", got, azOrigin)
}
fake.waitCompleted(t, 1)
// Ungelistete Origin: 403 OHNE Wirkung — der Fake-Provider darf keinen
// zweiten Treffer sehen. (Früher 202 ohne CORS-Header: der Browser
// konnte die Antwort nicht lesen, gesprochen wurde trotzdem.)
// Unlisted origin: 403 WITHOUT effect — the fake provider must not see
// a second hit. (Formerly 202 without CORS headers: the browser could
// not read the response, but it was spoken anyway.)
raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Origin": "https://evil.example",
"Content-Type": "application/json",
}, `{"text":"böser browser"}`)
}, `{"text":"evil browser"}`)
if raw.status != http.StatusForbidden {
t.Fatalf("POST /speak (ungelisted) Status = %d, want 403", raw.status)
t.Fatalf("POST /speak (unlisted) status = %d, want 403", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("ACAO für ungeliste Origin = %q, want leer", got)
t.Errorf("ACAO for unlisted origin = %q, want empty", got)
}
fake.assertNoFurtherArrivals(t, 300*time.Millisecond)
}
// TestCORSLeereListe: Ohne allowed_origins (Default) sendet der Daemon
// GAR KEINE CORS-Header — Tür zu, der Browser blockt.
func TestCORSLeereListe(t *testing.T) {
// TestCORSEmptyList: without allowed_origins (default) the daemon sends NO
// CORS headers at all — door closed, the browser blocks.
func TestCORSEmptyList(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil) // keine Origins konfiguriert
d := startDaemon(t, root, fake.srv.URL, nil) // no origins configured
raw := preflight(t, d, azOrigin)
if raw.status != http.StatusNoContent {
t.Fatalf("Preflight-Status = %d, want 204", raw.status)
t.Fatalf("preflight status = %d, want 204", raw.status)
}
for _, h := range []string{
"Access-Control-Allow-Origin",
@@ -109,31 +109,31 @@ func TestCORSLeereListe(t *testing.T) {
"Access-Control-Allow-Headers",
} {
if got := raw.header.Get(h); got != "" {
t.Errorf("%s = %q bei leerer Liste, want nicht gesetzt", h, got)
t.Errorf("%s = %q with an empty list, want not set", h, got)
}
}
raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Origin": azOrigin,
"Content-Type": "application/json",
}, `{"text":"ohne cors"}`)
}, `{"text":"without cors"}`)
if raw.status != http.StatusForbidden {
t.Fatalf("POST /speak mit Origin bei leerer Liste: Status = %d, want 403", raw.status)
t.Fatalf("POST /speak with Origin and an empty list: status = %d, want 403", raw.status)
}
fake.assertNoHits(t, 300*time.Millisecond)
// Ohne Origin-Header (CLI, curl) bleibt derselbe POST erlaubt.
// Without an Origin header (CLI, curl) the same POST remains allowed.
raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Content-Type": "application/json",
}, `{"text":"ohne origin"}`)
}, `{"text":"without origin"}`)
if raw.status != http.StatusAccepted {
t.Fatalf("POST /speak ohne Origin: Status = %d, want 202", raw.status)
t.Fatalf("POST /speak without Origin: status = %d, want 202", raw.status)
}
fake.waitCompleted(t, 1)
}
// TestCORSWildcard: Der Eintrag "*" erlaubt jede Origin ausdrücklich —
// ACAO ist dann das literale "*".
// TestCORSWildcard: the entry "*" explicitly allows every origin — ACAO is
// then the literal "*".
func TestCORSWildcard(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
@@ -141,21 +141,21 @@ func TestCORSWildcard(t *testing.T) {
"TALK_ALLOWED_ORIGINS": "*",
})
for _, origin := range []string{"https://irgendwo.example", "http://localhost:5173"} {
for _, origin := range []string{"https://somewhere.example", "http://localhost:5173"} {
raw := preflight(t, d, origin)
if raw.status != http.StatusNoContent {
t.Fatalf("Preflight von %s: Status = %d, want 204", origin, raw.status)
t.Fatalf("preflight from %s: status = %d, want 204", origin, raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("ACAO von %s = %q, want *", origin, got)
t.Errorf("ACAO from %s = %q, want *", origin, got)
}
}
}
// TestCORSNurAufSpeak: Kein anderer Endpunkt ist aus dem Browser erreichbar
// — auch nicht von einer gelisteten Origin: OPTIONS und GET auf /status mit
// Origin-Header sind 403 ohne CORS-Header.
func TestCORSNurAufSpeak(t *testing.T) {
// TestCORSOnlyOnSpeak: no other endpoint is reachable from the browser — not
// even from a listed origin: OPTIONS and GET on /status with an Origin
// header are 403 without CORS headers.
func TestCORSOnlyOnSpeak(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
@@ -167,79 +167,78 @@ func TestCORSNurAufSpeak(t *testing.T) {
"Access-Control-Request-Method": http.MethodGet,
}, "")
if raw.status != http.StatusForbidden {
t.Fatalf("OPTIONS /status Status = %d, want 403 (kein Browser-Zugriff)", raw.status)
t.Fatalf("OPTIONS /status status = %d, want 403 (no browser access)", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("OPTIONS /status ACAO = %q, want leer", got)
t.Errorf("OPTIONS /status ACAO = %q, want empty", got)
}
raw = doRequest(t, http.MethodGet, d.baseURL+"/status", map[string]string{
"Origin": azOrigin,
}, "")
if raw.status != http.StatusForbidden {
t.Fatalf("GET /status mit Origin: Status = %d, want 403", raw.status)
t.Fatalf("GET /status with Origin: status = %d, want 403", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("GET /status ACAO = %q, want leer (CORS nur auf /speak)", got)
t.Errorf("GET /status ACAO = %q, want empty (CORS only on /speak)", got)
}
}
// TestBrowserKannNichtSteuern: Der eigentliche Angriffsfall. Eine beliebige
// Webseite kann ohne Preflight „simple requests" absetzen (POST mit
// text/plain-Body, Formular-POST ohne Body) — der Browser blockt nur das
// Lesen der Antwort, nicht das Senden. talkd darf darauf weder sprechen noch
// muten noch abbrechen noch das MCP-Tool ausführen: alles 403, Zustand
// unverändert.
func TestBrowserKannNichtSteuern(t *testing.T) {
// TestBrowserCannotControl: the actual attack case. Any web page can send
// "simple requests" without preflight (POST with a text/plain body, form
// POST without body) — the browser only blocks reading the response, not
// sending. talkd must neither speak nor mute nor cancel nor run the MCP tool
// on these: all 403, state unchanged.
func TestBrowserCannotControl(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_ALLOWED_ORIGINS": azOrigin, // Tür für EINE Origin offen — andere trotzdem zu
"TALK_ALLOWED_ORIGINS": azOrigin, // door open for ONE origin — others still closed
})
evil := "https://evil.example"
// Simple request: POST mit text/plain-Body, kein Preflight.
// Simple request: POST with a text/plain body, no preflight.
raw := doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Origin": evil,
"Content-Type": "text/plain",
}, `{"text":"gekapert","now":true}`)
}, `{"text":"hijacked","now":true}`)
if raw.status != http.StatusForbidden {
t.Fatalf("simple POST /speak von %s: Status = %d, want 403", evil, raw.status)
t.Fatalf("simple POST /speak from %s: status = %d, want 403", evil, raw.status)
}
// Steuer-Endpunkte brauchen keinen Body — ein Formular-POST reicht.
// Control endpoints need no body — a form POST is enough.
for _, path := range []string{"/mute", "/unmute", "/cancel"} {
raw = doRequest(t, http.MethodPost, d.baseURL+path, map[string]string{"Origin": evil}, "")
if raw.status != http.StatusForbidden {
t.Errorf("POST %s von %s: Status = %d, want 403", path, evil, raw.status)
t.Errorf("POST %s from %s: status = %d, want 403", path, evil, raw.status)
}
}
// Auch die gelistete Origin darf NICHT steuern — nur sprechen.
// Even the listed origin must NOT control — only speak.
raw = doRequest(t, http.MethodPost, d.baseURL+"/mute", map[string]string{"Origin": azOrigin}, "")
if raw.status != http.StatusForbidden {
t.Errorf("POST /mute von gelisteter Origin: Status = %d, want 403 (CORS nur auf /speak)", raw.status)
t.Errorf("POST /mute from listed origin: status = %d, want 403 (CORS only on /speak)", raw.status)
}
// MCP aus dem Browser: ebenfalls zu.
// MCP from the browser: closed as well.
raw = doRequest(t, http.MethodPost, d.baseURL+"/mcp", map[string]string{
"Origin": evil,
"Content-Type": "text/plain",
}, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"speak","arguments":{"text":"gekapert"}}}`)
}, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"speak","arguments":{"text":"hijacked"}}}`)
if raw.status != http.StatusForbidden {
t.Errorf("POST /mcp von %s: Status = %d, want 403", evil, raw.status)
t.Errorf("POST /mcp from %s: status = %d, want 403", evil, raw.status)
}
// Nichts davon hatte Wirkung: kein Treffer beim Provider, nicht gemutet.
// None of it had any effect: no hit at the provider, not muted.
fake.assertNoHits(t, 300*time.Millisecond)
if st := getStatus(t, d); st.Muted {
t.Errorf("Daemon ist gemutet — Browser-POST /mute hat gewirkt")
t.Errorf("daemon is muted — browser POST /mute took effect")
}
}
// TestStatusCorsFeld: GET /status trägt die CORS-Origins als cors-Feld —
// gelistet als [], wenn die Tür zu ist (Compat-Anforderung Task 06).
func TestStatusCorsFeld(t *testing.T) {
t.Run("gelistet", func(t *testing.T) {
// TestStatusCorsField: GET /status carries the CORS origins as the cors
// field — listed as [] when the door is closed (compat requirement Task 06).
func TestStatusCorsField(t *testing.T) {
t.Run("listed", func(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
@@ -247,17 +246,17 @@ func TestStatusCorsFeld(t *testing.T) {
})
st := getStatus(t, d)
if len(st.Cors) != 2 || st.Cors[0] != azOrigin || st.Cors[1] != "https://grid.ag" {
t.Errorf("cors = %v, want beide gelisteten Origins", st.Cors)
t.Errorf("cors = %v, want both listed origins", st.Cors)
}
})
t.Run("leer = [] (nicht null)", func(t *testing.T) {
t.Run("empty = [] (not null)", func(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
st := getStatus(t, d)
if st.Cors == nil || len(st.Cors) != 0 {
t.Errorf("cors = %v, want [] bei geschlossener Tür", st.Cors)
t.Errorf("cors = %v, want [] with the door closed", st.Cors)
}
})
}
+12 -12
View File
@@ -1,15 +1,15 @@
package test
// Prozessgrenzen-Tests für die Diagnose-Befehle gegen einen echten Daemon.
// Process-boundary tests for the diagnostic commands against a real daemon.
import (
"strings"
"testing"
)
// TestDoctorMitDaemon: Läuft der Daemon, ist er ✔ mit Version und Log-
// Pfad; voices und test funktionieren als Prozess.
func TestDoctorMitDaemon(t *testing.T) {
// TestDoctorWithDaemon: when the daemon runs it is ✔ with version and log
// path; voices and test work as processes.
func TestDoctorWithDaemon(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
@@ -17,24 +17,24 @@ func TestDoctorMitDaemon(t *testing.T) {
res := runTalk(t, env, "doctor")
if res.code != 0 {
t.Fatalf("talk doctor: Exit %d\n%s\n%s", res.code, res.stdout, res.stderr)
t.Fatalf("talk doctor: exit %d\n%s\n%s", res.code, res.stdout, res.stderr)
}
for _, want := range []string{"✔ Daemon", "läuft unter " + d.baseURL, "daemon.log", "✔ elevenlabs", "Key gesetzt"} {
for _, want := range []string{"✔ Daemon", "running at " + d.baseURL, "daemon.log", "✔ elevenlabs", "key set"} {
if !strings.Contains(res.stdout, want) {
t.Errorf("doctor ohne %q:\n%s", want, res.stdout)
t.Errorf("doctor without %q:\n%s", want, res.stdout)
}
}
res = runTalk(t, env, "doctor", "--probe")
if res.code != 0 || !strings.Contains(res.stdout, "Probe ok") {
t.Errorf("doctor --probe: Exit %d\n%s", res.code, res.stdout)
if res.code != 0 || !strings.Contains(res.stdout, "probe ok") {
t.Errorf("doctor --probe: exit %d\n%s", res.code, res.stdout)
}
fake.waitArrived(t, 1)
if res := runTalk(t, env, "voices"); res.code != 0 || !strings.HasPrefix(res.stdout, "PROVIDER") {
t.Errorf("talk voices: Exit %d, stdout %q", res.code, res.stdout)
t.Errorf("talk voices: exit %d, stdout %q", res.code, res.stdout)
}
if res := runTalk(t, env, "test"); res.code != 0 || !strings.Contains(res.stdout, "provider: elevenlabs") {
t.Errorf("talk test: Exit %d, stdout %q, stderr %q", res.code, res.stdout, res.stderr)
if res := runTalk(t, env, "test"); res.code != 0 || !strings.Contains(res.stdout, "provider: elevenlabs") {
t.Errorf("talk test: exit %d, stdout %q, stderr %q", res.code, res.stdout, res.stderr)
}
}
+101 -100
View File
@@ -1,12 +1,12 @@
// Package test enthält die Prozessgrenzen-Tests von talk 2.0 — die primäre
// Test-Seam laut Spec: talkd und talk laufen als echte Prozesse (gebuildet
// aus diesem Repo), angetrieben über HTTP und die CLI, mit zwei injizierten
// Fakes: einem ElevenLabs-Fake-Endpunkt (httptest-Server mit Audio-Bytes)
// und audio.sink=null. Getestet wird nur extern beobachtbares Verhalten:
// HTTP-Antworten, CLI-Exit-Codes/-Output, Fake-Treffer (Reihenfolge und
// Zeit), Dateien im Config-Dir. Tests binden NIEMALS den Default-Port 7800
// und hängen nie von einem echten Daemon ab — talkd bekommt einen freien
// Port per TALK_PORT, CLI-Prozesse denselben (oder Port 1 für „Daemon tot").
// Package test contains the process-boundary tests of talk 2.0 — the primary
// test seam according to the spec: talkd and talk run as real processes
// (built from this repo), driven via HTTP and the CLI, with two injected
// fakes: an ElevenLabs fake endpoint (httptest server with audio bytes)
// and audio.sink=null. Only externally observable behaviour is tested:
// HTTP responses, CLI exit codes/output, fake hits (order and timing),
// files in the config dir. Tests NEVER bind the default port 7800 and never
// depend on a real daemon — talkd gets a free port via TALK_PORT, CLI
// processes the same one (or port 1 for "daemon dead").
package test
import (
@@ -27,8 +27,8 @@ import (
"time"
)
// Die Binaries werden einmal pro Testlauf gebaut (Build-Cache macht es
// billig) und von allen Tests geteilt.
// The binaries are built once per test run (the build cache makes it cheap)
// and shared by all tests.
var talkBin string
func TestMain(m *testing.M) {
@@ -56,8 +56,8 @@ func TestMain(m *testing.M) {
os.Exit(code)
}
// syncBuffer ist ein nebenläufigkeitssicherer Buffer (Daemon-stderr wird
// gelesen, während der Prozess läuft).
// syncBuffer is a concurrency-safe buffer (daemon stderr is read while the
// process is running).
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
@@ -75,23 +75,23 @@ func (b *syncBuffer) String() string {
return b.buf.String()
}
// fakeHit ist ein beobachteter Synthese-Request am Fake-Provider.
// fakeHit is an observed synthesis request at the fake provider.
type fakeHit struct {
Text string // Text aus dem Request-Body
Start time.Time // Ankunft am Fake
End time.Time // Antwort abgeschickt bzw. Abbruch gesehen
Completed bool // true: Antwort geschrieben; false: Client hat abgebrochen
Text string // text from the request body
Start time.Time // arrival at the fake
End time.Time // response sent or cancellation seen
Completed bool // true: response written; false: client cancelled
}
// fakeProvider ist ein ElevenLabs-Fake (POST /v1/text-to-speech/<voice>):
// Er antwortet mit ein paar Bytes „Audio" und protokolliert jeden Request
// mit Ankunfts- und Antwort-Zeit — daraus sind Reihenfolge (Textfolge) und
// Serialisierung (Ankunft des nächsten nach Antwort des vorigen) ablesbar.
// delay ist zur Laufzeit umschaltbar, um blockierende Synthesen zu
// simulieren (Serialisierung, Priorität, cancel). Ankünfte werden separat
// und sofort protokolliert: Sie zeigen, dass der Daemon mit einer Synthese
// BESCHÄFTIGT ist (Queue deterministisch befüllbar), noch bevor die
// (verzögerte) Antwort als Treffer landet.
// fakeProvider is an ElevenLabs fake (POST /v1/text-to-speech/<voice>): it
// answers with a few bytes of "audio" and logs every request with arrival
// and response time — from which order (text sequence) and serialization
// (arrival of the next after the response of the previous) can be read.
// delay can be switched at runtime to simulate blocking syntheses
// (serialization, priority, cancel). Arrivals are logged separately and
// immediately: they show that the daemon is BUSY with a synthesis (queue
// can be filled deterministically) before the (delayed) response lands as
// a hit.
type fakeProvider struct {
srv *httptest.Server
mu sync.Mutex
@@ -124,7 +124,7 @@ func (f *fakeProvider) serve(w http.ResponseWriter, r *http.Request) {
if d > 0 {
select {
case <-time.After(d):
case <-r.Context().Done(): // Client (Daemon) hat abgebrochen
case <-r.Context().Done(): // client (daemon) cancelled
f.record(fakeHit{Text: body.Text, Start: start, End: time.Now()})
return
}
@@ -153,7 +153,7 @@ func (f *fakeProvider) snapshot() []fakeHit {
return out
}
// completed liefert nur die abgeschlossenen (nicht abgebrochenen) Treffer.
// completed returns only the completed (not cancelled) hits.
func completed(hits []fakeHit) []fakeHit {
var out []fakeHit
for _, h := range hits {
@@ -164,8 +164,8 @@ func completed(hits []fakeHit) []fakeHit {
return out
}
// waitArrived pollt, bis n Synthese-Requests angekommen sind — der Daemon
// ist damit nachweisbar beschäftigt und die Queue deterministisch befüllbar.
// waitArrived polls until n synthesis requests have arrived — the daemon is
// then demonstrably busy and the queue can be filled deterministically.
func (f *fakeProvider) waitArrived(t *testing.T, n int) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
@@ -178,29 +178,30 @@ func (f *fakeProvider) waitArrived(t *testing.T, n int) {
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("nur %d von %d Ankünften am Fake innerhalb 5 s", len(f.snapshot()), n)
t.Fatalf("only %d of %d arrivals at the fake within 5 s", len(f.snapshot()), n)
}
// arrivedCount zählt die angekommten Synthese-Requests (auch abgebrochene)
// — Beobachtungspunkt für „genau N spielen, der Rest parkt/verfällt".
// arrivedCount counts the synthesis requests that arrived (including
// cancelled ones) — observation point for "exactly N play, the rest
// parks/expires".
func (f *fakeProvider) arrivedCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.arrived)
}
// assertNoFurtherArrivals prüft, dass innerhalb der Frist kein weiterer
// Synthese-Request ankommt (geparkte/verworfene Ansagen bleiben stumm).
// assertNoFurtherArrivals checks that no further synthesis request arrives
// within the deadline (parked/discarded announcements stay silent).
func (f *fakeProvider) assertNoFurtherArrivals(t *testing.T, wait time.Duration) {
t.Helper()
n := f.arrivedCount()
time.Sleep(wait)
if got := f.arrivedCount(); got != n {
t.Fatalf("%d zusätzliche Ankünfte (want keine) — Treffer: %+v", got-n, f.snapshot())
t.Fatalf("%d additional arrivals (want none) — hits: %+v", got-n, f.snapshot())
}
}
// waitHits pollt, bis n Requests abgeschlossen wurden (Abbrüche zählen).
// waitHits polls until n requests have finished (cancellations count).
func (f *fakeProvider) waitHits(t *testing.T, n int) []fakeHit {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
@@ -210,12 +211,12 @@ func (f *fakeProvider) waitHits(t *testing.T, n int) []fakeHit {
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("nur %d von %d Fake-Treffern innerhalb 5 s (Treffer: %+v)", len(f.snapshot()), n, f.snapshot())
t.Fatalf("only %d of %d fake hits within 5 s (hits: %+v)", len(f.snapshot()), n, f.snapshot())
return nil
}
// waitCompleted pollt, bis n Requests ERFOLGREICH abgeschlossen wurden
// (abgebrochene Synthesen zählen nicht).
// waitCompleted polls until n requests have finished SUCCESSFULLY
// (cancelled syntheses do not count).
func (f *fakeProvider) waitCompleted(t *testing.T, n int) []fakeHit {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
@@ -225,20 +226,20 @@ func (f *fakeProvider) waitCompleted(t *testing.T, n int) []fakeHit {
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("nur %d von %d abgeschlossenen Treffern innerhalb 5 s (alle: %+v)", len(completed(f.snapshot())), n, f.snapshot())
t.Fatalf("only %d of %d completed hits within 5 s (all: %+v)", len(completed(f.snapshot())), n, f.snapshot())
return nil
}
// assertNoHits prüft, dass innerhalb der Frist kein Request ankam.
// assertNoHits checks that no request arrived within the deadline.
func (f *fakeProvider) assertNoHits(t *testing.T, wait time.Duration) {
t.Helper()
time.Sleep(wait)
if hits := f.snapshot(); len(hits) > 0 {
t.Fatalf("unerwartete Fake-Treffer: %+v", hits)
t.Fatalf("unexpected fake hits: %+v", hits)
}
}
// daemonProc ist ein laufender talkd-Prozess.
// daemonProc is a running talkd process.
type daemonProc struct {
cmd *exec.Cmd
port int
@@ -246,9 +247,9 @@ type daemonProc struct {
baseURL string
}
// startDaemon startet `talk daemon run` als echten Prozess (freier Port per
// TALK_PORT, nie der Default-Port) und wartet, bis GET /status antwortet. Stop per
// t.Cleanup: SIGINT, nach Frist Kill.
// startDaemon starts `talk daemon run` as a real process (free port via
// TALK_PORT, never the default port) and waits until GET /status answers.
// Stopped via t.Cleanup: SIGINT, kill after a deadline.
func startDaemon(t *testing.T, root, fakeURL string, extra map[string]string) *daemonProc {
t.Helper()
port := freePort(t)
@@ -258,7 +259,7 @@ func startDaemon(t *testing.T, root, fakeURL string, extra map[string]string) *d
cmd.Env = env
cmd.Stderr = stderr
if err := cmd.Start(); err != nil {
t.Fatalf("talk daemon run starten: %v", err)
t.Fatalf("starting talk daemon run: %v", err)
}
d := &daemonProc{
cmd: cmd,
@@ -280,12 +281,12 @@ func startDaemon(t *testing.T, root, fakeURL string, extra map[string]string) *d
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("talk daemon antwortet nicht auf %s — stderr:\n%s", d.baseURL+"/status", stderr.String())
t.Fatalf("talk daemon does not answer on %s — stderr:\n%s", d.baseURL+"/status", stderr.String())
return nil
}
// stop fährt den Daemon-Prozess herunter (SIGINT wie ein menschlicher
// Stop; nach 3 s Kill-Fallback) und wartet auf sein Ende.
// stop shuts the daemon process down (SIGINT like a human stop; kill
// fallback after 3 s) and waits for it to end.
func (d *daemonProc) stop(t *testing.T) {
t.Helper()
if d.cmd.Process == nil {
@@ -302,18 +303,18 @@ func (d *daemonProc) stop(t *testing.T) {
}
}
// daemonEnv baut die Prozess-Umgebung für talk-Prozesse (CLI und Daemon):
// geerbtes Environment ohne fremde TALK_*-Variablen (Isolation von Keys/
// Ports der Außenwelt), isoliertes HOME/XDG_CONFIG_HOME (Config-Dir liegt
// damit unter <root>/talk), audio.sink=null und der Fake-Provider als
// ElevenLabs-Endpunkt. port=1 heißt „garantiert toter Daemon" (Verbindung
// wird sofort abgelehnt).
// daemonEnv builds the process environment for talk processes (CLI and
// daemon): the inherited environment without foreign TALK_* variables
// (isolation from keys/ports of the outside world), isolated HOME/
// XDG_CONFIG_HOME (the config dir is thus <root>/talk), audio.sink=null and
// the fake provider as ElevenLabs endpoint. port=1 means "guaranteed dead
// daemon" (connection is refused immediately).
//
// TALK_RATE_LIMIT=0s ist der Basis-Default der Prozessgrenzen-Tests: Die
// Task-04-Semantik-Tests (Serialisierung, Priorität, …) enqueueen dicht
// hintereinander und erwarten sofortiges Durchspielen — das Gesetz
// (Default 30 s) würde sie parken. Die Gesetz-Tests in ratelimit_test.go
// überschreiben den Wert bewusst per extra.
// TALK_RATE_LIMIT=0s is the base default of the process-boundary tests: the
// Task-04 semantics tests (serialization, priority, …) enqueue in quick
// succession and expect immediate playback — the law (default 30 s) would
// park them. The law tests in ratelimit_test.go deliberately override the
// value via extra.
func daemonEnv(root string, port int, fakeURL string, extra map[string]string) []string {
env := make([]string, 0, 32)
for _, kv := range os.Environ() {
@@ -326,12 +327,12 @@ func daemonEnv(root string, port int, fakeURL string, extra map[string]string) [
"HOME": root,
"XDG_CONFIG_HOME": root,
"TALK_AUDIO_SINK": "null",
"TALK_ELEVENLABS_KEY": "prozesstest-key",
"TALK_ELEVENLABS_KEY": "process-test-key",
"TALK_ELEVENLABS_ENDPOINT": fakeURL,
"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/", // Kette soll nie auf echte Fremd-Endpunkte fallen
"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/", // the chain must never fall back to real third-party endpoints
"TALK_PORT": strconv.Itoa(port),
"TALK_RATE_LIMIT": "0s", // Gesetz aus, bis ein Test es anstellt (siehe oben)
"TALK_AUTOSTART": "false", // kein Test darf unbemerkt einen Daemon hinterlassen; autostart_test.go schaltet es bewusst an
"TALK_RATE_LIMIT": "0s", // law off until a test turns it on (see above)
"TALK_AUTOSTART": "false", // no test may leave a daemon behind unnoticed; autostart_test.go turns it on deliberately
}
for k, v := range extra {
set[k] = v
@@ -342,32 +343,32 @@ func daemonEnv(root string, port int, fakeURL string, extra map[string]string) [
return env
}
// freePort besorgt einen freien Loopback-Port (bind-and-close; der Daemon
// bindet ihn sofort danach neu).
// freePort obtains a free loopback port (bind-and-close; the daemon rebinds
// it right afterwards).
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("freien Port finden: %v", err)
t.Fatalf("finding a free port: %v", err)
}
defer ln.Close()
return ln.Addr().(*net.TCPAddr).Port
}
// configDir ist das Config-Verzeichnis unter dem isolierten Root
// (Linux-Pfadlogik: XDG_CONFIG_HOME/<root> → <root>/talk).
// configDir is the config directory under the isolated root (Linux path
// logic: XDG_CONFIG_HOME/<root> → <root>/talk).
func configDir(root string) string {
return filepath.Join(root, "talk")
}
// talkResult ist das Beobachtungsergebnis eines talk-Prozesses.
// talkResult is the observed result of a talk process.
type talkResult struct {
code int
stdout string
stderr string
}
// runTalk führt die talk-CLI als echten Prozess aus.
// runTalk runs the talk CLI as a real process.
func runTalk(t *testing.T, env []string, args ...string) talkResult {
t.Helper()
cmd := exec.Command(talkBin, args...)
@@ -380,12 +381,12 @@ func runTalk(t *testing.T, env []string, args ...string) talkResult {
if ee, ok := err.(*exec.ExitError); ok {
code = ee.ExitCode()
} else if err != nil {
t.Fatalf("talk ausführen: %v (stderr: %s)", err, errBuf.String())
t.Fatalf("running talk: %v (stderr: %s)", err, errBuf.String())
}
return talkResult{code: code, stdout: out.String(), stderr: errBuf.String()}
}
// statusBody ist die dekodierte GET /status-Antwort.
// statusBody is the decoded GET /status response.
type statusBody struct {
Version string `json:"version"`
Queue int `json:"queue"`
@@ -401,8 +402,8 @@ type statusBody struct {
Cors []string `json:"cors"`
}
// getStatus ruft GET /status auf (Deadline: eine Sekunde — der Daemon ist
// lokal und muss antworten, sonst ist das selbst ein Testergebnis).
// getStatus calls GET /status (deadline: one second — the daemon is local
// and must answer, otherwise that is a test result in itself).
func getStatus(t *testing.T, d *daemonProc) statusBody {
t.Helper()
client := &http.Client{Timeout: time.Second}
@@ -412,16 +413,16 @@ func getStatus(t *testing.T, d *daemonProc) statusBody {
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /status: Status %d", resp.StatusCode)
t.Fatalf("GET /status: status %d", resp.StatusCode)
}
var st statusBody
if err := json.NewDecoder(resp.Body).Decode(&st); err != nil {
t.Fatalf("GET /status dekodieren: %v", err)
t.Fatalf("decoding GET /status: %v", err)
}
return st
}
// waitStatusIdle pollt, bis weder ein Item läuft noch wartet.
// waitStatusIdle polls until neither an item is running nor waiting.
func waitStatusIdle(t *testing.T, d *daemonProc, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
@@ -431,13 +432,13 @@ func waitStatusIdle(t *testing.T, d *daemonProc, timeout time.Duration) {
return
}
if time.Now().After(deadline) {
t.Fatalf("Queue wird nicht idle: %+v — stderr:\n%s", st, d.stderr.String())
t.Fatalf("queue does not become idle: %+v — stderr:\n%s", st, d.stderr.String())
}
time.Sleep(25 * time.Millisecond)
}
}
// speak enqueue per rohem HTTP (Prozessgrenze ohne CLI).
// speak enqueues via raw HTTP (process boundary without the CLI).
func speak(t *testing.T, d *daemonProc, body string) (int, map[string]any) {
t.Helper()
resp, err := http.Post(d.baseURL+"/speak", "application/json", strings.NewReader(body))
@@ -447,13 +448,13 @@ func speak(t *testing.T, d *daemonProc, body string) (int, map[string]any) {
defer resp.Body.Close()
var m map[string]any
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
t.Fatalf("POST /speak Antwort dekodieren: %v", err)
t.Fatalf("decoding POST /speak response: %v", err)
}
return resp.StatusCode, m
}
// postPath macht einen leeren POST auf einen Daemon-Endpunkt (/mute,
// /unmute, /cancel) und dekodiert die JSON-Antwort.
// postPath makes an empty POST to a daemon endpoint (/mute, /unmute,
// /cancel) and decodes the JSON response.
func postPath(t *testing.T, d *daemonProc, path string) (int, map[string]any) {
t.Helper()
resp, err := http.Post(d.baseURL+path, "application/json", strings.NewReader("{}"))
@@ -463,22 +464,22 @@ func postPath(t *testing.T, d *daemonProc, path string) (int, map[string]any) {
defer resp.Body.Close()
var m map[string]any
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
t.Fatalf("POST %s Antwort dekodieren: %v", path, err)
t.Fatalf("decoding POST %s response: %v", path, err)
}
return resp.StatusCode, m
}
// rawResponse ist eine vollständig gelesene HTTP-Antwort (Status, Header,
// Body als String) — Beobachtungsform der rohen CORS-/MCP-Tests.
// rawResponse is a fully read HTTP response (status, header, body as a
// string) — the observation form of the raw CORS/MCP tests.
type rawResponse struct {
status int
header http.Header
body string
}
// doRequest macht einen rohen HTTP-Request mit beliebigen Headern und
// optionalem Body und liest die Antwort komplett (CORS-Preflights und
// MCP-JSON-RPC-Payloads sind literal, kein Client-SDK dazwischen).
// doRequest makes a raw HTTP request with arbitrary headers and an optional
// body and reads the response completely (CORS preflights and MCP JSON-RPC
// payloads are literal, no client SDK in between).
func doRequest(t *testing.T, method, url string, headers map[string]string, body string) rawResponse {
t.Helper()
var rd io.Reader
@@ -487,7 +488,7 @@ func doRequest(t *testing.T, method, url string, headers map[string]string, body
}
req, err := http.NewRequest(method, url, rd)
if err != nil {
t.Fatalf("%s %s bauen: %v", method, url, err)
t.Fatalf("building %s %s: %v", method, url, err)
}
for k, v := range headers {
req.Header.Set(k, v)
@@ -499,13 +500,13 @@ func doRequest(t *testing.T, method, url string, headers map[string]string, body
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("%s %s Antwort lesen: %v", method, url, err)
t.Fatalf("reading %s %s response: %v", method, url, err)
}
return rawResponse{status: resp.StatusCode, header: resp.Header, body: string(raw)}
}
// preflight macht einen OPTIONS-Preflight gegen /speak (so wie ein Browser
// es vor einem echten POST tut).
// preflight makes an OPTIONS preflight against /speak (the way a browser
// does before a real POST).
func preflight(t *testing.T, d *daemonProc, origin string) rawResponse {
t.Helper()
return doRequest(t, http.MethodOptions, d.baseURL+"/speak", map[string]string{
@@ -514,7 +515,7 @@ func preflight(t *testing.T, d *daemonProc, origin string) rawResponse {
}, "")
}
// mcpPost POSTet einen literalen JSON-RPC-Payload an /mcp.
// mcpPost POSTs a literal JSON-RPC payload to /mcp.
func mcpPost(t *testing.T, d *daemonProc, payload string) rawResponse {
t.Helper()
return doRequest(t, http.MethodPost, d.baseURL+"/mcp", map[string]string{
@@ -522,13 +523,13 @@ func mcpPost(t *testing.T, d *daemonProc, payload string) rawResponse {
}, payload)
}
// mcpDecode dekodiert eine JSON-RPC-Antwort in eine generische Map (für
// Feld-Zugriffe der literalen Payload-Tests).
// mcpDecode decodes a JSON-RPC response into a generic map (for field
// access in the literal payload tests).
func mcpDecode(t *testing.T, raw rawResponse) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal([]byte(raw.body), &m); err != nil {
t.Fatalf("JSON-RPC-Antwort dekodieren: %v — Body: %s", err, raw.body)
t.Fatalf("decoding JSON-RPC response: %v — body: %s", err, raw.body)
}
return m
}
+31 -31
View File
@@ -1,9 +1,9 @@
package test
// Prozessgrenzen-Tests für die Längen- und Alters-Gesetze: queue.max_text
// kürzt überlange Texte beim Eintritt (Antwort truncated, CLI-Hinweis, der
// Provider sieht die gekürzte Fassung); queue.max_age verwirft Ansagen, die
// zu lange geparkt haben (expired, nie gesprochen).
// Process-boundary tests for the length and age laws: queue.max_text
// truncates overlong texts on admission (response truncated, CLI note, the
// provider sees the truncated version); queue.max_age discards
// announcements that parked too long (expired, never spoken).
import (
"strings"
@@ -11,48 +11,48 @@ import (
"time"
)
// TestMaxTextKuerzt: Ein 60-Wörter-Text gegen max_text=40 — /speak meldet
// truncated, die CLI warnt auf stderr, der Fake-Provider bekommt höchstens
// 40 Zeichen, und /status zeigt das Limit.
func TestMaxTextKuerzt(t *testing.T) {
// TestMaxTextTruncates: a 60-word text against max_text=40 — /speak reports
// truncated, the CLI warns on stderr, the fake provider receives at most 40
// characters, and /status shows the limit.
func TestMaxTextTruncates(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_MAX_TEXT": "40"})
lang := strings.Repeat("viel zu lange Antwort ", 12)
long := strings.Repeat("far too long an answer ", 12)
code, m := speak(t, d, `{"text":"`+lang+`"}`)
code, m := speak(t, d, `{"text":"`+long+`"}`)
if code != 202 || m["id"] == "" || m["truncated"] != true {
t.Fatalf("speak: %d %v, want 202 mit id und truncated=true", code, m)
t.Fatalf("speak: %d %v, want 202 with id and truncated=true", code, m)
}
hits := fake.waitCompleted(t, 1)
if n := len([]rune(hits[0].Text)); n > 40 || !strings.HasPrefix(hits[0].Text, "viel zu lange Antwort") {
t.Errorf("Provider bekam %q (%d Zeichen), want ≤ 40 Zeichen vom Anfang", hits[0].Text, n)
if n := len([]rune(hits[0].Text)); n > 40 || !strings.HasPrefix(hits[0].Text, "far too long an answer") {
t.Errorf("provider received %q (%d characters), want ≤ 40 characters from the start", hits[0].Text, n)
}
env := daemonEnv(root, d.port, fake.srv.URL, nil)
res := runTalk(t, env, "say", lang)
if res.code != 0 || !strings.Contains(res.stderr, "gekürzt") {
t.Errorf("talk say (lang): Exit %d, stderr %q — will Kürzungs-Hinweis", res.code, res.stderr)
res := runTalk(t, env, "say", long)
if res.code != 0 || !strings.Contains(res.stderr, "truncated") {
t.Errorf("talk say (long): exit %d, stderr %q — want the truncation note", res.code, res.stderr)
}
fake.waitCompleted(t, 2)
res = runTalk(t, env, "say", "kurz genug")
if strings.Contains(res.stderr, "gekürzt") {
t.Errorf("kurzer Text mit Kürzungs-Hinweis: %q", res.stderr)
res = runTalk(t, env, "say", "short enough")
if strings.Contains(res.stderr, "truncated") {
t.Errorf("short text with truncation note: %q", res.stderr)
}
fake.waitCompleted(t, 3)
res = runTalk(t, env, "status")
if !strings.Contains(res.stdout, "limits: max. 40 Zeichen") {
t.Errorf("talk status ohne Limits-Zeile: %q", res.stdout)
if !strings.Contains(res.stdout, "limits: max 40 characters") {
t.Errorf("talk status without limits line: %q", res.stdout)
}
}
// TestMaxAgeVerwirftGeparkte: Rate-Limit 1 s, max_age 1,5 s, drei normal-
// Ansagen direkt hintereinander: die erste spielt sofort, die zweite parkt
// 1 s (frisch genug), die dritte wäre erst nach 2 s fällig — veraltet,
// verworfen, nie beim Provider.
func TestMaxAgeVerwirftGeparkte(t *testing.T) {
// TestMaxAgeDiscardsParked: rate limit 1 s, max_age 1.5 s, three normal
// announcements right after one another: the first plays immediately, the
// second parks 1 s (fresh enough), the third would be due only after 2 s —
// stale, discarded, never at the provider.
func TestMaxAgeDiscardsParked(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
@@ -60,19 +60,19 @@ func TestMaxAgeVerwirftGeparkte(t *testing.T) {
"TALK_MAX_AGE": "1500ms",
})
for _, text := range []string{"eins", "zwei", "drei"} {
for _, text := range []string{"one", "two", "three"} {
code, m := speak(t, d, `{"text":"`+text+`"}`)
if code != 202 || m["id"] == "" {
t.Fatalf("speak %s: %d %v — beim Eintritt darf nichts verfallen", text, code, m)
t.Fatalf("speak %s: %d %v — nothing may expire on admission", text, code, m)
}
}
hits := fake.waitCompleted(t, 2)
if hits[0].Text != "eins" || hits[1].Text != "zwei" {
t.Errorf("gesprochen = %q, %q; want eins, zwei", hits[0].Text, hits[1].Text)
if hits[0].Text != "one" || hits[1].Text != "two" {
t.Errorf("spoken = %q, %q; want one, two", hits[0].Text, hits[1].Text)
}
fake.assertNoFurtherArrivals(t, 1500*time.Millisecond)
waitStatusIdle(t, d, 3*time.Second)
if !strings.Contains(d.stderr.String(), "max_age") {
t.Errorf("Daemon-Protokoll nennt den Verfall nicht:\n%s", d.stderr.String())
t.Errorf("daemon log does not mention the expiry:\n%s", d.stderr.String())
}
}
+86 -84
View File
@@ -1,10 +1,10 @@
package test
// Prozessgrenzen-Tests für den MCP-Endpoint (Task 06): der komplette
// Handshake (initialize → notifications/initialized → tools/list →
// tools/call) als rohe JSON-RPC-HTTP-Payloads gegen einen echten talkd —
// literal, ohne Client-SDK. tools/call muss sich exakt wie POST /speak
// verhalten: dieselbe Queue-Aufnahme inklusive Mute und Gesetz.
// Process-boundary tests for the MCP endpoint (Task 06): the complete
// handshake (initialize → notifications/initialized → tools/list →
// tools/call) as raw JSON-RPC HTTP payloads against a real talkd — literal,
// without a client SDK. tools/call must behave exactly like POST /speak: the
// same queue admission including mute and law.
import (
"fmt"
@@ -14,26 +14,26 @@ import (
"time"
)
// mcpToolText liest content[0].text aus einem tools/call-Ergebnis.
// mcpToolText reads content[0].text from a tools/call result.
func mcpToolText(t *testing.T, m map[string]any) string {
t.Helper()
result, ok := m["result"].(map[string]any)
if !ok {
t.Fatalf("tools/call ohne result: %v", m)
t.Fatalf("tools/call without result: %v", m)
}
content, ok := result["content"].([]any)
if !ok || len(content) == 0 {
t.Fatalf("tools/call ohne content: %v", result)
t.Fatalf("tools/call without content: %v", result)
}
first, ok := content[0].(map[string]any)
if !ok {
t.Fatalf("content[0] ist kein Objekt: %v", content[0])
t.Fatalf("content[0] is not an object: %v", content[0])
}
text, _ := first["text"].(string)
return text
}
// mcpIsError liest isError (fehlt = false — bewusste Semantik).
// mcpIsError reads isError (missing = false — deliberate semantics).
func mcpIsError(m map[string]any) bool {
if result, ok := m["result"].(map[string]any); ok {
if e, ok := result["isError"].(bool); ok {
@@ -43,72 +43,73 @@ func mcpIsError(m map[string]any) bool {
return false
}
// TestMCPHandshake (Abnahmekriterium): die curl-Sequenz initialize →
// notifications/initialized → tools/list → tools/call sieht das speak-Tool
// und enqueued erfolgreich — die Ansage läuft durch Queue und Fake-Provider.
// TestMCPHandshake (acceptance criterion): the curl sequence initialize →
// notifications/initialized → tools/list → tools/call sees the speak tool
// and enqueues successfully — the announcement runs through the queue and
// the fake provider.
func TestMCPHandshake(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
// 1. initialize: angefragte Version wird geechot; capabilities nennen
// tools; serverInfo ist talkd.
// 1. initialize: the requested version is echoed; capabilities name
// tools; serverInfo is talkd.
raw := mcpPost(t, d, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl-test","version":"0"}}}`)
if raw.status != http.StatusOK {
t.Fatalf("initialize: Status %d — Body: %s", raw.status, raw.body)
t.Fatalf("initialize: status %d — body: %s", raw.status, raw.body)
}
if ct := raw.header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("initialize Content-Type = %q, want application/json", ct)
}
m := mcpDecode(t, raw)
if m["jsonrpc"] != "2.0" || fmt.Sprint(m["id"]) != "1" {
t.Fatalf("initialize envelope = %v (jsonrpc/id prüfen)", m)
t.Fatalf("initialize envelope = %v (check jsonrpc/id)", m)
}
result, ok := m["result"].(map[string]any)
if !ok {
t.Fatalf("initialize ohne result: %v", m)
t.Fatalf("initialize without result: %v", m)
}
if result["protocolVersion"] != "2025-06-18" {
t.Errorf("protocolVersion = %v, want Echo der angefragten 2025-06-18", result["protocolVersion"])
t.Errorf("protocolVersion = %v, want echo of the requested 2025-06-18", result["protocolVersion"])
}
caps, ok := result["capabilities"].(map[string]any)
if !ok {
t.Fatalf("capabilities = %v, want Objekt", result["capabilities"])
t.Fatalf("capabilities = %v, want object", result["capabilities"])
}
if _, ok := caps["tools"]; !ok {
t.Errorf("capabilities ohne tools: %v", caps)
t.Errorf("capabilities without tools: %v", caps)
}
serverInfo, _ := result["serverInfo"].(map[string]any)
if serverInfo["name"] != "talkd" {
t.Errorf("serverInfo.name = %v, want talkd", serverInfo["name"])
}
if v, _ := serverInfo["version"].(string); v == "" {
t.Error("serverInfo.version ist leer")
t.Error("serverInfo.version is empty")
}
// Andere angefragte Version wird ebenso geechot (akzeptiert).
// Another requested version is echoed as well (accepted).
raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}`)
m = mcpDecode(t, raw)
if m["result"].(map[string]any)["protocolVersion"] != "2025-03-26" {
t.Errorf("protocolVersion = %v, want Echo 2025-03-26", m["result"])
t.Errorf("protocolVersion = %v, want echo 2025-03-26", m["result"])
}
// 2. notifications/initialized: Notification → 202 ohne Body.
// 2. notifications/initialized: notification → 202 without body.
raw = mcpPost(t, d, `{"jsonrpc":"2.0","method":"notifications/initialized"}`)
if raw.status != http.StatusAccepted {
t.Fatalf("notifications/initialized: Status %d, want 202", raw.status)
t.Fatalf("notifications/initialized: status %d, want 202", raw.status)
}
if raw.body != "" {
t.Errorf("notifications/initialized Body = %q, want leer", raw.body)
t.Errorf("notifications/initialized body = %q, want empty", raw.body)
}
// 3. tools/list: genau das speak-Tool mit dem Brief-Schema.
// 3. tools/list: exactly the speak tool with the briefed schema.
raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":3,"method":"tools/list"}`)
m = mcpDecode(t, raw)
result = m["result"].(map[string]any)
tools, ok := result["tools"].([]any)
if !ok || len(tools) != 1 {
t.Fatalf("tools = %v, want genau ein Tool", result["tools"])
t.Fatalf("tools = %v, want exactly one tool", result["tools"])
}
tool := tools[0].(map[string]any)
if tool["name"] != "speak" {
@@ -116,7 +117,7 @@ func TestMCPHandshake(t *testing.T) {
}
schema, ok := tool["inputSchema"].(map[string]any)
if !ok {
t.Fatalf("inputSchema = %v, want Objekt", tool["inputSchema"])
t.Fatalf("inputSchema = %v, want object", tool["inputSchema"])
}
if schema["type"] != "object" {
t.Errorf("inputSchema.type = %v, want object", schema["type"])
@@ -124,75 +125,76 @@ func TestMCPHandshake(t *testing.T) {
props := schema["properties"].(map[string]any)
for _, name := range []string{"text", "priority", "provider", "lang", "now"} {
if _, ok := props[name]; !ok {
t.Errorf("inputSchema ohne property %q: %v", name, props)
t.Errorf("inputSchema without property %q: %v", name, props)
}
}
if len(props) != 5 {
t.Errorf("inputSchema hat %d properties (%v), want genau die 5 aus dem Brief", len(props), props)
t.Errorf("inputSchema has %d properties (%v), want exactly the 5 from the brief", len(props), props)
}
if req, ok := schema["required"].([]any); !ok || len(req) != 1 || req[0] != "text" {
t.Errorf("required = %v, want [text]", schema["required"])
}
// 4. tools/call: Aufnahme wie POST /speak — "queued: <id>" und die
// Ansage taucht am Fake-Provider auf (gleiche Queue, kein Parallelweg).
raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"speak","arguments":{"text":"hallo über mcp"}}}`)
// 4. tools/call: admission like POST /speak — "queued: <id>" and the
// announcement shows up at the fake provider (same queue, no
// parallel path).
raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"speak","arguments":{"text":"hello via mcp"}}}`)
if raw.status != http.StatusOK {
t.Fatalf("tools/call: Status %d — Body: %s", raw.status, raw.body)
t.Fatalf("tools/call: status %d — body: %s", raw.status, raw.body)
}
m = mcpDecode(t, raw)
if fmt.Sprint(m["id"]) != "4" {
t.Errorf("tools/call id = %v, want Echo 4", m["id"])
t.Errorf("tools/call id = %v, want echo 4", m["id"])
}
text := mcpToolText(t, m)
if !strings.HasPrefix(text, "queued: ") || len(text) <= len("queued: ") {
t.Fatalf("tools/call text = %q, want \"queued: <id>\"", text)
}
if mcpIsError(m) {
t.Errorf("tools/call isError = true bei erfolgreicher Aufnahme: %v", m)
t.Errorf("tools/call isError = true on successful admission: %v", m)
}
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "hallo über mcp" {
t.Errorf("Fake-Treffer = %q, will die MCP-Ansage", hits[0].Text)
if hits[0].Text != "hello via mcp" {
t.Errorf("fake hit = %q, want the MCP announcement", hits[0].Text)
}
}
// TestMCPToolsCallGesetze: muted und Gesetz-Verwürfe sind Tool-ERGEBNISSE
// (isError:false mit erklärendem Text), keine Protokollfehler — und die
// Ansage bleibt stumm.
func TestMCPToolsCallGesetze(t *testing.T) {
// TestMCPToolsCallLaws: muted and law discards are tool RESULTS
// (isError:false with explanatory text), not protocol errors — and the
// announcement stays silent.
func TestMCPToolsCallLaws(t *testing.T) {
t.Run("muted", func(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
postPath(t, d, "/mute")
m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"speak","arguments":{"text":"stille bitte"}}}`))
m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"speak","arguments":{"text":"silence please"}}}`))
if mcpIsError(m) {
t.Fatalf("muted ist isError:true — soll Ergebnis sein: %v", m)
t.Fatalf("muted is isError:true — should be a result: %v", m)
}
if text := mcpToolText(t, m); !strings.HasPrefix(text, "muted") {
t.Errorf("text = %q, will muted-Erklärung", text)
t.Errorf("text = %q, want the muted explanation", text)
}
fake.assertNoHits(t, 300*time.Millisecond)
})
t.Run("rate_limit verwirft low", func(t *testing.T) {
t.Run("rate_limit discards low", func(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "30s"})
// Erste low-Ansage spielt (Fenster frei) und bucht es.
m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"speak","arguments":{"text":"erste","priority":"low"}}}`))
// The first low announcement plays (window free) and books it.
m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"speak","arguments":{"text":"first","priority":"low"}}}`))
if text := mcpToolText(t, m); !strings.HasPrefix(text, "queued: ") {
t.Fatalf("erste low: text = %q, want queued", text)
t.Fatalf("first low: text = %q, want queued", text)
}
fake.waitCompleted(t, 1)
// Zweite low innerhalb des Fensters: Verwurf als Ergebnis.
m = mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"speak","arguments":{"text":"zweite","priority":"low"}}}`))
// Second low inside the window: discard as a result.
m = mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"speak","arguments":{"text":"second","priority":"low"}}}`))
if mcpIsError(m) {
t.Fatalf("Verwurf ist isError:true — soll Ergebnis sein: %v", m)
t.Fatalf("discard is isError:true — should be a result: %v", m)
}
if text := mcpToolText(t, m); !strings.HasPrefix(text, "discarded: rate_limit") {
t.Errorf("text = %q, want discarded: rate_limit …", text)
@@ -200,16 +202,16 @@ func TestMCPToolsCallGesetze(t *testing.T) {
fake.assertNoFurtherArrivals(t, 300*time.Millisecond)
})
t.Run("quiet_hours verwirft", func(t *testing.T) {
t.Run("quiet_hours discards", func(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_QUIET_HOURS": fensterUm(),
"TALK_QUIET_HOURS": windowAround(),
})
m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"speak","arguments":{"text":"nachts"}}}`))
m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"speak","arguments":{"text":"at night"}}}`))
if mcpIsError(m) {
t.Fatalf("Ruhezeit-Verwurf ist isError:true — soll Ergebnis sein: %v", m)
t.Fatalf("quiet-hours discard is isError:true — should be a result: %v", m)
}
if text := mcpToolText(t, m); !strings.HasPrefix(text, "discarded: quiet_hours") {
t.Errorf("text = %q, want discarded: quiet_hours …", text)
@@ -218,10 +220,10 @@ func TestMCPToolsCallGesetze(t *testing.T) {
})
}
// TestMCPFehlerPfade: Validierungsfehler als JSON-RPC-Fehler (-32602),
// unbekannte Methode -32601, kaputtes JSON -32700, falsche jsonrpc-Version
// und Batches -32600, GET /mcp 405 (keine server-initiierten Streams).
func TestMCPFehlerPfade(t *testing.T) {
// TestMCPErrorPaths: validation errors as JSON-RPC errors (-32602), unknown
// method -32601, broken JSON -32700, wrong jsonrpc version and batches
// -32600, GET /mcp 405 (no server-initiated streams).
func TestMCPErrorPaths(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
@@ -232,34 +234,34 @@ func TestMCPFehlerPfade(t *testing.T) {
code float64
msgPart string
}{
{"text fehlt", `{"jsonrpc":"2.0","id":21,"method":"tools/call","params":{"name":"speak","arguments":{}}}`, -32602, "text"},
{"text leer", `{"jsonrpc":"2.0","id":22,"method":"tools/call","params":{"name":"speak","arguments":{"text":" "}}}`, -32602, "text"},
{"ungültige priority", `{"jsonrpc":"2.0","id":23,"method":"tools/call","params":{"name":"speak","arguments":{"text":"x","priority":"mega"}}}`, -32602, "priority"},
{"unbekanntes Tool", `{"jsonrpc":"2.0","id":24,"method":"tools/call","params":{"name":"sing","arguments":{"text":"x"}}}`, -32602, "sing"},
{"unbekannte Methode", `{"jsonrpc":"2.0","id":25,"method":"resources/list"}`, -32601, "resources/list"},
{"kaputtes JSON", `{"jsonrpc":"2.0","id":`, -32700, "JSON"},
{"falsche jsonrpc-Version", `{"jsonrpc":"1.0","id":26,"method":"ping"}`, -32600, "2.0"},
{"Batch (seit 2025-06-18 entfernt)", `[{"jsonrpc":"2.0","id":27,"method":"ping"}]`, -32600, "Batch"},
{"text missing", `{"jsonrpc":"2.0","id":21,"method":"tools/call","params":{"name":"speak","arguments":{}}}`, -32602, "text"},
{"text empty", `{"jsonrpc":"2.0","id":22,"method":"tools/call","params":{"name":"speak","arguments":{"text":" "}}}`, -32602, "text"},
{"invalid priority", `{"jsonrpc":"2.0","id":23,"method":"tools/call","params":{"name":"speak","arguments":{"text":"x","priority":"mega"}}}`, -32602, "priority"},
{"unknown tool", `{"jsonrpc":"2.0","id":24,"method":"tools/call","params":{"name":"sing","arguments":{"text":"x"}}}`, -32602, "sing"},
{"unknown method", `{"jsonrpc":"2.0","id":25,"method":"resources/list"}`, -32601, "resources/list"},
{"broken JSON", `{"jsonrpc":"2.0","id":`, -32700, "JSON"},
{"wrong jsonrpc version", `{"jsonrpc":"1.0","id":26,"method":"ping"}`, -32600, "2.0"},
{"batch (removed since 2025-06-18)", `[{"jsonrpc":"2.0","id":27,"method":"ping"}]`, -32600, "batch"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
raw := mcpPost(t, d, tc.payload)
if raw.status != http.StatusOK {
t.Fatalf("Status = %d, want 200 (JSON-RPC-Fehler im Body)", raw.status)
t.Fatalf("status = %d, want 200 (JSON-RPC error in the body)", raw.status)
}
m := mcpDecode(t, raw)
rpcErr, ok := m["error"].(map[string]any)
if !ok {
t.Fatalf("kein error-Objekt: %v", m)
t.Fatalf("no error object: %v", m)
}
if rpcErr["code"] != tc.code {
t.Errorf("code = %v, want %v (Body: %s)", rpcErr["code"], tc.code, raw.body)
t.Errorf("code = %v, want %v (body: %s)", rpcErr["code"], tc.code, raw.body)
}
if msg, _ := rpcErr["message"].(string); !strings.Contains(msg, tc.msgPart) {
t.Errorf("message = %q, will Teil %q", msg, tc.msgPart)
t.Errorf("message = %q, want part %q", msg, tc.msgPart)
}
if _, hasResult := m["result"]; hasResult {
t.Errorf("error-Antwort mit result: %v", m)
t.Errorf("error response with result: %v", m)
}
})
}
@@ -267,34 +269,34 @@ func TestMCPFehlerPfade(t *testing.T) {
t.Run("ping", func(t *testing.T) {
m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":28,"method":"ping"}`))
if _, ok := m["result"]; !ok {
t.Fatalf("ping ohne result: %v", m)
t.Fatalf("ping without result: %v", m)
}
})
t.Run("GET /mcp → 405 (keine server-initiierten Streams)", func(t *testing.T) {
t.Run("GET /mcp → 405 (no server-initiated streams)", func(t *testing.T) {
raw := doRequest(t, http.MethodGet, d.baseURL+"/mcp", nil, "")
if raw.status != http.StatusMethodNotAllowed {
t.Fatalf("GET /mcp Status = %d, want 405", raw.status)
t.Fatalf("GET /mcp status = %d, want 405", raw.status)
}
if allow := raw.header.Get("Allow"); !strings.Contains(allow, "POST") {
t.Errorf("Allow = %q, will POST nennen", allow)
t.Errorf("Allow = %q, want POST named", allow)
}
})
t.Run("Notification mit unbekannter Methode → still 202", func(t *testing.T) {
t.Run("notification with unknown method → silent 202", func(t *testing.T) {
raw := mcpPost(t, d, `{"jsonrpc":"2.0","method":"notifications/roots/list_changed"}`)
if raw.status != http.StatusAccepted || raw.body != "" {
t.Fatalf("Status %d Body %q, want 202 ohne Body", raw.status, raw.body)
t.Fatalf("status %d body %q, want 202 without body", raw.status, raw.body)
}
})
}
// TestStatusMCPFeld: /status meldet mcp:true (Compat-Feld Task 06).
func TestStatusMCPFeld(t *testing.T) {
// TestStatusMCPField: /status reports mcp:true (compat field Task 06).
func TestStatusMCPField(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
if st := getStatus(t, d); !st.MCP {
t.Error("mcp = false, want true (Endpoint ist vorhanden)")
t.Error("mcp = false, want true (endpoint is present)")
}
}
+30 -30
View File
@@ -8,15 +8,15 @@ import (
"time"
)
// TestMutePersistenzUeberRestart (Abnahmekriterium): `talk mute` → Daemon-
// Restart → weiter stumm. Mute verwirft Ansagen dokumentiert mit
// 202 {id:"", muted:true} und ohne jeden Provider-Kontakt.
func TestMutePersistenzUeberRestart(t *testing.T) {
// TestMutePersistenceAcrossRestart (acceptance criterion): `talk mute` →
// daemon restart → still muted. Mute discards announcements as documented
// with 202 {id:"", muted:true} and without any provider contact.
func TestMutePersistenceAcrossRestart(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
// Über die HTTP-API stummschalten: Datei mute liegt im Config-Dir.
// Mute via the HTTP API: the mute file is in the config dir.
code, m := postPath(t, d, "/mute")
if code != 200 || m["muted"] != true {
t.Fatalf("POST /mute: %d %v", code, m)
@@ -24,72 +24,72 @@ func TestMutePersistenzUeberRestart(t *testing.T) {
muteFile := filepath.Join(configDir(root), "mute")
b, err := os.ReadFile(muteFile)
if err != nil {
t.Fatalf("Mute-Datei fehlt im Config-Dir: %v", err)
t.Fatalf("mute file missing in the config dir: %v", err)
}
if strings.TrimSpace(string(b)) != "true" {
t.Errorf("Mute-Datei = %q, want true", b)
t.Errorf("mute file = %q, want true", b)
}
// Ansagen werden verworfen: 202, muted:true, leere ID, kein Treffer.
code, m = speak(t, d, `{"text":"stille"}`)
// Announcements are discarded: 202, muted:true, empty ID, no hit.
code, m = speak(t, d, `{"text":"silence"}`)
if code != 202 || m["muted"] != true || m["id"] != "" {
t.Fatalf("speak bei Mute: %d %v, want 202 {id:, muted:true}", code, m)
t.Fatalf("speak while muted: %d %v, want 202 {id:, muted:true}", code, m)
}
fake.assertNoHits(t, 300*time.Millisecond)
if st := getStatus(t, d); !st.Muted {
t.Fatal("/status meldet muted=false")
t.Fatal("/status reports muted=false")
}
// Daemon-Restart mit demselben Config-Dir: weiter stumm.
// Daemon restart with the same config dir: still muted.
d.stop(t)
d2 := startDaemon(t, root, fake.srv.URL, nil)
if st := getStatus(t, d2); !st.Muted {
t.Fatalf("nach Restart muted = false, want true (persistent)")
t.Fatalf("after restart muted = false, want true (persistent)")
}
code, m = speak(t, d2, `{"text":"immer noch stumm"}`)
code, m = speak(t, d2, `{"text":"still silent"}`)
if code != 202 || m["muted"] != true {
t.Fatalf("speak nach Restart bei Mute: %d %v", code, m)
t.Fatalf("speak after restart while muted: %d %v", code, m)
}
fake.assertNoHits(t, 300*time.Millisecond)
// Unmute: Ansagen laufen wieder.
// Unmute: announcements play again.
code, m = postPath(t, d2, "/unmute")
if code != 200 || m["muted"] != false {
t.Fatalf("POST /unmute: %d %v", code, m)
}
code, m = speak(t, d2, `{"text":"wieder da"}`)
code, m = speak(t, d2, `{"text":"back again"}`)
if code != 202 || m["id"] == "" {
t.Fatalf("speak nach unmute: %d %v, want 202 mit id", code, m)
t.Fatalf("speak after unmute: %d %v, want 202 with id", code, m)
}
hits := fake.waitHits(t, 1)
if hits[0].Text != "wieder da" {
t.Errorf("Treffer = %q, want wieder da", hits[0].Text)
if hits[0].Text != "back again" {
t.Errorf("hit = %q, want back again", hits[0].Text)
}
}
// TestOfflineMuteDateiWirdVonDaemonGelesen: eine offline (CLI, ohne
// Daemon) geschriebene Mute-Datei wirkt sofort im Daemon — beide lesen
// dieselbe Quelle.
func TestOfflineMuteDateiWirdVonDaemonGelesen(t *testing.T) {
// TestOfflineMuteFileIsReadByDaemon: a mute file written offline (CLI,
// without daemon) takes effect in the daemon immediately — both read the
// same source.
func TestOfflineMuteFileIsReadByDaemon(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
deadEnv := daemonEnv(root, 1, fake.srv.URL, nil)
// Offline stummschalten (talk mute ohne Daemon → Datei direkt).
// Mute offline (talk mute without daemon → file directly).
res := runTalk(t, deadEnv, "mute")
if res.code != 0 || res.stdout != "muted\n" {
t.Fatalf("talk mute (offline): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
}
// Der laufende Daemon sieht den Zustand bei der nächsten Ansage.
// The running daemon sees the state on the next announcement.
if st := getStatus(t, d); !st.Muted {
t.Fatal("Daemon sieht offline geschriebene Mute-Datei nicht")
t.Fatal("daemon does not see the mute file written offline")
}
code, m := speak(t, d, `{"text":"soll verworfen werden"}`)
code, m := speak(t, d, `{"text":"should be discarded"}`)
if code != 202 || m["muted"] != true {
t.Fatalf("speak: %d %v, want verworfen", code, m)
t.Fatalf("speak: %d %v, want discarded", code, m)
}
fake.assertNoHits(t, 300*time.Millisecond)
@@ -98,6 +98,6 @@ func TestOfflineMuteDateiWirdVonDaemonGelesen(t *testing.T) {
t.Fatalf("talk unmute (offline): %d — stderr: %s", res.code, res.stderr)
}
if st := getStatus(t, d); st.Muted {
t.Fatal("Daemon sieht offline geschriebene Unmute-Datei nicht")
t.Fatal("daemon does not see the unmute file written offline")
}
}
+11 -11
View File
@@ -1,8 +1,8 @@
package test
// Prozessgrenzen-Test für den Ruhezeit-Toast: quiet_mode = toast in aktiver
// Ruhezeit löst eine Desktop-Benachrichtigung aus — hier ein fake
// notify-send im PATH des Daemons, das seine Argumente protokolliert.
// Process-boundary test for the quiet-hours toast: quiet_mode = toast during
// active quiet hours fires a desktop notification — here a fake notify-send
// in the daemon's PATH that logs its arguments.
import (
"os"
@@ -13,9 +13,9 @@ import (
"time"
)
func TestRuhezeitToastZeigtNotification(t *testing.T) {
func TestQuietHoursToastShowsNotification(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("fake notify-send gibt es nur auf Linux")
t.Skip("the fake notify-send exists only on Linux")
}
root := t.TempDir()
bin := filepath.Join(root, "bin")
@@ -29,25 +29,25 @@ func TestRuhezeitToastZeigtNotification(t *testing.T) {
}
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_QUIET_HOURS": fensterUm(),
"TALK_QUIET_HOURS": windowAround(),
"TALK_QUIET_MODE": "toast",
"PATH": bin + string(os.PathListSeparator) + os.Getenv("PATH"),
})
code, m := speak(t, d, `{"text":"Build fertig, bitte Review"}`)
code, m := speak(t, d, `{"text":"Build done, please review"}`)
if code != 202 || m["discarded"] != "quiet_hours" || m["toast"] != true {
t.Fatalf("speak in Ruhezeit (toast): %d %v", code, m)
t.Fatalf("speak during quiet hours (toast): %d %v", code, m)
}
deadline := time.Now().Add(3 * time.Second)
for {
if b, err := os.ReadFile(seen); err == nil {
if !strings.Contains(string(b), "Build fertig, bitte Review") || !strings.Contains(string(b), "talk") {
t.Errorf("notify-send-Argumente = %q", b)
if !strings.Contains(string(b), "Build done, please review") || !strings.Contains(string(b), "talk") {
t.Errorf("notify-send arguments = %q", b)
}
break
}
if time.Now().After(deadline) {
t.Fatalf("notify-send wurde nicht gerufen — Daemon-Log:\n%s", d.stderr.String())
t.Fatalf("notify-send was not called — daemon log:\n%s", d.stderr.String())
}
time.Sleep(25 * time.Millisecond)
}
+91 -91
View File
@@ -8,26 +8,26 @@ import (
"time"
)
// TestSerialisierung: zwei schnell hintereinander eingereihte Ansagen
// spielen nacheinander — die zweite Synthese erreicht den Fake-Provider
// erst NACH der Antwort der ersten (kein Überlappen), und die Textfolge
// bleibt enqueue-stabil. Der null-Sink verwirft jede Ansage sichtbar.
func TestSerialisierung(t *testing.T) {
// TestSerialization: two announcements enqueued in quick succession play one
// after another — the second synthesis reaches the fake provider only AFTER
// the response to the first (no overlap), and the text order stays
// enqueue-stable. The null sink visibly discards every announcement.
func TestSerialization(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 250*time.Millisecond)
d := startDaemon(t, root, fake.srv.URL, nil)
code, first := speak(t, d, `{"text":"erste ansage"}`)
code, first := speak(t, d, `{"text":"first announcement"}`)
if code != 202 || first["id"] == "" || first["muted"] == true {
t.Fatalf("erste: Status=%d Body=%v, want 202 mit id", code, first)
t.Fatalf("first: status=%d body=%v, want 202 with id", code, first)
}
// Sobald die erste Synthese beim Fake angekommen ist (Daemon
// beschäftigt), zwei weitere schnell hinterher — sie müssen sich
// hinter der laufenden einreihen.
// As soon as the first synthesis has arrived at the fake (daemon busy),
// two more in quick succession — they must line up behind the running
// one.
fake.waitArrived(t, 1)
speak(t, d, `{"text":"zweite ansage"}`)
speak(t, d, `{"text":"dritte ansage"}`)
speak(t, d, `{"text":"second announcement"}`)
speak(t, d, `{"text":"third announcement"}`)
hits := fake.waitHits(t, 3)
@@ -35,74 +35,74 @@ func TestSerialisierung(t *testing.T) {
for _, h := range hits {
texts = append(texts, h.Text)
}
if strings.Join(texts, "|") != "erste ansage|zweite ansage|dritte ansage" {
t.Errorf("Abspiel-Reihenfolge = %v, want FIFO", texts)
if strings.Join(texts, "|") != "first announcement|second announcement|third announcement" {
t.Errorf("playback order = %v, want FIFO", texts)
}
// Serialisierung: die nächste Synthese startet erst nach der Antwort
// der vorherigen (kleine Toleranz für Uhr-Jitter).
// Serialization: the next synthesis starts only after the response to
// the previous one (small tolerance for clock jitter).
const slack = 25 * time.Millisecond
for i := 1; i < len(hits); i++ {
if hits[i].Start.Before(hits[i-1].End.Add(-slack)) {
t.Errorf("Ansage %d überlappt mit Ansage %d: Start %v < Ende %v", i, i-1, hits[i].Start, hits[i-1].End)
t.Errorf("announcement %d overlaps announcement %d: start %v < end %v", i, i-1, hits[i].Start, hits[i-1].End)
}
}
// Erst idle warten: Die dritte Verwurf-Zeile entsteht daemon-seitig
// erst kurz NACH der beim Fake aufgezeichneten Antwort.
// Wait for idle first: the third discard line appears daemon-side only
// shortly AFTER the response recorded at the fake.
waitStatusIdle(t, d, 3*time.Second)
// Der null-Sink hat alle drei verworfen (tonlos, aber sichtbar).
if n := strings.Count(d.stderr.String(), "verworfen (null sink)"); n < 3 {
t.Errorf("null-Sink-Verwürfe im Daemon-Log = %d, want >= 3 (stderr: %s)", n, d.stderr.String())
// The null sink discarded all three (silent, but visible).
if n := strings.Count(d.stderr.String(), "discarded (null sink)"); n < 3 {
t.Errorf("null-sink discards in the daemon log = %d, want >= 3 (stderr: %s)", n, d.stderr.String())
}
}
// TestPrioritaet: während einer langen Ansage werden low/normal/high
// eingereiht — die Abarbeitung folgt high > normal > low (FIFO je Level).
func TestPrioritaet(t *testing.T) {
// TestPriority: during a long announcement low/normal/high are enqueued —
// processing follows high > normal > low (FIFO per level).
func TestPriority(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 200*time.Millisecond)
d := startDaemon(t, root, fake.srv.URL, nil)
speak(t, d, `{"text":"blocker"}`)
fake.waitArrived(t, 1) // Blocker läuft, Worker gebunden.
fake.waitArrived(t, 1) // blocker running, worker bound.
// Eingereiht wird in Klassen-Durcheinander; die Queue-Sicht meldet
// IDs — also text→id einsammeln und die Reihenfolge über IDs prüfen.
// Enqueued in mixed class order; the queue view reports IDs — so
// collect text→id and check the order via IDs.
enq := make(map[string]string)
for _, tc := range []struct{ body, text string }{
{`{"text":"niedrig","priority":"low"}`, "niedrig"},
{`{"text":"normal-eins"}`, "normal-eins"},
{`{"text":"normal-zwei"}`, "normal-zwei"},
{`{"text":"dringend","priority":"high"}`, "dringend"},
{`{"text":"low one","priority":"low"}`, "low one"},
{`{"text":"normal-one"}`, "normal-one"},
{`{"text":"normal-two"}`, "normal-two"},
{`{"text":"urgent","priority":"high"}`, "urgent"},
} {
code, m := speak(t, d, tc.body)
if code != 202 {
t.Fatalf("speak %s: Status %d", tc.body, code)
t.Fatalf("speak %s: status %d", tc.body, code)
}
id, _ := m["id"].(string)
if id == "" {
t.Fatalf("speak %s: keine ID in %v", tc.body, m)
t.Fatalf("speak %s: no ID in %v", tc.body, m)
}
enq[tc.text] = id
}
wantOrder := []string{"dringend", "normal-eins", "normal-zwei", "niedrig"}
wantOrder := []string{"urgent", "normal-one", "normal-two", "low one"}
// Status zeigt aktuelles Item und die wartenden in Abarbeitungs-
// Reihenfolge (deterministisch: der Blocker hält den Worker).
// Status shows the current item and the waiting ones in processing
// order (deterministic: the blocker holds the worker).
st := getStatus(t, d)
if st.Current == nil || st.Current.Text != "blocker" {
t.Fatalf("current = %+v, will blocker", st.Current)
t.Fatalf("current = %+v, want blocker", st.Current)
}
if st.Queue != 4 {
t.Fatalf("Queue-Länge = %d, want 4", st.Queue)
t.Fatalf("queue length = %d, want 4", st.Queue)
}
var wantIDs []string
for _, text := range wantOrder {
wantIDs = append(wantIDs, enq[text])
}
if fmt.Sprint(st.QueueIDs) != fmt.Sprint(wantIDs) {
t.Errorf("queue_ids = %v, want %v (Abarbeitungs-Reihenfolge)", st.QueueIDs, wantIDs)
t.Errorf("queue_ids = %v, want %v (processing order)", st.QueueIDs, wantIDs)
}
hits := fake.waitHits(t, 5)
@@ -112,15 +112,15 @@ func TestPrioritaet(t *testing.T) {
}
want := append([]string{"blocker"}, wantOrder...)
if strings.Join(texts, "|") != strings.Join(want, "|") {
t.Errorf("Abspiel-Reihenfolge = %v, want %v", texts, want)
t.Errorf("playback order = %v, want %v", texts, want)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestNowSpringtVor: --now über die CLI setzt now:true — die Ansage
// springt vor alle wartenden normal-Items (unterbricht aber nicht den
// laufenden Blocker).
func TestNowSpringtVor(t *testing.T) {
// TestNowJumpsAhead: --now via the CLI sets now:true — the announcement
// jumps ahead of all waiting normal items (but does not interrupt the
// running blocker).
func TestNowJumpsAhead(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 600*time.Millisecond)
d := startDaemon(t, root, fake.srv.URL, nil)
@@ -129,14 +129,14 @@ func TestNowSpringtVor(t *testing.T) {
speak(t, d, `{"text":"blocker"}`)
fake.waitArrived(t, 1)
speak(t, d, `{"text":"spaeter"}`)
speak(t, d, `{"text":"later"}`)
res := runTalk(t, env, "say", "--now", "sofort")
res := runTalk(t, env, "say", "--now", "right away")
if res.code != 0 {
t.Fatalf("talk say --now: Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say --now: exit %d, stderr: %s", res.code, res.stderr)
}
if id := strings.TrimSpace(res.stdout); id == "" || strings.Contains(id, "\n") {
t.Errorf("talk say --now stdout = %q, will eine Zeile mit der Item-ID", res.stdout)
t.Errorf("talk say --now stdout = %q, want one line with the item ID", res.stdout)
}
hits := fake.waitHits(t, 3)
@@ -144,15 +144,15 @@ func TestNowSpringtVor(t *testing.T) {
for _, h := range hits {
texts = append(texts, h.Text)
}
if strings.Join(texts, "|") != "blocker|sofort|spaeter" {
t.Errorf("Abspiel-Reihenfolge = %v, want [blocker sofort spaeter] — now muss vor normal springen", texts)
if strings.Join(texts, "|") != "blocker|right away|later" {
t.Errorf("playback order = %v, want [blocker right away later] — now must jump ahead of normal", texts)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestCancel: bricht die laufende Ansage ab (Synthese-Request wird
// abgebrochen), leert die Queue, meldet die Gesamtzahl — und der Daemon
// bleibt danach funktionstüchtig.
// TestCancel: aborts the running announcement (synthesis request is
// cancelled), empties the queue, reports the total count — and the daemon
// remains functional afterwards.
func TestCancel(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 5*time.Second)
@@ -160,47 +160,47 @@ func TestCancel(t *testing.T) {
code, first := speak(t, d, `{"text":"blocker"}`)
if code != 202 {
t.Fatalf("speak: Status %d", code)
t.Fatalf("speak: status %d", code)
}
fake.waitArrived(t, 1) // Blocker steckt in der Synthese (5 s Fake-Delay).
fake.waitArrived(t, 1) // blocker is stuck in synthesis (5 s fake delay).
code, second := speak(t, d, `{"text":"wartet"}`)
code, second := speak(t, d, `{"text":"waiting"}`)
if code != 202 {
t.Fatalf("speak: Status %d", code)
t.Fatalf("speak: status %d", code)
}
// Status-Vertrag: aktuelles Item + ein wartendes.
// Status contract: current item + one waiting.
st := getStatus(t, d)
if st.Current == nil || st.Current.ID != first["id"] {
t.Fatalf("current = %+v, will den Blocker (%v)", st.Current, first["id"])
t.Fatalf("current = %+v, want the blocker (%v)", st.Current, first["id"])
}
if st.Queue != 1 || len(st.QueueIDs) != 1 || st.QueueIDs[0] != second["id"] {
t.Fatalf("queue = %v (%d), will das wartende Item", st.QueueIDs, st.Queue)
t.Fatalf("queue = %v (%d), want the waiting item", st.QueueIDs, st.Queue)
}
code, m := postPath(t, d, "/cancel")
if code != 200 {
t.Fatalf("POST /cancel: Status %d", code)
t.Fatalf("POST /cancel: status %d", code)
}
if m["cancelled"] != float64(2) {
t.Fatalf("cancelled = %v, want 2 (laufende + wartende)", m["cancelled"])
t.Fatalf("cancelled = %v, want 2 (running + waiting)", m["cancelled"])
}
// Der Daemon wird sofort idle (Synthese abgebrochen) und nimmt neue
// Ansagen an — cancel beschädigt den Daemon nicht.
// The daemon becomes idle immediately (synthesis cancelled) and accepts
// new announcements — cancel does not damage the daemon.
waitStatusIdle(t, d, 2*time.Second)
fake.setDelay(50 * time.Millisecond)
speak(t, d, `{"text":"nachher"}`)
speak(t, d, `{"text":"afterwards"}`)
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "nachher" {
t.Errorf("erster abgeschlossener Treffer = %q, will nachher (abgebrochene zählen nicht)", hits[0].Text)
if hits[0].Text != "afterwards" {
t.Errorf("first completed hit = %q, want afterwards (cancelled ones do not count)", hits[0].Text)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestSpeakValidierung: der HTTP-Vertrag lehnt kaputte Anfragen mit 400
// ab (und 405 bei falscher Methode), ohne den Daemon zu beschädigen.
func TestSpeakValidierung(t *testing.T) {
// TestSpeakValidation: the HTTP contract rejects broken requests with 400
// (and 405 for the wrong method) without damaging the daemon.
func TestSpeakValidation(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
@@ -209,64 +209,64 @@ func TestSpeakValidierung(t *testing.T) {
name string
body string
}{
{"kein JSON", `kein json`},
{"text fehlt", `{}`},
{"text leer", `{"text":" "}`},
{"priority Müll", `{"text":"x","priority":"sofort"}`},
{"provider unbekannt", `{"text":"x","provider":"gibtsnicht"}`},
{"lang Müll", `{"text":"x","lang":"fr"}`},
{"not JSON", `not json`},
{"text missing", `{}`},
{"text empty", `{"text":" "}`},
{"priority garbage", `{"text":"x","priority":"immediately"}`},
{"provider unknown", `{"text":"x","provider":"doesnotexist"}`},
{"lang garbage", `{"text":"x","lang":"fr"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, m := speak(t, d, tc.body)
if code != 400 {
t.Fatalf("Status = %d, want 400 (Body: %v)", code, m)
t.Fatalf("status = %d, want 400 (body: %v)", code, m)
}
if _, ok := m["error"]; !ok {
t.Errorf("Antwort ohne error-Feld: %v", m)
t.Errorf("response without error field: %v", m)
}
})
}
// Falsche Methode → 405 (Methoden-gebundene Routen).
// Wrong method → 405 (method-bound routes).
resp, err := http.Post(d.baseURL+"/status", "application/json", nil)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("POST /status: Status = %d, want 405", resp.StatusCode)
t.Errorf("POST /status: status = %d, want 405", resp.StatusCode)
}
// Danach ist der Daemon weiterhin gesund und speakbar.
code, m := speak(t, d, `{"text":"gesund"}`)
// Afterwards the daemon is still healthy and speakable.
code, m := speak(t, d, `{"text":"healthy"}`)
if code != 202 || m["id"] == "" {
t.Fatalf("nach Validierungs-Fehlern: speak = %d %v, want 202 mit id", code, m)
t.Fatalf("after validation errors: speak = %d %v, want 202 with id", code, m)
}
fake.waitHits(t, 1)
}
// TestStatusVertrag: Felder der Status-Antwort im Leerlauf (Version,
// Mute aus, kein current, Provider aufgelöst).
func TestStatusVertrag(t *testing.T) {
// TestStatusContract: fields of the status response when idle (version,
// mute off, no current, provider resolved).
func TestStatusContract(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
st := getStatus(t, d)
if st.Version != "dev" {
t.Errorf("version = %q, want dev (ohne ldflags gebaut)", st.Version)
t.Errorf("version = %q, want dev (built without ldflags)", st.Version)
}
if st.Muted {
t.Error("muted = true im Frischstart, want false")
t.Error("muted = true on a fresh start, want false")
}
if st.Current != nil {
t.Errorf("current = %+v, want null im Leerlauf", st.Current)
t.Errorf("current = %+v, want null when idle", st.Current)
}
if st.Queue != 0 {
t.Errorf("queue = %d, want 0", st.Queue)
}
// Aktiver Provider: elfenlabs zuerst, weil Key + Fake-Endpunkt gesetzt.
// Active provider: elevenlabs first, because key + fake endpoint are set.
if st.Provider != "elevenlabs" {
t.Errorf("provider = %q, want elevenlabs", st.Provider)
}
+126 -125
View File
@@ -1,10 +1,10 @@
package test
// Prozessgrenzen-Tests für das Anti-Spam-Gesetz (Task 05): Rate-Limit
// (low verworfen, normal geparkt, high-burst, now-Ausbruch) und Ruhezeiten
// (discard/toast) — beobachtet als Black Box über HTTP/CLI, Fake-Provider
// und Daemon-Protokoll. Der Harness stellt dafür TALK_RATE_LIMIT=0s als
// Basis; diese Tests stellen das Gesetz bewusst per extra an.
// Process-boundary tests for the anti-spam law (Task 05): rate limit (low
// discarded, normal parked, high burst, now breakout) and quiet hours
// (discard/toast) — observed as a black box via HTTP/CLI, fake provider and
// daemon log. The harness provides TALK_RATE_LIMIT=0s as the base; these
// tests deliberately turn the law on via extra.
import (
"encoding/json"
@@ -14,17 +14,17 @@ import (
"time"
)
// fensterUm legt ein Ruhezeit-Fenster um die aktuelle lokale Zeit — von
// vor drei Minuten bis in vier Minuten — so ist das Fenster unabhängig von
// der Uhrzeit des Testlaufs sicher aktiv, auch wenn der Test (Daemon-Start,
// Race-Detector, langsame CI) über eine Minutengrenze läuft. (±1 Minute
// war zu knapp: ein Lauf um hh:mm:59 kippte in die Inaktivität.)
func fensterUm() string {
// windowAround places a quiet-hours window around the current local time —
// from three minutes ago until four minutes from now — so the window is
// safely active regardless of the time of the test run, even when the test
// (daemon start, race detector, slow CI) crosses a minute boundary. (±1
// minute was too tight: a run at hh:mm:59 tipped into inactivity.)
func windowAround() string {
now := time.Now()
return now.Add(-3*time.Minute).Format("15:04") + "-" + now.Add(4*time.Minute).Format("15:04")
}
// lawStatus ist die Gesetz-Sicht aus GET /status.
// lawStatus is the law view from GET /status.
type lawStatus struct {
RateLimit struct {
Interval string `json:"interval"`
@@ -39,7 +39,7 @@ type lawStatus struct {
} `json:"quiet_hours"`
}
// getLawStatus ruft GET /status und dekodiert nur die Gesetz-Felder.
// getLawStatus calls GET /status and decodes only the law fields.
func getLawStatus(t *testing.T, d *daemonProc) lawStatus {
t.Helper()
resp, err := http.Get(d.baseURL + "/status")
@@ -49,13 +49,13 @@ func getLawStatus(t *testing.T, d *daemonProc) lawStatus {
defer resp.Body.Close()
var st lawStatus
if err := json.NewDecoder(resp.Body).Decode(&st); err != nil {
t.Fatalf("GET /status dekodieren: %v", err)
t.Fatalf("decoding GET /status: %v", err)
}
return st
}
// discardedOf liest das discarded-Feld einer /speak-Antwort-Map — fehlt der
// Schlüssel (JSON-Decode in map[string]any liefert nil), ist er leer.
// discardedOf reads the discarded field of a /speak response map — when the
// key is missing (JSON decode into map[string]any yields nil) it is empty.
func discardedOf(m map[string]any) string {
if v, ok := m["discarded"].(string); ok {
return v
@@ -63,104 +63,104 @@ func discardedOf(m map[string]any) string {
return ""
}
// TestRateLimitGesetz (Abnahmekriterium): kurzes Rate-Limit (1/s) — von
// fünf Ansagen spielt genau die erste sofort; low verfällt noch beim
// Eintritt (202 nennt den Grund), normal parkt und spielt nach dem Fenster.
func TestRateLimitGesetz(t *testing.T) {
// TestRateLimitLaw (acceptance criterion): short rate limit (1/s) — of five
// announcements exactly the first plays immediately; low expires right on
// admission (202 names the reason), normal parks and plays after the window.
func TestRateLimitLaw(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 100*time.Millisecond)
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "1s"})
env := daemonEnv(root, d.port, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "1s"})
code, erste := speak(t, d, `{"text":"erste ansage"}`)
if code != 202 || erste["id"] == "" {
t.Fatalf("erste: Status=%d Body=%v, want 202 mit id", code, erste)
code, first := speak(t, d, `{"text":"first announcement"}`)
if code != 202 || first["id"] == "" {
t.Fatalf("first: status=%d body=%v, want 202 with id", code, first)
}
fake.waitArrived(t, 1) // erste läuft — das Fenster (1 s) ist gebucht.
fake.waitArrived(t, 1) // first is running — the window (1 s) is booked.
// CLI-Beobachtung: talk say --priority low meldet den Verwurf plain.
res := runTalk(t, env, "say", "--priority", "low", "spam eins")
// CLI observation: talk say --priority low reports the discard plainly.
res := runTalk(t, env, "say", "--priority", "low", "spam one")
if res.code != 0 {
t.Fatalf("talk say --priority low: Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say --priority low: exit %d, stderr: %s", res.code, res.stderr)
}
if res.stdout != "discarded: rate_limit\n" {
t.Errorf("stdout = %q, want %q", res.stdout, "discarded: rate_limit\n")
}
// Zwei weitere low-Ansagen verfallen ebenso beim Eintritt.
for _, text := range []string{"spam zwei", "spam drei"} {
// Two more low announcements expire on admission as well.
for _, text := range []string{"spam two", "spam three"} {
code, m := speak(t, d, `{"text":"`+text+`","priority":"low"}`)
if code != 202 || m["id"] != "" || m["discarded"] != "rate_limit" {
t.Fatalf("low %s: Status=%d Body=%v, want 202 mit leerer id und discarded=rate_limit", text, code, m)
t.Fatalf("low %s: status=%d body=%v, want 202 with empty id and discarded=rate_limit", text, code, m)
}
}
// Normal wird aufgenommen …
code, geparkte := speak(t, d, `{"text":"geparkte ansage"}`)
if code != 202 || geparkte["id"] == "" || discardedOf(geparkte) != "" {
t.Fatalf("normal: Status=%d Body=%v, want 202 mit id", code, geparkte)
// Normal is admitted …
code, parked := speak(t, d, `{"text":"parked announcement"}`)
if code != 202 || parked["id"] == "" || discardedOf(parked) != "" {
t.Fatalf("normal: status=%d body=%v, want 202 with id", code, parked)
}
// … und sichtbar geparkt: Queue zeigt sie, der nächste Slot liegt in
// der Zukunft.
// … and visibly parked: the queue shows it, the next slot lies in the
// future.
st := getStatus(t, d)
if st.Queue != 1 || len(st.QueueIDs) != 1 || st.QueueIDs[0] != geparkte["id"] {
t.Fatalf("queue = %v (%d), will die geparkte Ansage", st.QueueIDs, st.Queue)
if st.Queue != 1 || len(st.QueueIDs) != 1 || st.QueueIDs[0] != parked["id"] {
t.Fatalf("queue = %v (%d), want the parked announcement", st.QueueIDs, st.Queue)
}
law := getLawStatus(t, d)
if law.RateLimit.Interval != "1s" || !law.RateLimit.AllowHighBurst {
t.Errorf("rate_limit = %+v, want Interval 1s mit Burst-Recht", law.RateLimit)
t.Errorf("rate_limit = %+v, want interval 1s with burst rights", law.RateLimit)
}
if law.RateLimit.NextSlot == nil {
t.Errorf("next_slot = null während geparkter Ansage, want Termin")
t.Errorf("next_slot = null while an announcement is parked, want a time")
}
// Genau eine ist bislang angekommen: die geparkte spielt NICHT vor
// dem Fenster-Ablauf (Abstand zum Fenster-Rand reichlich).
// Exactly one has arrived so far: the parked one does NOT play before
// the window expires (ample distance to the window edge).
fake.assertNoFurtherArrivals(t, 400*time.Millisecond)
// Nach dem Fenster spielt die geparkte — und zwar frühestens zum Slot.
// After the window the parked one plays — at the slot at the earliest.
hits := fake.waitCompleted(t, 2)
if hits[1].Text != "geparkte ansage" {
t.Errorf("zweiter Treffer = %q, will die geparkte Ansage", hits[1].Text)
if hits[1].Text != "parked announcement" {
t.Errorf("second hit = %q, want the parked announcement", hits[1].Text)
}
if gap := hits[1].Start.Sub(hits[0].Start); gap < 850*time.Millisecond {
t.Errorf("zweiter Start nach %v, want >= Fenster (1 s, Toleranz 150 ms)", gap)
t.Errorf("second start after %v, want >= window (1 s, tolerance 150 ms)", gap)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestHighBurstProzess: mit Burst-Recht (Default) startet high trotz
// vollem Fensters sofort nach der laufenden Ansage; ohne Recht parkt high
// wie normal. (Das spätere Abspielen nach langem Fenster deckt der
// Park-Test oben mit kurzem Fenster.)
func TestHighBurstProzess(t *testing.T) {
t.Run("burst an (Default)", func(t *testing.T) {
// TestHighBurstProcess: with burst rights (default) high starts immediately
// after the running announcement despite a full window; without rights high
// parks like normal. (The later playback after a long window is covered by
// the parking test above with a short window.)
func TestHighBurstProcess(t *testing.T) {
t.Run("burst on (default)", func(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 50*time.Millisecond)
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "8s"})
speak(t, d, `{"text":"erste"}`)
fake.waitArrived(t, 1) // Fenster für 8 s gebucht.
speak(t, d, `{"text":"first"}`)
fake.waitArrived(t, 1) // window booked for 8 s.
code, m := speak(t, d, `{"text":"dringend","priority":"high"}`)
code, m := speak(t, d, `{"text":"urgent","priority":"high"}`)
if code != 202 || m["id"] == "" {
t.Fatalf("high: Status=%d Body=%v, want 202 mit id", code, m)
t.Fatalf("high: status=%d body=%v, want 202 with id", code, m)
}
// high startet, sobald die erste fertig ist — bewusst innerhalb
// des Fensters (Verstoß mit Recht).
// high starts as soon as the first is done — deliberately inside
// the window (violation with rights).
hits := fake.waitCompleted(t, 2)
if hits[1].Text != "dringend" {
t.Errorf("zweiter Treffer = %q, will dringend", hits[1].Text)
if hits[1].Text != "urgent" {
t.Errorf("second hit = %q, want urgent", hits[1].Text)
}
if gap := hits[1].Start.Sub(hits[0].Start); gap >= 4*time.Second {
t.Errorf("high startete nach %v — Burst greift nicht (Fenster 8 s)", gap)
t.Errorf("high started after %v — burst does not take effect (window 8 s)", gap)
}
waitStatusIdle(t, d, 3*time.Second)
})
t.Run("burst aus", func(t *testing.T) {
t.Run("burst off", func(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
@@ -168,130 +168,131 @@ func TestHighBurstProzess(t *testing.T) {
"TALK_ALLOW_HIGH_BURST": "false",
})
speak(t, d, `{"text":"erste"}`)
speak(t, d, `{"text":"first"}`)
fake.waitArrived(t, 1)
code, m := speak(t, d, `{"text":"dringend","priority":"high"}`)
code, m := speak(t, d, `{"text":"urgent","priority":"high"}`)
if code != 202 || m["id"] == "" {
t.Fatalf("high: Status=%d Body=%v, want 202 mit id (parkt)", code, m)
t.Fatalf("high: status=%d body=%v, want 202 with id (parks)", code, m)
}
// high bleibt geparkt: keine zweite Ankunft, Queue zeigt es.
// high stays parked: no second arrival, the queue shows it.
fake.assertNoFurtherArrivals(t, 700*time.Millisecond)
st := getStatus(t, d)
if st.Queue != 1 || len(st.QueueIDs) != 1 || st.QueueIDs[0] != m["id"] {
t.Fatalf("queue = %v (%d), will geparktes high", st.QueueIDs, st.Queue)
t.Fatalf("queue = %v (%d), want the parked high", st.QueueIDs, st.Queue)
}
if law := getLawStatus(t, d); law.RateLimit.AllowHighBurst {
t.Error("allow_high_burst = true, want false")
}
// Aufräumen über cancel — und der Daemon bleibt gesund (now
// umgeht das weiter laufende 8-s-Fenster).
// Clean up via cancel — and the daemon stays healthy (now bypasses
// the still running 8 s window).
code, cm := postPath(t, d, "/cancel")
if code != 200 || cm["cancelled"] != float64(1) {
t.Fatalf("cancel: %d %v, want 1 geparkte Ansage", code, cm)
t.Fatalf("cancel: %d %v, want 1 parked announcement", code, cm)
}
speak(t, d, `{"text":"danach","now":true}`)
speak(t, d, `{"text":"afterwards","now":true}`)
hits := fake.waitCompleted(t, 2)
if hits[1].Text != "danach" {
t.Errorf("Treffer nach cancel = %q, want danach", hits[1].Text)
if hits[1].Text != "afterwards" {
t.Errorf("hit after cancel = %q, want afterwards", hits[1].Text)
}
})
}
// TestNowBypassesRateUndRuhe: now ist der dokumentierte Ausbruch — es
// spielt trotz vollem Rate-Fensters UND aktiver Ruhezeit.
func TestNowBypassesRateUndRuhe(t *testing.T) {
// TestNowBypassesRateAndQuiet: now is the documented breakout — it plays
// despite a full rate window AND active quiet hours.
func TestNowBypassesRateAndQuiet(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_RATE_LIMIT": "30s",
"TALK_QUIET_HOURS": fensterUm(),
"TALK_QUIET_HOURS": windowAround(),
})
// Normal verfällt in der Ruhezeit (hier: still).
code, m := speak(t, d, `{"text":"ruhig bitte"}`)
// Normal expires during quiet hours (here: silently).
code, m := speak(t, d, `{"text":"quiet please"}`)
if code != 202 || m["id"] != "" || m["discarded"] != "quiet_hours" {
t.Fatalf("normal in Ruhezeit: Status=%d Body=%v, want 202 mit leerer id und discarded=quiet_hours", code, m)
t.Fatalf("normal during quiet hours: status=%d body=%v, want 202 with empty id and discarded=quiet_hours", code, m)
}
// now bricht durch: Aufnahme mit ID, Synthese trotz beider Gesetze.
code, m = speak(t, d, `{"text":"notfall","now":true}`)
// now breaks through: admission with ID, synthesis despite both laws.
code, m = speak(t, d, `{"text":"emergency","now":true}`)
if code != 202 || m["id"] == "" || discardedOf(m) != "" {
t.Fatalf("now: Status=%d Body=%v, want 202 mit id", code, m)
t.Fatalf("now: status=%d body=%v, want 202 with id", code, m)
}
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "notfall" {
t.Errorf("Treffer = %q, will notfall", hits[0].Text)
if hits[0].Text != "emergency" {
t.Errorf("hit = %q, want emergency", hits[0].Text)
}
fake.assertNoFurtherArrivals(t, 300*time.Millisecond)
if law := getLawStatus(t, d); !law.QuietHours.Active {
t.Errorf("quiet_hours.active = false, want true (Fenster um jetzt): %+v", law.QuietHours)
t.Errorf("quiet_hours.active = false, want true (window around now): %+v", law.QuietHours)
}
}
// TestRuhezeitDiscard: aktives Fenster verwirft normal UND high still
// (Burst ist ein Rate-Recht, kein Ruhe-Recht) — ohne Provider-Kontakt,
// mit Verwurf-Zeile im Daemon-Protokoll und Fenster-Ende in /status.
func TestRuhezeitDiscard(t *testing.T) {
// TestQuietHoursDiscard: an active window discards normal AND high silently
// (burst is a rate right, not a quiet-hours right) — without provider
// contact, with a discard line in the daemon log and the window end in
// /status.
func TestQuietHoursDiscard(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
fenster := fensterUm()
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_QUIET_HOURS": fenster})
window := windowAround()
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_QUIET_HOURS": window})
for _, body := range []string{
`{"text":"abends eins"}`,
`{"text":"abends dringend","priority":"high"}`,
`{"text":"evening one"}`,
`{"text":"evening urgent","priority":"high"}`,
} {
code, m := speak(t, d, body)
if code != 202 || m["id"] != "" || m["discarded"] != "quiet_hours" || m["toast"] != nil {
t.Fatalf("%s: Status=%d Body=%v, want 202 mit leerer id und discarded=quiet_hours", body, code, m)
t.Fatalf("%s: status=%d body=%v, want 202 with empty id and discarded=quiet_hours", body, code, m)
}
}
fake.assertNoHits(t, 400*time.Millisecond)
log := d.stderr.String()
if !strings.Contains(log, "ruhezeit") || !strings.Contains(log, "verworfen") {
t.Errorf("Daemon-Log will Verwurf-Zeile mit ruhezeit, hat:\n%s", log)
if !strings.Contains(log, "quiet hours") || !strings.Contains(log, "discarded") {
t.Errorf("daemon log should have a discard line mentioning quiet hours, has:\n%s", log)
}
law := getLawStatus(t, d)
if law.QuietHours.Window != fenster || !law.QuietHours.Active || law.QuietHours.Mode != "discard" {
t.Errorf("quiet_hours = %+v, want Fenster %q aktiv im Modus discard", law.QuietHours, fenster)
if law.QuietHours.Window != window || !law.QuietHours.Active || law.QuietHours.Mode != "discard" {
t.Errorf("quiet_hours = %+v, want window %q active in mode discard", law.QuietHours, window)
}
if law.QuietHours.Until == nil {
t.Error("quiet_hours.until = null im aktiven Fenster, want Termin")
t.Error("quiet_hours.until = null in an active window, want a time")
}
}
// TestRuhezeitToast: quiet_mode=toast ersetzt Audio durch eine
// Benachrichtigung — derzeit eine Daemon-Protokoll-Zeile über den
// Notification-Pfad (dokumentierte Einschränkung bis zur OS-Anbindung).
func TestRuhezeitToast(t *testing.T) {
// TestQuietHoursToast: quiet_mode=toast replaces audio with a notification —
// currently a daemon log line via the notification path (documented
// limitation until the OS integration).
func TestQuietHoursToast(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_QUIET_HOURS": fensterUm(),
"TALK_QUIET_HOURS": windowAround(),
"TALK_QUIET_MODE": "toast",
})
code, m := speak(t, d, `{"text":"abend kurz"}`)
code, m := speak(t, d, `{"text":"evening short"}`)
if code != 202 || m["id"] != "" || m["discarded"] != "quiet_hours" || m["toast"] != true {
t.Fatalf("toast: Status=%d Body=%v, want 202 mit leerer id, discarded=quiet_hours und toast=true", code, m)
t.Fatalf("toast: status=%d body=%v, want 202 with empty id, discarded=quiet_hours and toast=true", code, m)
}
fake.assertNoHits(t, 300*time.Millisecond)
if log := d.stderr.String(); !strings.Contains(log, "toast") {
t.Errorf("Daemon-Log will Toast-Zeile (Notification-Pfad), hat:\n%s", log)
t.Errorf("daemon log should have a toast line (notification path), has:\n%s", log)
}
}
// TestStatusGesetzeFelder (Abnahmekriterium): /status und `talk status`
// zeigen Rate-Limit- und Ruhezeit-State — Defaults im Leerlauf, gebuchter
// Slot nach einer Ansage.
func TestStatusGesetzeFelder(t *testing.T) {
// TestStatusLawFields (acceptance criterion): /status and `talk status` show
// rate-limit and quiet-hours state — defaults when idle, a booked slot after
// an announcement.
func TestStatusLawFields(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "30s"})
@@ -299,35 +300,35 @@ func TestStatusGesetzeFelder(t *testing.T) {
law := getLawStatus(t, d)
if law.RateLimit.Interval != "30s" || !law.RateLimit.AllowHighBurst || law.RateLimit.NextSlot != nil {
t.Errorf("rate_limit = %+v, want Default 30s, Burst an, Slot frei", law.RateLimit)
t.Errorf("rate_limit = %+v, want default 30s, burst on, slot free", law.RateLimit)
}
if law.QuietHours.Window != "" || law.QuietHours.Active || law.QuietHours.Mode != "discard" || law.QuietHours.Until != nil {
t.Errorf("quiet_hours = %+v, want deaktiviert im Modus discard", law.QuietHours)
t.Errorf("quiet_hours = %+v, want disabled in mode discard", law.QuietHours)
}
res := runTalk(t, env, "status")
if res.code != 0 {
t.Fatalf("talk status: Exit %d — stderr: %s", res.code, res.stderr)
t.Fatalf("talk status: exit %d — stderr: %s", res.code, res.stderr)
}
for _, want := range []string{
"rate-limit: 30s",
"high-burst erlaubt",
"ruhezeit: aus",
"modus discard",
"rate-limit: 30s",
"high burst allowed",
"quiet-hours: off",
"mode discard",
} {
if !strings.Contains(res.stdout, want) {
t.Errorf("talk status stdout = %q, will %q", res.stdout, want)
t.Errorf("talk status stdout = %q, want %q", res.stdout, want)
}
}
// Nach einer Ansage steht der nächste Slot: HTTP-Feld + CLI-Zeile.
speak(t, d, `{"text":"einmal reicht"}`)
// After an announcement the next slot is set: HTTP field + CLI line.
speak(t, d, `{"text":"once is enough"}`)
fake.waitArrived(t, 1)
if law := getLawStatus(t, d); law.RateLimit.NextSlot == nil {
t.Error("next_slot = null nach abgespielter Ansage, want Termin")
t.Error("next_slot = null after a played announcement, want a time")
}
res = runTalk(t, env, "status")
if res.code != 0 || !strings.Contains(res.stdout, "nächster slot") {
t.Errorf("talk status nach Ansage = %q (Exit %d), will Slot-Zeile", res.stdout, res.code)
if res.code != 0 || !strings.Contains(res.stdout, "next slot") {
t.Errorf("talk status after announcement = %q (exit %d), want the slot line", res.stdout, res.code)
}
}
+76 -55
View File
@@ -1,8 +1,8 @@
// wrap-Prozessgrenzen-Tests (Task 07): talk läuft als echter Prozess,
// talkd als echter Daemon, Fake-Provider + sink null wie im Harness —
// beobachtet wird nur extern: Exit-Codes, Befehls-stdout, Ansage-Text am
// Fake. Die gewrappten Befehle sind echte Binaries (sleep/sh/true), die
// Dauern bewusst klein.
// wrap process-boundary tests (Task 07): talk runs as a real process, talkd
// as a real daemon, fake provider + sink null as in the harness — only the
// externally observable is checked: exit codes, command stdout, announcement
// text at the fake. The wrapped commands are real binaries (sleep/sh/true),
// the durations deliberately small.
package test
import (
@@ -12,9 +12,9 @@ import (
"time"
)
// TestWrapErfolgMitDauer (Abnahmekriterium): `talk wrap -- sleep 1`
// annonciert Erfolg inklusive Dauer und exitet 0.
func TestWrapErfolgMitDauer(t *testing.T) {
// TestWrapSuccessWithDuration (acceptance criterion): `talk wrap -- sleep 1`
// announces success including the duration and exits 0.
func TestWrapSuccessWithDuration(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
@@ -22,20 +22,21 @@ func TestWrapErfolgMitDauer(t *testing.T) {
res := runTalk(t, env, "wrap", "--", "sleep", "1")
if res.code != 0 {
t.Fatalf("talk wrap -- sleep 1: Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk wrap -- sleep 1: exit %d, stderr: %s", res.code, res.stderr)
}
hits := fake.waitCompleted(t, 1)
// sleep 1 misst ~1 s (unter Last gern mehr) — Zahl offen, Form nicht.
if !regexp.MustCompile(`^sleep erfolgreich\. Dauer: \d+ Sekunden?\.$`).MatchString(hits[0].Text) {
t.Errorf("Ansage = %q, will „sleep erfolgreich. Dauer: N Sekunde(n).“", hits[0].Text)
// sleep 1 measures ~1 s (often more under load) — the number is open,
// the form is not.
if !regexp.MustCompile(`^sleep succeeded\. Duration: \d+ seconds?\.$`).MatchString(hits[0].Text) {
t.Errorf("announcement = %q, want \"sleep succeeded. Duration: N second(s).\"", hits[0].Text)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestWrapFehlschlagCodeDurchgereicht (Abnahmekriterium): scheitert der
// Befehl mit Code 2, exitet wrap mit 2 und sagt den Misserfolg mit Code —
// ohne Dauer, wortgenau nach Brief.
func TestWrapFehlschlagCodeDurchgereicht(t *testing.T) {
// TestWrapFailureCodePassedThrough (acceptance criterion): when the command
// fails with code 2, wrap exits 2 and announces the failure with the code —
// without duration, word for word as briefed.
func TestWrapFailureCodePassedThrough(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
@@ -43,19 +44,39 @@ func TestWrapFehlschlagCodeDurchgereicht(t *testing.T) {
res := runTalk(t, env, "wrap", "--", "sh", "-c", "exit 2")
if res.code != 2 {
t.Fatalf("talk wrap -- sh -c 'exit 2': Exit %d, want 2 (Durchreichung)", res.code)
t.Fatalf("talk wrap -- sh -c 'exit 2': exit %d, want 2 (passed through)", res.code)
}
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "sh fehlgeschlagen, Code 2." {
t.Errorf("Ansage = %q, want %q", hits[0].Text, "sh fehlgeschlagen, Code 2.")
if hits[0].Text != "sh failed, code 2." {
t.Errorf("announcement = %q, want %q", hits[0].Text, "sh failed, code 2.")
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestWrapFlagShielding (Regression, Task-07-Kern): `talk wrap -- true
// --bar` reicht --bar an den Befehl weiter — talk darf --bar NICHT als
// eigenes Flag parsen (wäre Exit 2 mit Flag-Fehler). GNU true ignoriert
// Operanden, exitet also 0, und die Erfolg-Ansage läuft.
// TestWrapGermanAnnouncement: TALK_LANG=de switches talk's own announcement
// to German (the detection cannot decide on a bare label like "sh" and
// falls back to the configured language).
func TestWrapGermanAnnouncement(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
env := daemonEnv(root, d.port, fake.srv.URL, map[string]string{"TALK_LANG": "de"})
res := runTalk(t, env, "wrap", "--", "sh", "-c", "exit 2")
if res.code != 2 {
t.Fatalf("talk wrap -- sh -c 'exit 2' (de): exit %d, want 2", res.code)
}
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "sh fehlgeschlagen, Code 2." {
t.Errorf("announcement = %q, want %q", hits[0].Text, "sh fehlgeschlagen, Code 2.")
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestWrapFlagShielding (regression, core of Task 07): `talk wrap -- true
// --bar` passes --bar on to the command — talk must NOT parse --bar as its
// own flag (that would be exit 2 with a flag error). GNU true ignores
// operands, so it exits 0, and the success announcement runs.
func TestWrapFlagShielding(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
@@ -64,20 +85,20 @@ func TestWrapFlagShielding(t *testing.T) {
res := runTalk(t, env, "wrap", "--", "true", "--bar")
if res.code != 0 {
t.Fatalf("talk wrap -- true --bar: Exit %d, want 0 (--bar gehört dem Befehl) — stderr: %s", res.code, res.stderr)
t.Fatalf("talk wrap -- true --bar: exit %d, want 0 (--bar belongs to the command) — stderr: %s", res.code, res.stderr)
}
if strings.Contains(res.stderr, "flag provided but not defined") {
t.Errorf("talk hat --bar als eigenes Flag geparsed — Shielding kaputt: %s", res.stderr)
t.Errorf("talk parsed --bar as its own flag — shielding broken: %s", res.stderr)
}
hits := fake.waitCompleted(t, 1)
if !strings.HasPrefix(hits[0].Text, "true erfolgreich.") {
t.Errorf("Ansage = %q, will Erfolg für true", hits[0].Text)
if !strings.HasPrefix(hits[0].Text, "true succeeded.") {
t.Errorf("announcement = %q, want success for true", hits[0].Text)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestWrapLabelOverride: TALK_WRAP_LABEL ersetzt den Basisnamen in der
// Ansage (Fleet-Muster: Env statt Config-Datei).
// TestWrapLabelOverride: TALK_WRAP_LABEL replaces the base name in the
// announcement (fleet pattern: env instead of config file).
func TestWrapLabelOverride(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
@@ -86,18 +107,18 @@ func TestWrapLabelOverride(t *testing.T) {
res := runTalk(t, env, "wrap", "--", "false")
if res.code != 1 {
t.Fatalf("talk wrap -- false: Exit %d, want 1", res.code)
t.Fatalf("talk wrap -- false: exit %d, want 1", res.code)
}
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "Deployment fehlgeschlagen, Code 1." {
t.Errorf("Ansage = %q, want %q", hits[0].Text, "Deployment fehlgeschlagen, Code 1.")
if hits[0].Text != "Deployment failed, code 1." {
t.Errorf("announcement = %q, want %q", hits[0].Text, "Deployment failed, code 1.")
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestWrapNonBlocking: wrap kehrt nach dem enqueue zurück, bevor die
// Ansage zu Ende gespielt ist (Ansage dauert 600 ms am Fake) — stdout
// gehört dabei allein dem Befehl, die Item-ID geht auf stderr.
// TestWrapNonBlocking: wrap returns after the enqueue, before the
// announcement has finished playing (the announcement takes 600 ms at the
// fake) — stdout belongs to the command alone, the item ID goes to stderr.
func TestWrapNonBlocking(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 600*time.Millisecond)
@@ -105,29 +126,29 @@ func TestWrapNonBlocking(t *testing.T) {
env := daemonEnv(root, d.port, fake.srv.URL, nil)
start := time.Now()
res := runTalk(t, env, "wrap", "--", "sh", "-c", "echo bauausgabe")
res := runTalk(t, env, "wrap", "--", "sh", "-c", "echo build-output")
elapsed := time.Since(start)
if res.code != 0 {
t.Fatalf("talk wrap: Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk wrap: exit %d, stderr: %s", res.code, res.stderr)
}
if elapsed >= 500*time.Millisecond {
t.Fatalf("talk wrap war blockierend (%s; Ansage dauert 600 ms) — Default muss non-blocking sein", elapsed)
t.Fatalf("talk wrap was blocking (%s; announcement takes 600 ms) — the default must be non-blocking", elapsed)
}
if res.stdout != "bauausgabe\n" {
t.Errorf("stdout = %q, will ausschließlich die Befehlsausgabe (Item-ID gehört auf stderr)", res.stdout)
if res.stdout != "build-output\n" {
t.Errorf("stdout = %q, want exclusively the command output (the item ID belongs on stderr)", res.stdout)
}
if strings.TrimSpace(res.stderr) == "" {
t.Errorf("stderr = %q, will mindestens die Item-ID der Ansage", res.stderr)
t.Errorf("stderr = %q, want at least the item ID of the announcement", res.stderr)
}
// Die Ansage läuft im Daemon trotzdem zu Ende.
// The announcement still runs to completion in the daemon.
fake.waitCompleted(t, 1)
waitStatusIdle(t, d, 3*time.Second)
}
// TestWrapWait: `talk wrap --wait -- …` kehrt erst nach Ende der Ansage
// zurück (geerbtes say-Flag, hier: 400 ms Synthese).
// TestWrapWait: `talk wrap --wait -- …` returns only after the announcement
// ends (inherited say flag, here: 400 ms synthesis).
func TestWrapWait(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 400*time.Millisecond)
@@ -139,30 +160,30 @@ func TestWrapWait(t *testing.T) {
elapsed := time.Since(start)
if res.code != 0 {
t.Fatalf("talk wrap --wait: Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk wrap --wait: exit %d, stderr: %s", res.code, res.stderr)
}
if elapsed < 350*time.Millisecond {
t.Fatalf("talk wrap --wait kam zu früh zurück (%s; Ansage dauert 400 ms)", elapsed)
t.Fatalf("talk wrap --wait returned too early (%s; announcement takes 400 ms)", elapsed)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestWrapOhneDaemon: Daemon tot, Autostart aus → Ansage direkt gegen den
// Fake, und der Exit-Code des Befehls bleibt trotzdem.
func TestWrapOhneDaemon(t *testing.T) {
// TestWrapWithoutDaemon: daemon dead, autostart off → announcement directly
// against the fake, and the command's exit code is still preserved.
func TestWrapWithoutDaemon(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot
env := daemonEnv(root, 1, fake.srv.URL, nil) // port 1: daemon guaranteed dead
res := runTalk(t, env, "wrap", "--", "sh", "-c", "exit 3")
if res.code != 3 {
t.Fatalf("talk wrap (direkt): Exit %d, want 3 (Befehls-Code gewinnt)", res.code)
t.Fatalf("talk wrap (direct): exit %d, want 3 (the command's code wins)", res.code)
}
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "sh fehlgeschlagen, Code 3." {
t.Errorf("Ansage = %q, want %q", hits[0].Text, "sh fehlgeschlagen, Code 3.")
if hits[0].Text != "sh failed, code 3." {
t.Errorf("announcement = %q, want %q", hits[0].Text, "sh failed, code 3.")
}
if !strings.Contains(res.stderr, "spreche direkt") {
t.Errorf("stderr = %q, will Direkt-Hinweis (nichts bricht still)", res.stderr)
if !strings.Contains(res.stderr, "speaking directly") {
t.Errorf("stderr = %q, want the direct-path note (nothing fails silently)", res.stderr)
}
}