# voice

> Speak to the console — bundled streaming speech-to-text for live dictation, optional host whisper.cpp for final-pass and file transcription, read-aloud, and remote speech providers, with a mic chip and a voice page.

| field | value |
|-------|-------|
| version | 0.1.2 |
| type | binary |
| license | Apache-2.0 |
| repo | https://github.com/iii-hq/workers |
| supported_targets | x86_64-apple-darwin, aarch64-apple-darwin, x86_64-unknown-linux-gnu |
| author | iii |

## installation

```sh
iii trigger compose::add worker=voice@0.1.2
```

## dependencies

- `configuration` @ `latest`

## readme

# voice

Speak to the console. The voice worker turns microphone audio into text as
you talk, transcribes recordings to timestamped segments, and reads replies
aloud. Speech-to-text can use bundled English models or a host-installed
`whisper.cpp` command and multilingual GGML model; nothing leaves the machine
unless you select a remote backend.

Dictation is two passes. A small streaming model produces words as you speak
and decides where utterances end. The final pass can use the bundled Parakeet
TDT 0.6B v2 model, a local `whisper-cli` process, or a remote speech backend.
The selected final pass re-decodes each finished utterance with punctuation and
casing; until it answers, the streaming text remains visible.

Three surfaces, one worker:

1. **A mic in every chat.** A `Dictate` button joins the composer toolbar
   beside attach (the chat header on consoles without that slot). Click to
   toggle, or hold to talk. Words appear as you speak in a header pill and
   land in the composer when you stop, ready to edit before sending.
2. **Read aloud.** Every finished turn gets a `Read aloud` action above the
   composer that speaks the last reply.
3. **A voice page.** The `voice` page shows the engine state, downloads the
   model, transcribes a WAV file with per-segment timestamps, and runs a
   dictation test.

Agents get the same through `voice::*` functions and the `voice::transcript`
trigger.

## Install

```bash
iii trigger compose::add worker=voice
```

The first dictation or transcription downloads the streaming model (about
44 MB) into `data/voice/models` under the project directory and loads it in
well under a second; the second-pass model (about 660 MB, CC-BY-4.0) follows
in the background. `voice::models::download id=parakeet-tdt-0.6b-v2` fetches
it ahead of time.

## Direct whisper.cpp

On **Voice → Overview**, select **whisper.cpp on this machine**, choose a
Whisper model, then click **Download** when it is missing. Tiny, base, small,
medium, and large-v3-turbo are multilingual choices; the UI shows download
sizes, installation state, progress, and errors. The **Models** section also
lets you select a model with **Use**, even before downloading it. Model
selection does not start a download. Settings offers the same picker and
keeps a **Custom GGML file path** option for existing files.

Downloads are stored under `models_dir/<model-id>/` and checked against
pinned SHA-256 digests. Save changes to the models directory before downloading
from Settings; download actions use the saved directory, not the form draft.
The live streaming model can also be selected and downloaded on Overview,
regardless of the final transcription backend.

Set `stt.backend` to `whisper_cpp` to invoke an existing `whisper-cli` binary
without running an HTTP server. The worker writes a temporary 16 kHz mono WAV,
asks the CLI for JSON, parses its timestamped segments, and removes the
temporary files. The command is executed directly, never through a shell.

```yaml
stt:
  backend: whisper_cpp
  model: zipformer-en-20m       # live dictation preview and boundaries
  num_threads: 4
  whisper_cpp:
    command: whisper-cli
    model: whisper-large-v3-turbo # or /models/ggml-large-v3-turbo.bin
    language: pt
    timeout_secs: 300
```

Install `whisper-cli` separately; downloading weights does not install the
command. Download a catalog model ahead of use with:

```bash
iii trigger voice::models::download id=whisper-large-v3-turbo
```

Whisper weights are downloaded only when requested, not during transcription.
Use a multilingual GGML model for Portuguese. `.en` models only recognize
English. `voice::doctor` reports a missing command or model before the first
transcription. The configured language is used unless a `voice::transcribe`
request supplies its own `language` value.

## Quickstart

Transcribe a recording:

```bash
iii trigger voice::transcribe path=./meeting.wav
```

```json
{
  "text": "The quick brown fox jumps over the lazy dog.",
  "segments": [
    { "segment": 0, "text": "The quick brown fox jumps over the lazy dog.", "start_secs": 0.3, "end_secs": 2.9 }
  ],
  "duration_secs": 5.5,
  "model": "zipformer-en-20m",
  "backend": "local"
}
```

Generate speech on the server and play it in the requesting browser:

- **Voice → Read aloud → Local synthesis → this browser** uses `espeak-ng`
  (Linux) or `say` (macOS) in WAV file-output mode. No sound plays on the server.
- `voice::speak` returns `audio_base64`, `mime`, a unique `speech_id`, and
  `played: false` for every enabled backend. The browser plays that response.
- Each request uses a private temporary file, removed after synthesis. Multiple
  clients can generate speech independently; there is no audio broadcast.
- **Stop** pauses and releases only this browser's audio. While generation is
  pending it discards the eventual response; it does not cancel other clients'
  requests or stop them globally. Generation may continue until completion or
  the local 60-second synthesis timeout.
- `espeak-ng` is needed only on the worker machine, not on each client. A server
  audio device is not needed. For Brazilian Portuguese set `tts.voice: pt-br`.
- Audio is returned after synthesis completes, not streamed. Local output is
  capped at 64 MiB in addition to `tts.max_speak_chars`.

For an API caller, generate audio (this command alone does **not** play it):

```bash
iii trigger voice::speak text="Build finished, three tests failed."
```

Check what the worker can do right now:

```bash
iii trigger voice::doctor
```

## Natural local voice and voice chat

### Read words, not Markdown markers

Read aloud and Voice chat convert Markdown to prose on the worker, before any
TTS backend runs. `**bold**`, `*italic*`, headings, list bullets, checkboxes and
table separators are not pronounced. Links keep their visible label, not the
hidden URL; image descriptions and inline-code text are kept. Fenced/indented
code blocks, raw HTML blocks and footnote bodies are omitted. No HTML is
rendered and no link is fetched during conversion.

For example, `Use **Piper** para *ler*` becomes `Use Piper para ler`. Literal
punctuation and symbols are preserved rather than deleting all asterisks or
underscores: `2 * 3`, `snake_case` and escaped markers remain literal.

