From 85fa64cfe640bea78e4e617732ccea49af39159a Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Sat, 24 Oct 2026 11:15:35 +0200 Subject: [PATCH] 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 --- .gitea/workflows/release.yml | 20 +- .gitea/workflows/test.yml | 6 +- .gitignore | 8 +- README.md | 573 ++++++++++++++------------- cmd/talk/cli.go | 257 ++++++------ cmd/talk/cli_test.go | 63 ++- cmd/talk/control.go | 104 +++-- cmd/talk/control_test.go | 89 ++--- cmd/talk/daemon.go | 116 +++--- cmd/talk/daemon_cmd.go | 41 +- cmd/talk/doctor.go | 150 ++++--- cmd/talk/doctor_test.go | 54 +-- cmd/talk/main.go | 28 +- cmd/talk/parse_test.go | 47 ++- cmd/talk/say.go | 69 ++-- cmd/talk/say_test.go | 295 +++++++------- cmd/talk/setup.go | 253 +++++++----- cmd/talk/setup_test.go | 90 +++-- cmd/talk/testcmd.go | 40 +- cmd/talk/testcmd_test.go | 22 +- cmd/talk/voices.go | 26 +- cmd/talk/voices_test.go | 44 +- cmd/talk/wrap.go | 142 ++++--- cmd/talk/wrap_test.go | 188 +++++---- docs/spec.md | 198 ++++----- flake.nix | 24 +- internal/api/client.go | 39 +- internal/api/cors.go | 81 ++-- internal/api/doc.go | 19 +- internal/api/mcp.go | 192 +++++---- internal/api/server.go | 129 +++--- internal/api/types.go | 111 +++--- internal/audio/audio.go | 38 +- internal/audio/audio_test.go | 78 ++-- internal/audio/check.go | 18 +- internal/audio/doc.go | 8 +- internal/audio/mp3.go | 46 +-- internal/audio/mp3_test.go | 84 ++-- internal/audio/oto.go | 35 +- internal/audio/wav.go | 35 +- internal/config/config.go | 232 +++++------ internal/config/config_test.go | 539 +++++++++---------------- internal/config/doc.go | 10 +- internal/config/path.go | 29 +- internal/daemon/daemon.go | 100 ++--- internal/daemon/spawn.go | 23 +- internal/daemon/spawn_other.go | 2 +- internal/daemon/spawn_unix.go | 4 +- internal/daemon/spawn_windows.go | 8 +- internal/langdetect/detect.go | 33 +- internal/langdetect/detect_test.go | 50 +-- internal/langdetect/doc.go | 4 - internal/notify/notify.go | 42 +- internal/notify/notify_test.go | 30 +- internal/provider/catalog.go | 28 +- internal/provider/chain.go | 213 +++++----- internal/provider/chain_test.go | 209 +++++----- internal/provider/doc.go | 21 +- internal/provider/edge.go | 155 ++++---- internal/provider/edge_test.go | 167 ++++---- internal/provider/edgedrm.go | 92 ++--- internal/provider/elevenlabs.go | 66 +-- internal/provider/elevenlabs_test.go | 94 ++--- internal/provider/httpclient.go | 38 +- internal/provider/openai.go | 57 +-- internal/provider/openai_test.go | 58 +-- internal/provider/osbuiltin.go | 138 +++---- internal/provider/osbuiltin_test.go | 130 +++--- internal/provider/provider.go | 91 +++-- internal/provider/voices.go | 43 +- internal/provider/voices_test.go | 22 +- internal/queue/doc.go | 17 +- internal/queue/laws_test.go | 100 ++--- internal/queue/mute.go | 36 +- internal/queue/queue.go | 250 ++++++------ internal/queue/queue_test.go | 472 +++++++++++----------- internal/queue/quiet.go | 48 +-- internal/queue/quiet_test.go | 81 ++-- internal/queue/rate.go | 161 ++++---- internal/speak/engine.go | 76 ++-- scripts/release.sh | 16 +- skills/talk/AGENTS-snippet.md | 16 +- skills/talk/SKILL.md | 109 +++-- test/autostart_test.go | 152 +++---- test/cli_test.go | 177 +++++---- test/cors_test.go | 159 ++++---- test/doctor_test.go | 24 +- test/harness_test.go | 201 +++++----- test/limits_test.go | 62 +-- test/mcp_test.go | 170 ++++---- test/mute_test.go | 60 +-- test/notify_test.go | 22 +- test/queue_test.go | 182 ++++----- test/ratelimit_test.go | 251 ++++++------ test/wrap_test.go | 131 +++--- 95 files changed, 4707 insertions(+), 4854 deletions(-) delete mode 100644 internal/langdetect/doc.go diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 5cc074c..499a370 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,5 +1,5 @@ -# Release-Matrix: bei Tag v* → Tests → Artefakte für alle Plattformen → -# Gitea-Release mit Assets talk__[.exe] + checksums.txt. +# Release matrix: on tag v* -> tests -> artifacts for all platforms -> +# Gitea release with assets talk__[.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__` 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__`, 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 diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 6bcc37d..2e2fb75 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -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 diff --git a/.gitignore b/.gitignore index c7521ca..1a1b320 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 6de1ed6..ca5d1bc 100644 --- a/README.md +++ b/README.md @@ -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 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 -- ` | 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__` 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__` 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..` - 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..` 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: `. `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: `. `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: "` bei Aufnahme; `muted`/`discarded: ` 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: "` on admission; `muted`/`discarded: ` +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=` oder `provider = ""` 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=` or `provider = ""` 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.] 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.] 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..` > Repo-Default. -MP3 wird von der Binärdatei selbst dekodiert (reiner Go-Dekoder, kein -ffmpeg, CGO-frei). +Precedence: `--voice` flag > `voice..` > 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 -- [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 -- [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__[.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__[.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). diff --git a/cmd/talk/cli.go b/cmd/talk/cli.go index 6365a25..142ed69 100644 --- a/cmd/talk/cli.go +++ b/cmd/talk/cli.go @@ -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 --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 --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: "-- [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: "-- [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.. 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.. 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 ` plus -// Commit-Hash in Klammern, falls per ldflags injiziert. +// versionString renders the version output: `talk 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 [Flags] Befehl ausführen - talk help [Befehl] Hilfe anzeigen + talk [flags] "text" speak a text (shorthand for: talk say) + talk [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).") - } } diff --git a/cmd/talk/cli_test.go b/cmd/talk/cli_test.go index 9d0454c..a5694fa 100644 --- a/cmd/talk/cli_test.go +++ b/cmd/talk/cli_test.go @@ -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()) } } diff --git a/cmd/talk/control.go b/cmd/talk/control.go index ed1db0a..e14a2df 100644 --- a/cmd/talk/control.go +++ b/cmd/talk/control.go @@ -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) diff --git a/cmd/talk/control_test.go b/cmd/talk/control_test.go index cd67c76..bd5f938 100644 --- a/cmd/talk/control_test.go +++ b/cmd/talk/control_test.go @@ -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()) } } diff --git a/cmd/talk/daemon.go b/cmd/talk/daemon.go index 5a0d483..dfc0ff1 100644 --- a/cmd/talk/daemon.go +++ b/cmd/talk/daemon.go @@ -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) { diff --git a/cmd/talk/daemon_cmd.go b/cmd/talk/daemon_cmd.go index dfe5888..b837dfe 100644 --- a/cmd/talk/daemon_cmd.go +++ b/cmd/talk/daemon_cmd.go @@ -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 } diff --git a/cmd/talk/doctor.go b/cmd/talk/doctor.go index 94c47bb..587a79f 100644 --- a/cmd/talk/doctor.go +++ b/cmd/talk/doctor.go @@ -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() } diff --git a/cmd/talk/doctor_test.go b/cmd/talk/doctor_test.go index d45f9e4..4d44704 100644 --- a/cmd/talk/doctor_test.go +++ b/cmd/talk/doctor_test.go @@ -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()) } } } diff --git a/cmd/talk/main.go b/cmd/talk/main.go index d52a4b0..7990604 100644 --- a/cmd/talk/main.go +++ b/cmd/talk/main.go @@ -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 -- ` 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 -- ` 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 diff --git a/cmd/talk/parse_test.go b/cmd/talk/parse_test.go index 7e3ce22..0abcc00 100644 --- a/cmd/talk/parse_test.go +++ b/cmd/talk/parse_test.go @@ -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) } }) } diff --git a/cmd/talk/say.go b/cmd/talk/say.go index 05e6093..80687c0 100644 --- a/cmd/talk/say.go +++ b/cmd/talk/say.go @@ -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 { diff --git a/cmd/talk/say_test.go b/cmd/talk/say_test.go index 7babc4f..dbac0d4 100644 --- a/cmd/talk/say_test.go +++ b/cmd/talk/say_test.go @@ -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") } } diff --git a/cmd/talk/setup.go b/cmd/talk/setup.go index 41a179f..06c6dcd 100644 --- a/cmd/talk/setup.go +++ b/cmd/talk/setup.go @@ -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.) 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.) 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 { diff --git a/cmd/talk/setup_test.go b/cmd/talk/setup_test.go index f0edddf..f99f0a9 100644 --- a/cmd/talk/setup_test.go +++ b/cmd/talk/setup_test.go @@ -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()) } } diff --git a/cmd/talk/testcmd.go b/cmd/talk/testcmd.go index 6b15313..758252f 100644 --- a/cmd/talk/testcmd.go +++ b/cmd/talk/testcmd.go @@ -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 } diff --git a/cmd/talk/testcmd_test.go b/cmd/talk/testcmd_test.go index 211d3a3..e6f7304 100644 --- a/cmd/talk/testcmd_test.go +++ b/cmd/talk/testcmd_test.go @@ -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()) } } diff --git a/cmd/talk/voices.go b/cmd/talk/voices.go index d4193d8..1f36f11 100644 --- a/cmd/talk/voices.go +++ b/cmd/talk/voices.go @@ -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 einmalig, oder dauerhaft in der config.toml unter [voice.] de = \"\".") + fmt.Fprintln(stderr, "Pick a voice: --voice once, or permanently in config.toml under [voice.] de = \"\".") 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 } diff --git a/cmd/talk/voices_test.go b/cmd/talk/voices_test.go index 9ea0f0f..d60a8d5 100644 --- a/cmd/talk/voices_test.go +++ b/cmd/talk/voices_test.go @@ -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()) } } diff --git a/cmd/talk/wrap.go b/cmd/talk/wrap.go index 2ce8363..f5ebec4 100644 --- a/cmd/talk/wrap.go +++ b/cmd/talk/wrap.go @@ -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 -- -// [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: „