Files
talk/README.md
T
m3ta-chironandClaude Fable 5.1 c65835f02a
release / release (push) Canceled after 0s
test / test (push) Canceled after 0s
docs(readme): release download URLs with the version; Linux and Windows assets
Gitea serves release assets only under releases/download/<tag>/, the
latest/download alias returns 404.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 12:56:13 +01:00

23 KiB

talk

Status: WIP, under active development. Specification with the decision addendum: 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 uri <talk:…> handler behind the talk: links of notification buttons (Windows); not called by hand
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); --toast sends a test notification instead
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
talk version version and commit (also talk --version)

Say flags (also for talk "text"): --wait, --now, --priority, --voice, --lang, --provider.

Installation

Release binary (Linux and Windows, amd64/arm64; macOS builds on request, untested so far): download the matching talk_<os>_<arch> from the releases, verify the checksum against checksums.txt, put it in your PATH as talk. No other dependencies. The download URL carries the version (Gitea has no latest/download alias):

V=v0.1.0
curl -LO https://code.m3ta.dev/m3tam3re/talk/releases/download/$V/talk_linux_amd64
sha256sum -c --ignore-missing <(curl -sL https://code.m3ta.dev/m3tam3re/talk/releases/download/$V/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

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 test --toast shows a test notification with the Cancel button and waits for a click, so you can check the notification setup without speaking anything.
  • 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.

Notifications with a Cancel button

Every announcement that plays also shows a desktop notification with its text and a Cancel button (German: Abbrechen) that stops that announcement without a terminal. The notification disappears when the announcement ends.

  • Linux: talk talks to the freedesktop notification service on the session bus directly (any daemon with the "actions" capability: mako, dunst, GNOME Shell, KDE, quickshell) and receives the click over D-Bus. The notification carries the talk icon. Without a session bus it falls back to notify-send without a button.
  • Windows: a toast from "talk" with its icon and a Cancel button. The button opens a talk: link; the daemon registers that URL protocol for the current user (HKCU, no admin rights) so Windows starts talk uri talk:cancel?id=…, which tells the daemon to stop the announcement. The toast identity (AppUserModelId m3ta.talk, also HKCU) is registered the same way on the first daemon start or talk test --toast; before that the toast appears under Windows PowerShell's identity.
  • macOS: Notification Center, no button and no icon yet.

The icon file lives next to the config (talk.png in the config directory); talk writes it there itself.

Turn it off with:

[notify]
on_speak = false   # env TALK_NOTIFY=false

talk test --toast shows one such notification, waits a few seconds for a click on Cancel and reports what happened; talk doctor names the service and whether it supports the button.

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:

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:

[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 /cancel-item {"id":"…"} cancels only that announcement if it is playing (notification Cancel button) → 200 {"cancelled":bool}
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 (the notification service or the system's own tool, see "Notifications"), best effort: if nothing is available, 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):

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

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

{"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:

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:

[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)
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.
[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 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. The daemon enforces rate limit, length and age limits, mute and quiet hours regardless of what the prompt says.

Development

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.

Icon: the source is assets/icon/talk.svg. go run assets/icon/gen.go (needs inkscape) renders the embedded PNG (internal/icon/talk.png), the Windows talk.ico and the resource objects cmd/talk/rsrc_windows_*.syso that give talk.exe its icon; all of them are committed, so a normal build needs no resource compiler.

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.