The `voice::speak` request accepts `text_format: "markdown"` (default) or
`text_format: "plain"` to preserve text literally. The chat's selected-passage
button uses `plain` because it receives already-rendered text; full replies,
automatic reading and the Voice text area use `markdown`. Conversion affects
only the speech input, never the saved message. `tts.max_speak_chars` is checked
against the converted text; if no readable prose remains, the request reports
an empty-text error instead of generating audio for formatting symbols.

### Listening and reading model configuration

The Models page separates model selection and downloads into two independent
sections rather than a single Speech models list:

- **Listening models · Speech to text**: Zipformer (live dictation), Parakeet
  and Whisper (final transcription). These process microphone or recorded audio
  into text; translation into another language is not configured on this page.
- **Reading models · Text to speech**: Piper voices used by Read aloud and
  Voice chat to speak text in the requesting browser. The language filter is
  scoped to this section only; it never hides listening models.

Each section has its own installed count. Selection buttons explicitly say
**Use for live dictation**, **Use for transcription**, or **Use for read aloud**.
Choosing a listening model preserves the configured reading voice and vice versa.
Download, progress, error and removal controls remain available in both groups.
This is a model-configuration layout change, not a new microphone/playback mode.

### Choose your language before downloading

Piper is not limited to Brazilian Portuguese. In Overview → Neural voice,
Settings → Read aloud, or Models, the **What language do you speak?** field
filters the available voices. Type a language name (`português`, `English`,
`español`) or a regional code (`pt-BR`, `pt-PT`, `en-US`). Suggestions show the
language/region and number of voices. Language names are searchable in English,
Portuguese, the browser's locale and the language's native name.

Choose a matching voice and explicitly click **Download**. Names, quality,
download size and installed status help distinguish the choices. A language
without matches shows an empty-state explanation; it never falls back silently
to Portuguese. Filtering does not change the configured voice, download files,
or delete installed voices. On Models, installed Piper voices remain visible
for management until a language filter is entered.

The bundled snapshot contains **176 voices across 57 language/region variants**
from `rhasspy/piper-voices` at commit
`1162a9173d0ce503555aed757976b7a9912eae4c`. Only metadata is embedded, not model
weights. Catalog browsing works offline; a requested download needs network
access. Each additional voice's ONNX, configuration and model card use pinned
URLs and SHA-256 digests. The existing Faber id and installed directory remain
compatible. Availability in this catalog is not a claim that every voice has
been synthesis-tested; licenses and quality vary by voice, so consult its card.

Maintainers can refresh a reviewed snapshot with
`python3 voice/scripts/import_piper_catalog.py --revision <40-character-commit> --output <new-snapshot.json>`.
The importer reads upstream metadata, verifies small files against Git blob
ids and derives their SHA-256, and takes weight hashes from LFS metadata. It
does not download weights and refuses to overwrite an existing destination.
Catalog updates require a worker build; browsing does not fetch a mutable
remote index at runtime.

### GPU preferred, CPU fallback

`tts.piper.device` defaults to `auto`: the worker requests Piper's `--cuda`
mode (NVIDIA CUDA). ONNX Runtime may transparently use CPU when that provider
is unavailable. If the CUDA-preferred process fails or times out, the worker
retries once without `--cuda`, with the same model and text, on CPU. Auto uses
up to 30 seconds per attempt, within the existing 60-second synthesis budget.
If both attempts fail, both errors are reported. Files are private to each
attempt and the CPU attempt never consumes partial GPU output.

Choose **Settings → Read aloud → Processing device → CPU only** to skip GPU
and allow a single CPU attempt up to 60 seconds. This option changes Piper TTS
only, not Whisper, listening models or the browser playback destination.

```yaml
tts:
  backend: piper
  piper:
    device: auto # prefer GPU; use cpu to force CPU
```

GPU acceleration requires a compatible NVIDIA GPU/driver, CUDA/cuDNN libraries
and the GPU build of ONNX Runtime (`onnxruntime-gpu`) **inside the same Python
environment/container as Piper**. The regular `piper-tts` install uses CPU
ONNX Runtime. Do not treat a GPU physically present on the host as sufficient,
or install CPU and GPU ONNX Runtime packages side by side (they share the same
Python module). Match package/library versions using ONNX Runtime's CUDA
requirements. The worker installs no GPU runtime or drivers automatically.
This CUDA path does not enable Apple Metal or AMD acceleration.

Doctor/Overview show the requested policy, not the actual provider used. The
CLI does not report its effective provider in the speech response; successful
auto synthesis alone is not proof of GPU usage. Model quality and voice do
not change between CPU and GPU, and GPU is not guaranteed to be faster for
short requests because Piper currently loads the model per request.

### Install and use Piper

For a more natural voice than eSpeak, select **Piper · natural local voice** in
Voice → Overview or Settings. Install `piper-tts==1.8.0` separately on the worker
(for example with `uv tool install piper-tts==1.8.0 --python 3.12`). The executable
must be on the worker's PATH, or set `tts.piper.command` to its absolute path.
Piper is invoked as a separate process, not linked into the worker.

For example, filter by `pt-BR`, download **Piper Faber · Brazilian Portuguese**
in Models (~63 MB), then choose **Use model**. Other languages use the same flow. ONNX weights, configuration and the model card are SHA-256 verified.
This changes TTS only: your Whisper/dictation configuration is preserved. No
model downloads happen implicitly during speech generation, and missing Piper
or weights produce an actionable error rather than silently using eSpeak.

```yaml
tts:
  backend: piper
  piper:
    command: piper
    model: piper-pt-br-faber-medium
```

The chat footer has three independent controls:

- **Read aloud** reads the last final assistant reply.
- **Read selection** appears when you highlight a passage in this conversation's
  messages. It reads only that passage, not selections in other panes or forms.
  The Voice → Read aloud text area also supports selecting only a passage.
- **Voice chat** explicitly enables incremental reading in this browser/conversation.
  As assistant text arrives, complete sentences enter a local audio queue; long
  sentences use phrases of at most 240 characters (or the configured speech cap,
  whichever is smaller). It does not wait for the turn to finish. Visible progress
  text is included; thinking blocks, tool arguments and tool results are not read.
  It does not open the microphone or send a draft automatically. Enabling it does
  not fetch or replay chat history; enabled mid-turn, the next live snapshot can
  include text already generated in that current message.
  Duplicate/out-of-order message revisions are ignored. Completion flushes only
  the unread tail, not the full response again. A new turn or cancellation clears
  the old queue. **Stop** also turns automatic reading off. Switching chats,
  closing the view, or a playback error disables it; other clients are unaffected.

