Files
talk/README.md
T

420 lines
21 KiB
Markdown
Raw Normal View History

# talk
> **Status: WIP**, under active development. Specification with the decision
> addendum: [`docs/spec.md`](docs/spec.md)
**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.
## Commands
| Command | Purpose |
| --- | --- |
| `talk "text"` | announce; shorthand for `talk say`, non-blocking |
| `talk say "text"` | announce (text as argument or from stdin) |
| `talk wrap -- <command>` | run a command, announce success/failure and duration |
| `talk mute` / `talk unmute` | mute all announcements persistently / turn them back on |
| `talk status` | daemon status (queue, mute, provider, laws, version) |
| `talk cancel` | cancel the playing and all queued announcements |
| `talk daemon run\|start\|stop` | run the daemon in the foreground / start it in the background / stop it (normally not needed, see below) |
| `talk voices` | known voices per provider and language, default marked (`--provider`, `--lang`, `--json`) |
| `talk test` | speak a test sentence; shows provider and timings (`--lang`, `--provider`, `--voice`) |
| `talk doctor` | self-diagnosis: config, daemon, audio, providers, notifications, mute, laws (`--probe`, `--json`) |
| `talk setup` | first-time setup in a few questions: language, voices with preview, quiet hours, config.toml |
Say flags (also for `talk "text"`): `--wait`, `--now`, `--priority`,
`--voice`, `--lang`, `--provider`.
## Installation
**Release binary** (Linux, macOS, Windows; amd64/arm64): download the
matching `talk_<os>_<arch>` from the
[releases](https://code.m3ta.dev/m3tam3re/talk/releases), verify the
checksum against `checksums.txt`, put it in your PATH as `talk`. No other
dependencies.
```console
curl -LO https://code.m3ta.dev/m3tam3re/talk/releases/latest/download/talk_linux_amd64
sha256sum -c --ignore-missing <(curl -sL https://code.m3ta.dev/m3tam3re/talk/releases/latest/download/checksums.txt)
install -m 755 talk_linux_amd64 ~/.local/bin/talk
```
**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.
**From source:** `go install code.m3ta.dev/m3tam3re/talk/cmd/talk@latest`.
## Getting started
```console
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.
```
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.
### Diagnosis and setup
- **`talk doctor`** reports one line per item with ✔ (ok), ⚠ (note), ✘
(error): config file, daemon (running / starts on the next speak command
/ foreign service on the port), audio output (can oto initialize? `null`
= no sound), every provider tier (key present, endpoint, default voice;
OS voice: which mechanism was found), notifications, mute, laws
including an active quiet window. Exit 0 without ✘, else 1; a daemon that
is not running is ⚠. `--probe` runs real synthesis probes (network),
`--json` returns the list for machines. Windows consoles get
`[OK]`/`[WARN]`/`[FAIL]` instead of the symbols.
- **`talk test`** speaks a fixed test sentence directly in this process (not
via the daemon; mute and quiet hours do not apply here, an active mute is
mentioned) and shows which tier spoke and how long synthesis and playback
took.
- **`talk voices`** lists the curated voice catalog (ElevenLabs premade,
OpenAI, Edge neural voices per locale) as a table; `*` marks the voice
talk uses without `--voice` (config `voice.<provider>.<lang>` or the
built-in default). Config entries outside the catalog appear as their own
row. Any voice ID the provider knows works without a catalog entry.
- **`talk setup`** asks in the terminal: keys (only a hint; without a key
the Edge voice speaks), the fallback language, a voice for each language
of the active tier (number from the catalog or a voice ID, preview,
accept), quiet hours. Then it writes config.toml, merging an existing
file (other settings stay) and backing it up as `.bak`, and speaks a
test sentence. `q` aborts at any point; the file is written only at the
very end. Without a terminal (pipe, agent) setup asks nothing and only
shows the effective configuration.
### Language
talk detects German and English per announcement (word lists plus umlauts;
no ML) and picks the voice accordingly. When it cannot tell (numbers,
single words, ties) it uses the configured fallback language, which is also
the language of talk's own announcements such as the `wrap` result:
```toml
lang = "en" # de | en; default en (env TALK_LANG)
```
## Daemon
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:** when no daemon runs, `talk say` (and `talk wrap`) start it on
the first speak command, detached from the terminal (on Windows also broken
away from the caller's job object, so a WinRM, SSH or CI session does not
take it down), 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
```
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.
| Endpoint | Effect |
| --- | --- |
| `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` | 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 behaviour:** `talk say` (and `talk "text"`) detects the daemon via
`GET /status`. If it runs: enqueue and return at once (non-blocking,
stdout gets the item ID); `--wait` polls until spoken; `--now` jumps the
queue; when muted the CLI prints `muted`, on a law discard
`discarded: <reason>`. `talk mute`/`unmute`/`status`/`cancel` are daemon
calls with plain output.
- **Mute is persistent** (file `mute` in the config directory, content
`true`/`false`) and survives daemon restarts. `talk mute` without a
running daemon writes the file directly; the daemon re-reads it on every
announcement, so the state applies offline too.
- **Direct path:** if no daemon is reachable and none can be started
(autostart off, port taken by something else, start failed), `talk`
speaks directly in its own process: blocking, without a queue (parallel
calls may overlap), with a note and the reason on stderr. Mute is
honoured here too. Nothing fails silently.
### Rate limit, quiet hours, length and age
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` 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).
**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): 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).
**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 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.
**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" # 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" # empty = off; env TALK_QUIET_HOURS ("off" = off)
quiet_mode = "discard" # discard | toast (TALK_QUIET_MODE)
```
`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 door and MCP endpoint
**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.example.com"] # env TALK_ALLOWED_ORIGINS
```
- `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`.
- 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): 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",
"inputSchema": {"type": "object",
"properties": {"text": {"type": "string"},
"priority": {"enum": ["low","normal","high"]},
"provider": {"type": "string"},
"lang": {"enum": ["de","en"]},
"now": {"type": "boolean"}},
"required": ["text"]}}
```
`tools/call` takes exactly the same admission path as `POST /speak`
(including mute, rate limit, quiet hours, truncation). The result is always
text content: `"queued: <id>"` on admission; `muted`/`discarded: <reason>`
with an explanation, deliberately `isError: false`, because a discard is
the correct documented result, not a protocol error. Validation errors
(missing text, invalid priority, unknown tool) are JSON-RPC errors
(`-32602`). Handshake as a curl sequence:
```sh
curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"speak","arguments":{"text":"Build done"}}}'
```
### Provider chain
Without a pin, the four-tier fallback chain runs
**ElevenLabs → OpenAI TTS → Edge TTS → OS built-in**: on a missing key, an
HTTP error, a timeout or a network problem the chain falls through to the
next tier automatically; every fallback is logged to stderr (never silent).
Pin with `--provider=<name>` or `provider = "<name>"` in config.toml.
| 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` | none (free) | `wss://speech.platform.bing.com/...` | mp3 |
| `osbuiltin` | none | local OS TTS | WAV/PCM |
Keys come exclusively from environment variables (never config.toml).
Endpoints are configurable: file `[providers.<name>] endpoint = "…"` or env
`TALK_ELEVENLABS_ENDPOINT` / `TALK_OPENAI_ENDPOINT` / `TALK_EDGE_ENDPOINT`
(env beats the file; this is how tests and CI point the chain at fake
servers). The OpenAI model is configurable (`[providers.openai] model`,
default `tts-1`).
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" # your own voice per language
en = "21m00Tcm4TlvDq8ikWAM"
[voice.edge]
de = "de-DE-KatjaNeural"
```
Precedence: `--voice` flag > `voice.<provider>.<lang>` > built-in default.
MP3 is decoded by the binary itself (pure Go decoder, no ffmpeg, CGO-free).
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.
### OS TTS on Linux
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`.
- **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` (or `audio.sink = "null"` in config.toml) turns all
sound off, for CI and servers.
## `talk wrap`: announce commands
`talk wrap -- <command> [args …]` runs the command and makes the result
audible: stdin/stdout/stderr are passed through; stdout belongs to the
command alone, talk's information lines (for example the announcement's
item ID) go to stderr so pipes and redirects stay clean. Wall time is
measured and announced after the command ends:
- success: "sleep succeeded. Duration: 2 seconds."
- failure: "sh failed, code 2." (without duration)
```console
talk wrap -- sleep 2
talk wrap --wait -- make test
```
- **`--` 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" # announcement label instead of the command's base name (TALK_WRAP_LABEL)
```
## For agents: skill and standing order
[`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.
## Development
```console
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) runs vet, the gofmt check,
tests and build on every push and pull request.
**Release:** push a `v*` tag → `.gitea/workflows/release.yml` tests, builds
the six targets with `scripts/release.sh` (static, `CGO_ENABLED=0`, version
via ldflags) and creates a Gitea release with `talk_<os>_<arch>[.exe]` and
`checksums.txt`. Locally: `scripts/release.sh 1.2.3` → `dist/`. Deliberately
without GoReleaser: a shell script around `go build` yields the same
artifact locally and in CI without another tool. `nix build .#talk` builds
the Nix package (after changes to go.mod, update the `vendorHash` in
flake.nix; `nix build` prints the expected value).
## License
MIT, see [LICENSE](LICENSE).