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: „