Streaming speech still synthesizes on the worker, one short text request
at a time, with at most one next clip prepared ahead of the current audio. It is not
sample-level TTS streaming: first-audio latency depends on receiving a complete
sentence/phrase and the selected backend's synthesis speed. Snapshot preparation
is coalesced over 120 ms; no history polling is used. The same CommonMark parser
prepares the full growing message, preserving code-fence/link context; incomplete
inline syntax (and ambiguous literal markers) may be held until more text or the
final flush. A message rewrite that changes already-queued speech stops automatic
reading with an error rather than repeating/replacing words already heard.

`voice::speech::prepare` is stateless and returns plain text plus the configured
chunk cap without generating audio. The browser then queues `voice::speak` with
`text_format: plain`. Queue overflow (128 waiting clips), oversized messages
(256 KiB), or more than 128 message entries in one turn stops with an actionable
error. Stopping discards local pending work and late audio; it cannot cancel an
already-dispatched server synthesis request. No model/config changes or automatic
downloads are needed.

Stop/Cancel finishes even if the microphone permission prompt remains unanswered.
The browser cannot abort that prompt; if permission is granted later, the obsolete
capture is immediately released without reviving dictation. Cancel during Stop
clears and suppresses the result; if the remote Stop request was already sent,
it cannot be retroactively changed, but its returned text is not delivered.

Chat reading controls validate the current configuration on each attempt rather
than staying disabled from an old availability snapshot. After installing a voice
or fixing its executable, retry Read aloud without reloading the conversation.

The browser may block automatic playback according to its autoplay policy; errors
are shown rather than claiming playback succeeded. Piper loads its model per
request and returns the completed WAV, so there is a generation delay. The
configured `tts.max_speak_chars` still applies; select a shorter passage if needed.

## Dictation over the bus

A dictation session is a live recognizer stream. The caller names a function
that receives transcript events, then pushes 16 kHz mono 16-bit PCM in ~100 ms
chunks:

```bash
iii trigger voice::dictation::start output_function_id=my::transcript
iii trigger voice::dictation::push session_id=d_… seq=1 pcm16_base64=…
iii trigger voice::dictation::stop session_id=d_…
```

Events carry `kind` (`partial` replaces the in-progress text, `final` commits
a segment, `closed` ends the session), `seq`, `segment`, and `text`. The same
events fan out on the `voice::transcript` trigger, filterable by `session_id`.
Sessions idle for `session_idle_secs` are closed by the worker.

## Configuration

Stored in the `configuration` worker under `voice` and editable in the
console's Settings; every field takes effect on the next call, and a model or
endpointing change reloads the recognizer on next use.

| Field | Default | Meaning |
| --- | --- | --- |
| `models_dir` | `data/voice/models` | Where models live (relative to the project directory). |
| `stt.backend` | `local` | `local` (bundled recognizer), `whisper_cpp` (host `whisper-cli`), `openai` (any `/v1/audio/transcriptions` server), or `router` (any speech provider registered with llm-router). |
| `stt.model` | `zipformer-en-20m` | Streaming model for live words and sentence boundaries: `zipformer-en-20m` (44 MB, fastest) or `zipformer-en-large` (73 MB, fewer misheard words in the preview). |
| `stt.final_model` | `parakeet-tdt-0.6b-v2` | Second-pass model that re-decodes each utterance for the final text. Empty disables the second pass. |
| `stt.num_threads` | `2` | Decoder threads. |
| `stt.silence_after_speech_secs` | `0.8` | Trailing silence that commits an utterance. |
| `stt.silence_without_speech_secs` | `2.4` | Trailing silence that ends an empty segment. |
| `stt.max_utterance_secs` | `20` | Longest utterance before a forced commit. |
| `stt.openai.base_url`, `api_key`, `model`, `language` | OpenAI defaults | The remote transcription endpoint. `api_key` accepts `${OPENAI_API_KEY}`. |
| `stt.router.model`, `language` | empty | Router model (`provider::model`) for `voice::transcribe` and for the second pass of every dictated sentence; empty lets the router pick. Keys live in the router's Settings, not here. |
| `stt.whisper_cpp.command` | `whisper-cli` | Command on `PATH`, or an explicit executable path. |
| `stt.whisper_cpp.model` | `whisper-large-v3-turbo` | Catalog id (`whisper-tiny`, `whisper-base`, `whisper-small`, `whisper-medium`, `whisper-large-v3-turbo`) or a custom GGML file path. Download catalog models from the Voice page or `voice::models::download`; custom paths remain user-managed. |
| `stt.whisper_cpp.language`, `timeout_secs` | `auto`, `300` | ISO 639-1 language hint (`pt`) or detection, and the per-process timeout. |
| `tts.backend` | `host` | `piper` (natural local neural voice), `host` (local WAV synthesis with `say` on macOS or `espeak-ng` on Linux, returned to the browser), `openai` (`/v1/audio/speech`, audio returned to the caller), `router` (llm-router's `router::speak`, any registered speech provider, audio returned to the caller), or `off`. |
| `tts.piper.device` | `auto` | Prefer NVIDIA CUDA, with CPU fallback; `cpu` forces CPU. Requires GPU dependencies in the Piper environment to accelerate. |
| `tts.piper.command`, `tts.piper.model` | `piper`, `piper-pt-br-faber-medium` | Local neural TTS executable and downloaded voice catalog id. Audio plays in the browser. |
| `tts.voice`, `tts.rate_wpm` | system default | Host voice and speaking rate. |
| `tts.max_speak_chars` | `4000` | Longest text one `voice::speak` call reads. |
| `tts.openai.base_url`, `api_key`, `model`, `voice` | OpenAI defaults | The remote speech endpoint. |
| `tts.router.model`, `voice`, `format` | empty, empty, `mp3` | Router model, voice id or name, and container for `voice::speak`. |
| `max_audio_bytes` | `10485760` | Largest inline (`audio_base64`) file for `voice::transcribe`. |
| `max_sessions` | `8` | Open dictation sessions across all callers. |
| `session_idle_secs` | `120` | Idle time before a session is closed. |

A local whisper server (whisper.cpp `server`, speaches, and similar) works as
the `openai` backend with an empty `api_key`.

## Functions

| Function | What it does |
| --- | --- |
| `voice::transcribe` | A WAV file (path or base64) to text with segments. |
| `voice::dictation::start` / `push` / `stop` / `list` | Live sessions. |
| `voice::speak` | Generate audio for the requesting client to play. |
| `voice::speech::prepare` | Prepare a growing Markdown snapshot without audio; returns plain text and the chunk character cap. |
| `voice::speak::stop` | Legacy compatibility: returns `stopped: 0`; browser playback is stopped locally. |
| `voice::models::list` / `voice::models::download` / `voice::models::remove` | Local model catalog, install, delete. |
| `voice::doctor` | Backends, model state, open sessions. |

Triggers: `voice::transcript`, `voice::session-started`,
`voice::session-stopped`, `voice::model-progress`. The legacy
`voice::speech-ended` trigger remains registered for compatibility but is no
longer emitted: each browser uses its audio element's ended/error events.

## Limits

- The streaming Zipformer and Parakeet transcription models are English-only.
  Multilingual transcription uses whisper.cpp or a remote backend; Piper reading
  voices cover the languages listed in the catalog.
- WAV is the only container decoded. Convert other formats first, for
  example `ffmpeg -i in.m4a -ac 1 -ar 16000 out.wav`.
- Every read-aloud backend returns audio to the requesting browser; nothing
  plays on the worker machine. Local synthesis returns a complete WAV, not a stream.
- Prebuilt binaries cover macOS (Intel and Apple silicon) and x86_64 glibc
  Linux. The speech engine publishes no static no-TTS archive for Linux on
  aarch64, musl or 32-bit ARM, and the build only accepts archives whose
  SHA-256 it pins.

## Licenses

The worker is Apache-2.0. [THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md) lists bundled components and the documented child-process and service integrations with their licenses and sources. If you configure a custom child-process command or model, review that artifact and its license separately. In short: sherpa-onnx and its Kaldi-derived libraries are Apache-2.0, ONNX Runtime is MIT, KISS FFT is BSD-3-Clause; the streaming Zipformer model is Apache-2.0 and Parakeet TDT 0.6B v2 is NVIDIA's under CC BY 4.0, with the attribution text in the notices file. The build links the no-TTS archives and drops espeak-ng and its `ucd` tables (GPL-3.0), so no GPL code is in the binary; on Linux, read-aloud runs `espeak-ng` as a separate program when it is installed. Models are downloaded on the worker machine from Hugging Face (ONNX transcription models may download on first use; Whisper and Piper require an explicit download), and `voice::models::list` reports each model's license, author and source.

## Development

```bash
cd voice
cargo build                 # builds ui/ with pnpm first
cargo test
III_VOICE_UI_WATCH=1 cargo run   # hot-reloads the console UI from ui/dist
```

Run the recognizer against real audio with the model on disk:

```bash
VOICE_TEST_DOWNLOAD=1 VOICE_TEST_WAV=./clip.wav cargo test --test engine_live -- --nocapture
```

## skills

This version ships 1 skill file for agents, also browsable on the worker page:

- [`SKILL.md`](https://workers.iii.dev/workers/voice?tab=skills&skill=SKILL.md&version=0.1.2)

### SKILL.md

# voice

Local speech-to-text and read-aloud on the iii bus. Nothing leaves the machine by default: a small streaming recognizer produces live text, while bundled Parakeet or a configured host `whisper-cli` can produce accurate final text. An OpenAI-compatible endpoint or any speech provider registered with llm-router can replace either half through configuration.

## When to Use

- A user attached or named a WAV recording and wants its text: `voice::transcribe`.
- A surface streams microphone audio and wants live text: `voice::dictation::*`.
- A reply should be spoken: `voice::speak`.

## Boundaries

- Audio in is 16 kHz mono 16-bit PCM for dictation; WAV files of any rate for `voice::transcribe`. No other container is decoded.
- The bundled models are English. Portuguese and other languages need `stt.backend: whisper_cpp` with a multilingual GGML model, or a remote backend.
- For natural local read-aloud, install `piper-tts`, enter your language in Voice's Piper language filter, choose a matching voice from the bundled multilingual catalog, explicitly download it, and select `tts.backend: piper`. `piper-pt-br-faber-medium` remains available for Brazilian Portuguese. Filtering does not download or change the active voice. `tts.piper.command` is an executable path, not a shell command. No fallback to robotic eSpeak occurs if Piper is unavailable.
- `tts.piper.device` defaults to `auto` (request NVIDIA CUDA, allow ONNX Runtime CPU fallback; retry once on CPU if the GPU-preferred process fails). `cpu` forces CPU. Auto allocates 30 seconds per attempt; CPU-only gets 60 seconds. GPU requires compatible onnxruntime-gpu/CUDA/cuDNN in Piper's own environment. No drivers are auto-installed; doctor reports policy, not verified GPU usage.
- In Console, Read selection speaks only highlighted text in that chat. Voice chat is opt-in per browser/conversation and reads live assistant text in sentence/phrase chunks before the turn ends (including visible progress, never thinking/tool arguments). It deduplicates snapshots and flushes only the unread tail at completion; it does not send messages or keep the microphone open. Stop disables automatic reading.
- `voice::speak` accepts `text_format: markdown` (default) to read prose without Markdown markers, hidden link URLs or block code; `plain` preserves literal text. Chat selections use plain because they are already rendered. The character cap applies after conversion. No messages are modified and no remote links are fetched.
- Every `voice::speak` backend returns `audio_base64` and `mime` to the caller. `host` generates WAV using say/espeak in file-output mode, never server playback. The requesting browser plays/stops its own audio; other clients are unaffected.
- Dictation sessions idle past `session_idle_secs` are closed by the worker.

## Functions

- `voice::transcribe` — a WAV file (path or base64) to text with timestamped segments.
- `voice::dictation::start` — open a session; transcript events go to `output_function_id`.
- `voice::dictation::push` — feed one base64 PCM chunk (rising `seq`).
- `voice::dictation::stop` — close a session and return its transcript (`discard` to drop it).
- `voice::dictation::list` — open sessions.
- `voice::speak` — read text aloud; returns a `speech_id`.
- `voice::speech::prepare` — stateless Markdown snapshot preparation (`text`, `complete`); returns plain text and `max_chunk_chars`. Withhold unfinished syntax, then queue plain-text `voice::speak` clips in the requesting browser.
- `voice::speak::stop` — legacy compatibility, returns `stopped: 0`. Stop browser audio locally.
- `voice::models::list` — local models, whether each is installed, and each one's license, author and source (Parakeet TDT 0.6B v2 is CC BY 4.0: keep the attribution if you copy its files elsewhere).
- `voice::models::download` — install a local model, checksum-verified.
- `voice::models::remove` — delete a downloaded model.
- `voice::doctor` — backends, model state, open sessions.

## Reactive triggers

- `voice::transcript` — partial, final, closed and error events of dictation sessions (filter `session_id`).
- `voice::session-started`, `voice::session-stopped` — session lifecycle.
- `voice::model-progress` — download progress, one event per megabyte and a final `done`.

## api reference

```json
{
  "functions": [
    {
      "description": "Returns this worker instance's effective configuration entry ID, without its value.",
      "metadata": {
        "internal": true
      },
      "name": "voice::configuration-id",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "Empty user request. Ignore transport metadata such as `_caller_worker_id`, which the engine adds to routed calls; no payload field selects the entry.",
        "title": "ConfigurationIdentityRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "Only the entry ID is exposed; configuration values and secrets stay in the service.",
        "properties": {
          "id": {
            "type": "string"
          }
        },
        "required": [
          "id"
        ],
        "title": "ConfigurationIdentityResponse",
        "type": "object"
      }
    },
    {
      "description": "List the dictation sessions this worker currently holds open, with their age, audio duration and idle time.",
      "metadata": {},
      "name": "voice::dictation::list",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "title": "ListRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "SessionSummary": {
            "description": "Summary of an open session for `voice::dictation::list`.",
            "properties": {
              "duration_secs": {
                "format": "float",
                "type": "number"
              },
              "idle_secs": {
                "format": "uint64",
                "minimum": 0,
                "type": "integer"
              },
              "model": {
                "type": "string"
              },
              "segments": {
                "format": "uint32",
                "minimum": 0,
                "type": "integer"
              },
              "session_id": {
                "type": "string"
              },
              "started_at_ms": {
                "format": "int64",
                "type": "integer"
              }
            },
            "required": [
              "duration_secs",
              "idle_secs",
              "model",
              "segments",
              "session_id",
              "started_at_ms"
            ],
            "type": "object"
          }
        },
        "properties": {
          "sessions": {
            "items": {
              "$ref": "#/definitions/SessionSummary"
            },
            "type": "array"
          }
        },
        "required": [
          "sessions"
        ],
        "title": "ListResponse",
        "type": "object"
      }
    },
    {
      "description": "Feed one chunk of 16 kHz mono 16-bit PCM (base64, at most 64 KiB) to an open dictation session. Chunks carry a rising seq; ~100 ms per chunk keeps partial text prompt.",
      "metadata": {},
      "name": "voice::dictation::push",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "pcm16_base64": {
            "description": "Little-endian signed 16-bit PCM, 16 kHz mono, base64.",
            "type": "string"
          },
          "seq": {
            "description": "Rising per-session counter; a chunk not after the last accepted one is rejected.",
            "format": "uint64",
            "minimum": 0,
            "type": "integer"
          },
          "session_id": {
            "type": "string"
          }
        },
        "required": [
          "pcm16_base64",
          "seq",
          "session_id"
        ],
        "title": "PushRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "accepted": {
            "type": "boolean"
          },
          "queued_ms": {
            "description": "Milliseconds of audio this chunk held.",
            "format": "uint32",
            "minimum": 0,
            "type": "integer"
          },
          "seq": {
            "format": "uint64",
            "minimum": 0,
            "type": "integer"
          }
        },
        "required": [
          "accepted",
          "queued_ms",
          "seq"
        ],
        "title": "PushResponse",
        "type": "object"
      }
    },
    {
      "description": "Open a dictation session. Transcript events (partial, final, closed) are delivered to output_function_id as audio arrives through voice::dictation::push; stop with voice::dictation::stop. Audio must be 16 kHz mono 16-bit PCM.",
      "metadata": {},
      "name": "voice::dictation::start",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "output_function_id": {
            "description": "Function that receives every transcript event of this session.",
            "type": "string"
          },
          "sample_rate": {
            "default": null,
            "description": "Sample rate of the audio the caller will push. Only 16000 is accepted.",
            "format": "uint32",
            "minimum": 0,
            "type": [
              "integer",
              "null"
            ]
          }
        },
        "required": [
          "output_function_id"
        ],
        "title": "StartRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "model": {
            "type": "string"
          },
          "sample_rate": {
            "format": "uint32",
            "minimum": 0,
            "type": "integer"
          },
          "session_id": {
            "type": "string"
          }
        },
        "required": [
          "model",
          "sample_rate",
          "session_id"
        ],
        "title": "StartResponse",
        "type": "object"
      }
    },
    {
      "description": "Close a dictation session, flush the recognizer and return the whole transcript. discard=true throws the text away instead.",
      "metadata": {},
      "name": "voice::dictation::stop",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "discard": {
            "default": false,
            "description": "Drop the transcript instead of returning it.",
            "type": "boolean"
          },
          "session_id": {
            "type": "string"
          }
        },
        "required": [
          "session_id"
        ],
        "title": "StopRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "Segment": {
            "description": "One committed stretch of speech.",
            "properties": {
              "end_secs": {
                "format": "float",
                "type": [
                  "number",
                  "null"
                ]
              },
              "segment": {
                "format": "uint32",
                "minimum": 0,
                "type": "integer"
              },
              "start_secs": {
                "format": "float",
                "type": [
                  "number",
                  "null"
                ]
              },
              "text": {
                "type": "string"
              }
            },
            "required": [
              "segment",
              "text"
            ],
            "type": "object"
          }
        },
        "properties": {
          "duration_secs": {
            "format": "float",
            "type": "number"
          },
          "segments": {
            "items": {
              "$ref": "#/definitions/Segment"
            },
            "type": "array"
          },
          "session_id": {
            "type": "string"
          },
          "text": {
            "type": "string"
          }
        },
        "required": [
          "duration_secs",
          "segments",
          "session_id",
          "text"
        ],
        "title": "StopResponse",
        "type": "object"
      }
    },
    {
      "description": "Report the speech-to-text backend and whether its model is installed and loaded, the read-aloud backend and whether its command exists, and how many dictation sessions are open.",
      "metadata": {},
      "name": "voice::doctor",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "title": "Request",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "FinalState": {
            "description": "Where the second-pass model stands, for `voice::doctor`.",
            "oneOf": [
              {
                "description": "No second-pass model configured.",
                "enum": [
                  "off"
                ],
                "type": "string"
              },
              {
                "description": "Named in the configuration but not on disk yet, and no download running.",
                "enum": [
                  "missing"
                ],
                "type": "string"
              },
              {
                "description": "Downloading in the background; streaming text stands meanwhile.",
                "enum": [
                  "downloading"
                ],
                "type": "string"
              },
              {
                "description": "On disk, loads on next use.",
                "enum": [
                  "installed"
                ],
                "type": "string"
              },
              {
                "description": "Loaded and refining utterances.",
                "enum": [
                  "loaded"
                ],
                "type": "string"
              },
              {
                "description": "The configured id is not in the catalog.",
                "enum": [
                  "unknown"
                ],
                "type": "string"
              }
            ]
          },
          "PiperDevice": {
            "description": "Device preference, not a claim about the provider used for a particular request.",
            "oneOf": [
              {
                "description": "Request NVIDIA CUDA first; allow ONNX Runtime's CPU fallback and retry once without CUDA if that attempt fails. No GPU dependencies are installed.",
                "enum": [
                  "auto"
                ],
                "type": "string"
              },
              {
                "description": "Never request GPU execution.",
                "enum": [
                  "cpu"
                ],
                "type": "string"
              }
            ]
          },
          "SttReport": {
            "properties": {
              "backend": {
                "description": "`local`, `whisper_cpp`, `router` or `openai`.",
                "type": "string"
              },
              "final_load_ms": {
                "format": "uint64",
                "minimum": 0,
                "type": [
                  "integer",
                  "null"
                ]
              },
              "final_model": {
                "description": "The second-pass model that gives final text its punctuation and accuracy, and where it stands.",
                "type": "string"
              },
              "final_state": {
                "$ref": "#/definitions/FinalState"
              },
              "installed": {
                "type": "boolean"
              },
              "live_installed": {
                "type": "boolean"
              },
              "live_loaded": {
                "type": "boolean"
              },
              "live_model": {
                "description": "The local streaming model that shows live words and finds sentence ends while dictating, whatever the engine.",
                "type": "string"
              },
              "load_ms": {
                "format": "uint64",
                "minimum": 0,
                "type": [
                  "integer",
                  "null"
                ]
              },
              "loaded": {
                "type": "boolean"
              },
              "model": {
                "description": "The model that writes final text: bundled local, whisper.cpp GGML, router, or OpenAI-compatible endpoint model.",
                "type": "string"
              },
              "models_dir": {
                "type": "string"
              },
              "problem": {
                "description": "Set when the local model id is unknown or the selected host/remote backend is unavailable or incomplete.",
                "type": [
                  "string",
                  "null"
                ]
              }
            },
            "required": [
              "backend",
              "final_model",
              "final_state",
              "installed",
              "live_installed",
              "live_loaded",
              "live_model",
              "loaded",
              "model",
              "models_dir"
            ],
            "type": "object"
          },
          "TtsReport": {
            "properties": {
              "available": {
                "type": "boolean"
              },
              "backend": {
                "description": "`host`, `openai`, or `off`.",
                "type": "string"
              },
              "command": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "device": {
                "anyOf": [
                  {
                    "$ref": "#/definitions/PiperDevice"
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "Requested Piper policy only, not an assertion that GPU is available."
              },
              "model": {
                "type": "string"
              },
              "playing": {
                "description": "Legacy field; browser playback is not tracked on the worker.",
                "format": "uint",
                "minimum": 0,
                "type": "integer"
              },
              "problem": {
                "type": [
                  "string",
                  "null"
                ]
              }
            },
            "required": [
              "available",
              "backend",
              "model",
              "playing"
            ],
            "type": "object"
          }
        },
        "properties": {
          "sessions": {
            "format": "uint",
            "minimum": 0,
            "type": "integer"
          },
          "stt": {
            "$ref": "#/definitions/SttReport"
          },
          "tts": {
            "$ref": "#/definitions/TtsReport"
          },
          "version": {
            "type": "string"
          }
        },
        "required": [
          "sessions",
          "stt",
          "tts",
          "version"
        ],
        "title": "Response",
        "type": "object"
      }
    },
    {
      "description": "Download a local speech model (the active one when id is omitted), verifying every file's checksum. Progress arrives on the voice::model-progress trigger.",
      "metadata": {},
      "name": "voice::models::download",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "id": {
            "default": null,
            "description": "Model id from voice::models::list; omit for the active one.",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "title": "DownloadRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "bytes": {
            "description": "Bytes written by this call (0 when everything was already present).",
            "format": "uint64",
            "minimum": 0,
            "type": "integer"
          },
          "id": {
            "type": "string"
          },
          "installed": {
            "type": "boolean"
          }
        },
        "required": [
          "bytes",
          "id",
          "installed"
        ],
        "title": "DownloadResponse",
        "type": "object"
      }
    },
    {
      "description": "List the speech models the local backend can run, which one is active, and whether each is installed under models_dir.",
      "metadata": {},
      "name": "voice::models::list",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "title": "ListRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "ModelEntry": {
            "properties": {
              "author": {
                "description": "Who trained the model; CC-BY weights must carry this when redistributed.",
                "type": "string"
              },
              "id": {
                "type": "string"
              },
              "installed": {
                "type": "boolean"
              },
              "kind": {
                "allOf": [
                  {
                    "$ref": "#/definitions/ModelKind"
                  }
                ],
                "description": "`streaming_transducer` (live partials), `offline_nemo_transducer` (second pass), `whisper_ggml` (whisper-cli weights), or `piper_onnx` (neural TTS)."
              },
              "languages": {
                "items": {
                  "type": "string"
                },
                "type": "array"
              },
              "license": {
                "description": "SPDX id of the model weights' license.",
                "type": "string"
              },
              "name": {
                "type": "string"
              },
              "size_bytes": {
                "format": "uint64",
                "minimum": 0,
                "type": "integer"
              },
              "source": {
                "description": "Model card with the license text and the files' origin.",
                "type": "string"
              }
            },
            "required": [
              "author",
              "id",
              "installed",
              "kind",
              "languages",
              "license",
              "name",
              "size_bytes",
              "source"
            ],
            "type": "object"
          },
          "ModelKind": {
            "description": "How the recognizer loads a model.",
            "oneOf": [
              {
                "description": "Streaming zipformer transducer: partial text as audio arrives.",
                "enum": [
                  "streaming_transducer"
                ],
                "type": "string"
              },
              {
                "description": "NeMo offline transducer: one decode per finished utterance.",
                "enum": [
                  "offline_nemo_transducer"
                ],
                "type": "string"
              },
              {
                "description": "GGML weights loaded by a separate whisper-cli process.",
                "enum": [
                  "whisper_ggml"
                ],
                "type": "string"
              },
              {
                "description": "Neural text-to-speech loaded by the separately installed Piper process.",
                "enum": [
                  "piper_onnx"
                ],
                "type": "string"
              }
            ]
          }
        },
        "properties": {
          "active": {
            "description": "The model the configuration names.",
            "type": "string"
          },
          "models": {
            "items": {
              "$ref": "#/definitions/ModelEntry"
            },
            "type": "array"
          },
          "models_dir": {
            "type": "string"
          }
        },
        "required": [
          "active",
          "models",
          "models_dir"
        ],
        "title": "ListResponse",
        "type": "object"
      }
    },
    {
      "description": "Delete a downloaded local model from models_dir. Configured ONNX transcription models may download on use; Whisper and Piper models require an explicit download.",
      "metadata": {},
      "name": "voice::models::remove",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "id": {
            "description": "Model id from voice::models::list.",
            "type": "string"
          }
        },
        "required": [
          "id"
        ],
        "title": "RemoveRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "id": {
            "type": "string"
          },
          "removed": {
            "type": "boolean"
          }
        },
        "required": [
          "id",
          "removed"
        ],
        "title": "RemoveResponse",
        "type": "object"
      }
    },
    {
      "description": "Internal: hot-reload the voice worker from the authoritative configuration when it changes, swapping the per-call snapshot.",
      "metadata": {},
      "name": "voice::on-config-change",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "Payload of the internal config-change handler. The handler re-fetches the authoritative value, so this carries only the advisory id.",
        "properties": {
          "id": {
            "default": null,
            "description": "Configuration id that changed (advisory; the handler re-fetches).",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "title": "OnConfigChangeEvent",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "Ack returned by the internal config-change handler.",
        "properties": {
          "ok": {
            "type": "boolean"
          }
        },
        "required": [
          "ok"
        ],
        "title": "OnConfigChangeResponse",
        "type": "object"
      }
    },
    {
      "description": "Synthesize text and return audio_base64 plus mime to the requesting caller. Every backend (host, piper, openai, router) returns audio for client-side playback; nothing plays on the worker machine. Markdown formatting is omitted by default; set text_format=plain for literal text. host uses say/espeak; piper uses a local neural voice.",
      "metadata": {},
      "name": "voice::speak",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "TextFormat": {
            "description": "Input syntax, independent of the voice/language/backend selected for TTS.",
            "oneOf": [
              {
                "description": "Read the text content, omitting Markdown markup and fenced code blocks.",
                "enum": [
                  "markdown"
                ],
                "type": "string"
              },
              {
                "description": "Already-rendered selections or text whose symbols should remain literal.",
                "enum": [
                  "plain"
                ],
                "type": "string"
              }
            ]
          }
        },
        "properties": {
          "rate_wpm": {
            "default": null,
            "description": "Speaking rate in words per minute (host backend).",
            "format": "uint32",
            "minimum": 0,
            "type": [
              "integer",
              "null"
            ]
          },
          "text": {
            "description": "What to say. Markdown is converted to prose before tts.max_speak_chars is applied.",
            "type": "string"
          },
          "text_format": {
            "allOf": [
              {
                "$ref": "#/definitions/TextFormat"
              }
            ],
            "description": "`markdown` (default) removes presentation syntax; `plain` keeps literal symbols."
          },
          "voice": {
            "default": null,
            "description": "Voice name for the backend; empty uses the configured default.",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "text"
        ],
        "title": "Request",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "What a speak call produced.",
        "properties": {
          "audio_base64": {
            "description": "Base64 audio for the requesting caller to play, on every enabled backend.",
            "type": [
              "string",
              "null"
            ]
          },
          "backend": {
            "description": "`host`, `piper`, `openai`, or `router`.",
            "type": "string"
          },
          "mime": {
            "description": "MIME type of `audio_base64`.",
            "type": [
              "string",
              "null"
            ]
          },
          "played": {
            "description": "Legacy compatibility field; always false. Playback belongs to the caller.",
            "type": "boolean"
          },
          "speech_id": {
            "description": "Unique id of this synthesis result.",
            "type": "string"
          }
        },
        "required": [
          "backend",
          "played",
          "speech_id"
        ],
        "title": "Spoken",
        "type": "object"
      }
    },
    {
      "description": "Legacy server-playback stop (returns stopped=0). Audio is now returned to the caller; stop its local audio element to stop browser playback.",
      "metadata": {},
      "name": "voice::speak::stop",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "speech_id": {
            "default": null,
            "description": "Legacy playback id; no effect on client-side audio.",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "title": "StopRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "stopped": {
            "description": "Always zero: playback is now owned by each client.",
            "format": "uint",
            "minimum": 0,
            "type": "integer"
          }
        },
        "required": [
          "stopped"
        ],
        "title": "StopResponse",
        "type": "object"
      }
    },
    {
      "description": "Prepare a growing Markdown message as plain speech text without synthesizing audio. Incomplete inline syntax is withheld until complete=true or more text arrives. Returns the configured per-request speech limit for client-side chunking; no state, downloads or playback.",
      "metadata": {},
      "name": "voice::speech::prepare",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "complete": {
            "default": false,
            "description": "Flush incomplete syntax only when the message is no longer growing.",
            "type": "boolean"
          },
          "text": {
            "type": "string"
          }
        },
        "required": [
          "text"
        ],
        "title": "Request",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "max_chunk_chars": {
            "format": "uint",
            "minimum": 0,
            "type": "integer"
          },
          "text": {
            "type": "string"
          }
        },
        "required": [
          "max_chunk_chars",
          "text"
        ],
        "title": "Response",
        "type": "object"
      }
    },
    {
      "description": "Transcribe a WAV file to text with timestamped segments. Pass a path on the worker's host or the file itself as base64 (up to max_audio_bytes). Any sample rate and channel count; the audio is downmixed and resampled.",
      "metadata": {},
      "name": "voice::transcribe",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "audio_base64": {
            "default": null,
            "description": "The WAV file itself, base64.",
            "type": [
              "string",
              "null"
            ]
          },
          "language": {
            "default": null,
            "description": "Language hint (ISO 639-1), used by the remote backend.",
            "type": [
              "string",
              "null"
            ]
          },
          "path": {
            "default": null,
            "description": "Path of a WAV file on the machine running the worker.",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "title": "Request",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "Segment": {
            "description": "One committed stretch of speech.",
            "properties": {
              "end_secs": {
                "format": "float",
                "type": [
                  "number",
                  "null"
                ]
              },
              "segment": {
                "format": "uint32",
                "minimum": 0,
                "type": "integer"
              },
              "start_secs": {
                "format": "float",
                "type": [
                  "number",
                  "null"
                ]
              },
              "text": {
                "type": "string"
              }
            },
            "required": [
              "segment",
              "text"
            ],
            "type": "object"
          }
        },
        "properties": {
          "backend": {
            "description": "`local`, `whisper_cpp`, `openai`, or `router`.",
            "type": "string"
          },
          "duration_secs": {
            "format": "float",
            "type": "number"
          },
          "model": {
            "type": "string"
          },
          "segments": {
            "items": {
              "$ref": "#/definitions/Segment"
            },
            "type": "array"
          },
          "text": {
            "type": "string"
          }
        },
        "required": [
          "backend",
          "duration_secs",
          "model",
          "segments",
          "text"
        ],
        "title": "Response",
        "type": "object"
      }
    },
    {
      "description": "Serve the voice worker's injected console UI assets (content function for its console:script / console:style / console:module triggers).",
      "metadata": {
        "internal": true
      },
      "name": "voice::ui-content",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "Input of the content function: the console asks for one asset by path.",
        "properties": {
          "path": {
            "description": "The asset path from the trigger config (e.g. `state/page.js`).",
            "type": "string"
          }
        },
        "required": [
          "path"
        ],
        "title": "UiContentInput",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "Output of the content function.",
        "properties": {
          "content": {
            "description": "The asset source, verbatim.",
            "type": "string"
          },
          "content_type": {
            "description": "MIME type the console should serve the asset with.",
            "type": "string"
          }
        },
        "required": [
          "content",
          "content_type"
        ],
        "title": "UiContentResult",
        "type": "object"
      }
    }
  ],
  "triggers": [
    {
      "description": "Bytes received while a speech model downloads, one event per megabyte and a final done event (with error when the download failed).",
      "invocation_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "additionalProperties": false,
        "description": "Config accepted by every `voice::*` trigger binding. The only filter is an optional id equality match: `session_id` for dictation events, `speech_id` for `voice::speech-ended`. Unknown fields fail at registration so a misspelled filter key fails loudly instead of silently receiving nothing.",
        "properties": {
          "session_id": {
            "description": "Only deliver events for this dictation session.",
            "type": [
              "string",
              "null"
            ]
          },
          "speech_id": {
            "description": "Only deliver the end of this playback (`voice::speech-ended`).",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "title": "BindingConfig",
        "type": "object"
      },
      "metadata": {},
      "name": "voice::model-progress",
      "return_schema": {}
    },
    {
      "description": "A dictation session is ready for audio.",
      "invocation_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "additionalProperties": false,
        "description": "Config accepted by every `voice::*` trigger binding. The only filter is an optional id equality match: `session_id` for dictation events, `speech_id` for `voice::speech-ended`. Unknown fields fail at registration so a misspelled filter key fails loudly instead of silently receiving nothing.",
        "properties": {
          "session_id": {
            "description": "Only deliver events for this dictation session.",
            "type": [
              "string",
              "null"
            ]
          },
          "speech_id": {
            "description": "Only deliver the end of this playback (`voice::speech-ended`).",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "title": "BindingConfig",
        "type": "object"
      },
      "metadata": {},
      "name": "voice::session-started",
      "return_schema": {}
    },
    {
      "description": "A dictation session ended (stopped, discarded, idle, or worker shutdown).",
      "invocation_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "additionalProperties": false,
        "description": "Config accepted by every `voice::*` trigger binding. The only filter is an optional id equality match: `session_id` for dictation events, `speech_id` for `voice::speech-ended`. Unknown fields fail at registration so a misspelled filter key fails loudly instead of silently receiving nothing.",
        "properties": {
          "session_id": {
            "description": "Only deliver events for this dictation session.",
            "type": [
              "string",
              "null"
            ]
          },
          "speech_id": {
            "description": "Only deliver the end of this playback (`voice::speech-ended`).",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "title": "BindingConfig",
        "type": "object"
      },
      "metadata": {},
      "name": "voice::session-stopped",
      "return_schema": {}
    },
    {
      "description": "A host read-aloud started by voice::speak finished: reason ended, stopped or failed. Filter with speech_id.",
      "invocation_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "additionalProperties": false,
        "description": "Config accepted by every `voice::*` trigger binding. The only filter is an optional id equality match: `session_id` for dictation events, `speech_id` for `voice::speech-ended`. Unknown fields fail at registration so a misspelled filter key fails loudly instead of silently receiving nothing.",
        "properties": {
          "session_id": {
            "description": "Only deliver events for this dictation session.",
            "type": [
              "string",
              "null"
            ]
          },
          "speech_id": {
            "description": "Only deliver the end of this playback (`voice::speech-ended`).",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "title": "BindingConfig",
        "type": "object"
      },
      "metadata": {},
      "name": "voice::speech-ended",
      "return_schema": {}
    },
    {
      "description": "A dictation session produced text: a partial replaces the in-progress text, a final commits a segment, closed ends the session. Filter with session_id.",
      "invocation_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "additionalProperties": false,
        "description": "Config accepted by every `voice::*` trigger binding. The only filter is an optional id equality match: `session_id` for dictation events, `speech_id` for `voice::speech-ended`. Unknown fields fail at registration so a misspelled filter key fails loudly instead of silently receiving nothing.",
        "properties": {
          "session_id": {
            "description": "Only deliver events for this dictation session.",
            "type": [
              "string",
              "null"
            ]
          },
          "speech_id": {
            "description": "Only deliver the end of this playback (`voice::speech-ended`).",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "title": "BindingConfig",
        "type": "object"
      },
      "metadata": {},
      "name": "voice::transcript",
      "return_schema": {}
    }
  ]
}
```
