Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

brisvox is a Rust-native, open-source engine for real-time voice agents: a phone call or a browser session in which a caller talks, the agent listens, thinks (an LLM, with tools) and answers in a synthesised voice, and the caller can interrupt at any moment.

It is built to beat Pipecat on three axes, with the same speech and language providers:

  • Latency. Voice-to-voice latency is the time from the end of the caller's speech to the first sound of the answer. brisvox keeps its own share of that to well under a millisecond per stage and takes work off the critical path: connections are prewarmed, the speech-to-text Finalize runs in parallel with the end-of-turn model, the LLM can start speculatively, and the first clause of the answer goes to the voice after one word.
  • Density. One process hosts many calls. Every call is an isolated session; a panic or a provider failure in one never touches another. Model inference runs on a dedicated pool, never on the async workers.
  • Correctness. Interruptions and tool calls are where voice agents silently break. brisvox numbers every user turn (a turn epoch) and drops stale frames everywhere; after a barge-in the LLM sees only the text that was actually played; side-effecting tools never run speculatively and never run twice within a call's turn (the exact scope is in the Tools chapter: a second call or a later turn is not covered, so keep your own idempotency key for "once per order, ever"). Behaviour is proven by deterministic, paused-time simulation, not by hope.

Status. The engine, the transports, the provider adapters, the server binary and both agent SDKs are implemented and tested against mocks and recorded protocol fixtures. Live runs against the real providers, and the head-to-head comparison with Pipecat, are pending: see Benchmark results for exactly what has been measured so far and on what.

How the book is organised

PartWhat is in it
QuickstartRun the server with an example agent, write an agent in Python or TypeScript, run it all with Docker compose.
ConceptsThe mental model: the pipeline, turns and interruptions, played-text truncation, tools. Short pages that point into the reference.
Configuration, Telephony, OperationsAgent definition files, phone carriers, running the server in production.
BenchmarksThe numbers that exist, where they come from, and what they do not show.
ReferenceArchitecture and design documents, the protocols, and one page per provider.

The configuration, telephony, operations and reference chapters are the repository's docs/ files, included as they are, so there is one source of truth: the same text is on GitHub and in this book. References in those files to source paths (crates/..., docs/..., bench/results/...) name files in the repository.

Where to start

License

Dual-licensed under Apache-2.0 or MIT, at your option. The speech test fixtures in fixtures/ (Google FLEURS / FLORES) are CC BY-SA 4.0; see the repository's fixtures/NOTICE.

Quickstart

Four ways in, from smallest to most complete:

  1. Run the server with an example agent. Build brisvox-server, point it at examples/agents/support-en.toml, set three provider keys, and talk to it over the websocket protocol.
  2. Write an agent with the Python or TypeScript SDK. The server runs the call (audio, speech recognition, turn taking, LLM, speech synthesis) and connects out to your process, which holds your tools and your application logic.
  3. Docker and compose. The container image and deploy/compose.yaml: a websocket agent and a Twilio phone agent from one image.
  4. A fully local assistant. sherpa-onnx, Ollama and Kokoro on your own machine, no API key, talked to from a browser page.

What you need in all cases:

  • Rust (the toolchain is pinned in rust-toolchain.toml; rustup picks it up) for a source build, or Docker for the image.
  • API keys for one provider per stage (none for the local assistant). The examples use Deepgram (speech to text), OpenAI (LLM) and Cartesia (text to speech). Keys are read from the environment, never from a file in the repository, and the server does not read a .env file: export them in the shell (or use --env-file with Docker).
  • For the browser example (examples/web-assistant) a server built with the webrtc feature, which also needs a C compiler and cmake.

Without keys you can still run everything that does not call a provider: the deterministic simulations and the benchmark harness run on mock providers (cargo run --release -p brisvox-bench -- v2v --sim, see Benchmark results).

Run the server with an example agent

brisvox-server loads one agent definition and serves every call with it. This page builds the server, runs it with examples/agents/support-en.toml (Deepgram + OpenAI + Cartesia) and plays a recorded utterance at it.

1. Build

git clone https://github.com/bilal07karadeniz/brisvox && cd brisvox
cargo build --release -p brisvox-server --features all-providers,smart-turn-ort,demo-tools
python3 scripts/models/fetch_models.py     # the Smart Turn model, SHA-256 pinned, into models/
  • all-providers compiles every STT/LLM/TTS adapter. The default build has none; a definition that names a provider whose feature is off is refused at startup with the feature to turn on.
  • smart-turn-ort loads the Smart Turn v3.2 end-of-turn model (the example agent uses the adaptive turn policy). The ONNX Runtime binaries are downloaded at build time.
  • demo-tools adds a canned lookup_order tool so the example runs without an application. Never ship it to callers. Your own tools come from an SDK agent or from brisvox_server::tools::AppTools in a small Rust binary of your own (examples/rust-tools).

The Silero voice-activity model is compiled into the binary.

2. Run

Check the agent file first. check runs the server's own loader without starting anything (no sockets, no network), so a typo in a key, a missing environment variable, a provider the binary was built without or a tool nothing provides shows up now, with the line and column, instead of when the server starts:

export DEEPGRAM_API_KEY=...  OPENAI_API_KEY=...  CARTESIA_API_KEY=...
export BRISVOX_AGENT_FILE=examples/agents/support-en.toml
target/release/brisvox-server check "$BRISVOX_AGENT_FILE" --require-secrets

It exits 0 when the file is valid and 1 when it has errors; without --require-secrets a variable that is not set is only a warning (use that where the keys are not present, such as a build job). Then start the server:

target/release/brisvox-server

With no other listener configured the server listens for the websocket protocol on 127.0.0.1:8080 and for health probes on 0.0.0.0:8081 (GET /healthz, GET /readyz, and GET /metrics unless BRISVOX_METRICS turns it off; off a loopback address /metrics needs BRISVOX_METRICS_TOKEN). The websocket listener has no authentication of its own: it refuses to bind a non-loopback address without BRISVOX_SESSION_SECRET or BRISVOX_WS_TOKEN (see Security), and a TLS-terminating proxy belongs in front of it.

curl -i http://127.0.0.1:8081/readyz

3. Talk to it

One websocket connection is one call: binary frames carry audio, text frames carry JSON (start, started, clear, mark, stop, ...). Any client that follows the protocol works. The repository ships two ways to try it without writing one:

  • The benchmark harness plays a recorded utterance in real time and reports how long the agent took to answer (this needs the three keys, because the providers are real):

    cargo run --release -p brisvox-bench -- v2v --url ws://127.0.0.1:8080/ \
        --fixtures en_two_utterances --runs 1
    
  • The browser assistant in examples/web-assistant (one static HTML file) talks to the server over WebRTC with your microphone. It needs a server built with the webrtc feature and BRISVOX_WEBRTC_ADDR; see its README.

4. Change the agent

The agent file is the whole configuration: prompt, language, which tools the LLM may call, the turn and interruption settings, and one provider per stage. Copy support-en.toml, edit it, and restart. examples/agents/ also has a Turkish support agent, a telephony agent and a provider failover agent, each as TOML and as YAML. Every key is documented in Configuration; every server setting (BRISVOX_* environment variables: listeners, admission limits, metrics, recording) in Operations.

Next

Write an agent with the Python or TypeScript SDK

The SDKs implement the agent protocol. The split:

  • The server runs the call: audio, voice-activity detection, speech recognition, turn taking, the LLM, speech synthesis, interruptions.
  • Your agent process holds the application logic: the tools the LLM can call, the prompt per call, commands such as "say this", "transfer the call" or "hang up", and the events you want to react to (what the caller said, what the bot said, turns, errors).

The server connects out to your agent over a WebSocket, one connection per call, so your process is a plain WebSocket server you can run anywhere the brisvox server can reach. Set BRISVOX_AGENT_URL (ws:// or wss://) on the server; BRISVOX_AGENT_TOKEN is sent as a bearer token. Without BRISVOX_AGENT_URL, calls run on the agent file alone.

Python

Python 3.11 or newer, one dependency (websockets).

pip install brisvox-agent          # from PyPI (`pip install brisvox` installs it too, under the short name)
pip install ./sdk/python             # or from a checkout of the repository
from brisvox_agent import Agent, Session, UserText

agent = Agent("luigi", system_prompt="You are Luigi's host.", greeting="Hi, Luigi's here!")

@agent.tool                                   # JSON schema from the type hints
async def opening_hours(day: str) -> str:
    """Opening hours of the restaurant on a weekday."""
    return "12:00 to 22:00"

@agent.on_user_text
async def heard(session: Session, event: UserText) -> None:
    if "bye" in event.text.lower():
        await session.end("Goodbye!")         # speaks the farewell, then ends the call

agent.run(port=8765)

Tools default to read_only; mark a tool that changes the world with @agent.tool(kind="side_effect"). The server never runs those speculatively and never twice for one call id of a call (the tool context carries an idempotency key). That key does not see a second phone call or a later turn: for "one refund per order, ever" derive your own key from the business data (the order id) and enforce it in your store or with the downstream API's Idempotency-Key. More in sdk/python/README.md; a larger, tested example is examples/booking-agent.

TypeScript

Node 20 or newer, one runtime dependency (ws).

npm install @brisvox/agent              # from npm (`npm i brisvox` re-exports it; the browser client is `brisvox/web`)
# or from a checkout of the repository:
cd sdk/typescript && npm ci && npm run build
import { Agent, s } from "@brisvox/agent";

const agent = new Agent({ name: "luigi", systemPrompt: "You are Luigi's host.", greeting: "Hi, Luigi's here!" });

agent.tool({
  name: "opening_hours",
  description: "Opening hours of the restaurant on a weekday.",
  parameters: s.object({ day: s.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]) }),
  handler: async ({ day }) => (day === "mon" ? "Closed on Monday." : "12:00 to 22:00."),
});

agent.onUserText(async (session, event) => {
  if (event.text.toLowerCase().includes("bye")) {
    await session.end({ say: "Goodbye!" });
  }
});

await agent.run({ port: 8765 });

More in sdk/typescript/README.md. The TypeScript SDK mirrors the Python one.

Connect the server to it

Start the agent, then the server (the same build and keys as in Run the server with an example agent):

python sdk/python/examples/minimal.py        # listens on ws://127.0.0.1:8765
BRISVOX_AGENT_URL=ws://127.0.0.1:8765 \
BRISVOX_AGENT_FILE=examples/agents/support-en.toml \
  target/release/brisvox-server

The agent file still names the providers and the turn settings; the agent process adds its tools to the call and may set the prompt per call. An agent that is down, slow or rejects a call never fails the call: it runs on the agent file's own prompt and tools (and the caller hears up to 4 s of silence while the server waits for an unreachable agent, with the default timeouts). The wire format, version negotiation, error codes and limits are in Agent protocol; tools in general are in Tools.

Docker and compose

The repository has a multi-stage Dockerfile and deploy/compose.yaml, which runs two services from one image:

cp deploy/ws.env.example deploy/ws.env              # fill in DEEPGRAM_API_KEY, OPENAI_API_KEY, CARTESIA_API_KEY
cp deploy/twilio.env.example deploy/twilio.env      # keys, Twilio settings and BRISVOX_TWILIO_STREAM_SECRET (required)
docker compose -f deploy/compose.yaml up --build
# or just one service:
docker compose -f deploy/compose.yaml up --build agent-ws

Every release also publishes the image, so nothing has to be built: set BRISVOX_IMAGE to ghcr.io/bilal07karadeniz/brisvox-server:vX.Y.Z (or :latest) and start with up --no-build:

BRISVOX_IMAGE=ghcr.io/bilal07karadeniz/brisvox-server:latest \
  docker compose -f deploy/compose.yaml up --no-build agent-ws

agent-twilio does not start until BRISVOX_TWILIO_STREAM_SECRET (at least 32 random bytes, for example openssl rand -base64 48) has a value in deploy/twilio.env; the same secret goes to your voice webhook, which answers each call with a signed per-call stream URL (examples/phone-receptionist/twiml_server.py is one; see Twilio Media Streams), and the proxy in front must route the per-call path /twilio/stream* (Deploying in a container).

Check that the websocket agent is up:

curl -i http://127.0.0.1:8081/readyz

As written, both services publish their ports on 127.0.0.1 only. The generic websocket listener has no authentication of its own (compose runs it with BRISVOX_INSECURE_LISTENERS=1 because its port is loopback-only; set BRISVOX_SESSION_SECRET before publishing it) and Twilio needs a public wss:// URL, so for anything beyond your own machine put a TLS-terminating reverse proxy in front. The container runs as a non-root user with a read-only root filesystem, no capabilities and a tmpfs /tmp; it drains on SIGTERM (the compose file's stop_grace_period: 45s is longer than the drain).

The image bakes in the Smart Turn model and two reference agent definitions (deploy/agents/web.toml, deploy/agents/phone.toml); mount your own over /etc/brisvox/agent.toml to change the agent.

Everything about the image (build arguments and feature sets, size, health checks, TLS and reverse-proxy notes, Kubernetes probes) is in Deploying in a container; every setting is in Operations.

To add your own tools, run an SDK agent next to the container and set BRISVOX_AGENT_URL (and BRISVOX_AGENT_TOKEN) on the service.

A fully local assistant

A voice assistant that never leaves your machine: no API key, no cloud. Speech recognition is sherpa-onnx (on the CPU), the language model is Ollama, the voice is Kokoro (Kokoro-FastAPI, on the GPU when you have one), and you talk to it from a browser page that brisvox-server serves itself. The agent is examples/agents/local/local-en.toml.

What runs where:

browser (microphone) --WebSocket--> brisvox-server --ws--> sherpa-onnx   :6007  (STT, CPU)
                                           |------http--> Ollama         :11434 (LLM)
                                           '------http--> Kokoro-FastAPI :8880  (TTS, GPU or CPU)

The brisvox server and Kokoro (published with -p 127.0.0.1:8880:8880) bind 127.0.0.1, and Ollama does by default. sherpa-onnx's streaming_server.py does not: it has no bind-address flag and listens on all interfaces (its source passes host="" to websockets.serve, checked 2026-10-14). The three helper servers have no authentication, so on a machine that other computers can reach, block port 6007 (6006 for Turkish) from outside with the host firewall, or run the script inside a container that publishes the port only on loopback (-p 127.0.0.1:6007:6007). Nothing in this setup needs a key or an account.

This page is verified in parts: the server, sherpa-onnx (English and Turkish models) and Kokoro were each run on WSL2 (Ubuntu 24.04) with an NVIDIA RTX 5070, and the whole stack was exercised in a browser call. The project gives no memory or disk figure for the stack; plan for a few GB of downloads (the models, the Kokoro image, an Ollama model) and check what your GPU can hold. Tool calling through Ollama was measured on five local models and works reliably with one of them (step 2); the example agent itself defines no tools.

1. Build the server

git clone https://github.com/bilal07karadeniz/brisvox && cd brisvox
cargo build --release -p brisvox-server \
  --features provider-sherpa-onnx,provider-openai-compatible,provider-openai-speech,smart-turn-ort,webrtc
python3 scripts/models/fetch_models.py     # the Smart Turn model, SHA-256 pinned, into models/

The server looks for that model in BRISVOX_MODELS_DIR, whose default models is relative to the directory the server is started from, not to the binary. Start it from the repository root as the commands below do, or set an absolute path in step 3 (export BRISVOX_MODELS_DIR="$PWD/models"); check warns when the file is not found.

The repository pins its Rust toolchain in rust-toolchain.toml; rustup installs it on the first build. Why each feature:

  • provider-sherpa-onnx, provider-openai-compatible, provider-openai-speech are the three adapters this agent names (all-providers turns every adapter on instead).
  • smart-turn-ort loads the end-of-turn model. The agent uses the default, adaptive turn policy; without this feature and the model file the server logs a warning and every turn ends at a 3 second fallback deadline, which feels broken. Keep it.
  • webrtc is what serves the browser page: the page, its signaling and the WebSocket audio path live in the WebRTC listener. It compiles libopus, so you need a C compiler and cmake (sudo apt install build-essential cmake on Debian and Ubuntu). If you talk to the server with your own websocket client instead, leave it out and use BRISVOX_WS_ADDR (protocol).

2. Start the three helper servers

Each in its own terminal. These are the commands of the projects themselves, as in the header of the agent file.

Speech recognition: sherpa-onnx on port 6007 (English).

git clone https://github.com/k2-fsa/sherpa-onnx          # for python-api-examples/
pip install sherpa-onnx websockets numpy
wget https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-en-2023-06-26.tar.bz2
tar xvf sherpa-onnx-streaming-zipformer-en-2023-06-26.tar.bz2
M=./sherpa-onnx-streaming-zipformer-en-2023-06-26
python3 ./sherpa-onnx/python-api-examples/streaming_server.py \
  --tokens  $M/tokens.txt \
  --encoder $M/encoder-epoch-99-avg-1-chunk-16-left-128.int8.onnx \
  --decoder $M/decoder-epoch-99-avg-1-chunk-16-left-128.onnx \
  --joiner  $M/joiner-epoch-99-avg-1-chunk-16-left-128.int8.onnx \
  --port 6007

The model prints UPPERCASE text without punctuation; the language model copes with it. For a Turkish assistant use examples/agents/local/local-tr.toml, the seda-v0.1 model and --port 6006 (the commands are in docs/providers/sherpa-onnx.md). The ports are fixed by the two example agents, 6006 for Turkish and 6007 for English, so both stacks can run side by side.

The language model: Ollama.

ollama pull gemma4:12b-it-qat

gemma4:12b-it-qat is the model that called tools correctly in every test run (it needs about 7.7 GB of GPU memory and answers its first sentence in roughly 0.3 s on an RTX 5070). With less room, gemma4:e4b-it-qat (3.1 GB, even faster) speaks well but fumbles tool calls, so give the agent no tools. The example agents set reasoning_effort = "none": the models above think by default, which costs seconds before the first word. If the language model and Kokoro together do not fit in your GPU memory, Ollama puts part of the model on the CPU and the first sentence takes about 1.7 s; the Kokoro CPU image frees the GPU for the language model. The numbers for five models are in Tools ("Local models through Ollama").

The Linux package starts ollama serve as a service; start it yourself only when nothing listens on 127.0.0.1:11434. A model that was idle is unloaded and the next request pays for loading it (OLLAMA_KEEP_ALIVE on the server keeps it resident).

The voice: Kokoro-FastAPI on port 8880.

docker run --gpus all -p 127.0.0.1:8880:8880 ghcr.io/remsky/kokoro-fastapi-gpu:latest

On an RTX 50 series card use the tag latest-cu128 (CUDA 12.8). Without a GPU use the image ghcr.io/remsky/kokoro-fastapi-cpu:latest and drop --gpus all. The 127.0.0.1: in -p matters: Kokoro's own examples publish the port on every interface. The first request after a long idle is slower (the model has to warm up), so the agent's kokoro preset warms it in the background at the start of a call.

So the compute is split like this: speech recognition runs on the CPU, the language model and the voice use the GPU when there is one. Turkish needs a different voice server, and the one the project tried (Chatterbox) takes about 3 seconds to the first audio, too slow for a live turn (docs/providers/openai-speech.md, "Live").

3. Check, then run

target/release/brisvox-server check examples/agents/local/local-en.toml

check loads the agent file with the server's own loader and opens no sockets, so a typo, a provider the binary was built without or a missing Smart Turn model shows up now with the line and column. Then start the server with loopback-only settings:

export BRISVOX_AGENT_FILE=examples/agents/local/local-en.toml
export BRISVOX_MODELS_DIR="$PWD/models"        # absolute: the default `models` is relative to the working directory
export BRISVOX_WEBRTC_ADDR=127.0.0.1:8088      # the page and the WebSocket audio path
export BRISVOX_HEALTH_ADDR=127.0.0.1:8081      # the default is 0.0.0.0:8081
target/release/brisvox-server
curl http://127.0.0.1:8081/readyz              # ready

No credentials are needed: listeners on a loopback address start without them. Nothing here reads a .env file; export the variables in the shell that starts the server.

4. Talk to it

Open http://127.0.0.1:8088/demo?force=ws in Chrome or Edge, press Start, allow the microphone and say "Hello". Talk over the assistant to interrupt it.

There are two ways to open the page. /demo (no query) is the default: the page tries WebRTC first, and if the media connection is not up after 4 seconds it falls back to a WebSocket on the same port. /demo?force=ws skips WebRTC and sends the audio over that WebSocket from the start. Use ?force=ws with a browser on the same machine as the server: WebRTC media between a browser and a server on one host needs a real network address (Chrome gathers no loopback candidates), so the WebRTC attempt cannot succeed here and plain /demo only adds the 4 second wait (and a second session token). On a server other people reach over the network, use plain /demo: WebRTC carries the audio there, with the WebSocket as the safety net. The status line says which transport carries the call. The page examples/web-assistant/web/index.html does the same with its own server box if you want a page you can edit.

Notes for Linux, WSL2 and Windows

  • Linux. Everything above runs as written, and nothing leaves loopback, so no LAN address or firewall rule is involved.
  • WSL2 on Windows. This is what the project's own local runs used (Ubuntu 24.04). Run the server, sherpa-onnx, Ollama and Kokoro inside WSL2. Docker needs the NVIDIA container toolkit for --gpus all (Docker Desktop with the WSL2 backend, or Docker inside the distribution). Open the page from the Windows browser at http://127.0.0.1:8088/demo?force=ws: WSL2 normally forwards loopback ports to Windows, and localhost counts as a secure context for the microphone. If the page does not load, check localhostForwarding in .wslconfig or use WSL's mirrored networking (not tested here).
  • Windows without WSL2. The server itself builds on Windows (the project is developed there), but the helper servers and the commands on this page were run on Linux. Use WSL2 for them.
  • Any host other than your own machine. A page opened from another computer is not a secure context for the microphone, so it needs HTTPS in front, and the server then needs credentials (BRISVOX_SESSION_SECRET): see Deploying in a container and Security. Do not expose the helper servers beyond loopback (see the sherpa-onnx note at the top of this page).

If it does not work

  • check says a provider's feature is off. The binary was built without the adapter it names; rebuild with the --features of step 1.
  • The first turn is empty or answered late. Look at the server log for the STT connection (ws://127.0.0.1:6007 must be the port sherpa-onnx runs on) and at smart-turn-ort in the build line. The sherpa-onnx notes ("First-turn check" in sherpa-onnx) explain the padding the adapter sends.
  • Long silence before the first answer after a break. Ollama loading the model or Kokoro warming up; see the notes in step 2.
  • Settings and keys of the agent file: Configuration.

The pipeline

A call is one session. The production pipeline (brisvox-engine, run_call) is a chain of stages connected by bounded queues; each call has its own chain, so calls share nothing but the inference pool and the provider connections' budgets.

transport -> AudioIn -> VadWorker -> SttStage -> TurnDriver -> LlmRunner -> SegmenterStage -> TtsStage -> TransportOut -> transport
StageJob
AudioInResamples the call's audio to 16 kHz and forwards it at once. A copy goes to the VAD through a bounded, drop-oldest queue, so the audio path never waits on a model.
VadWorkerVoice-activity detection (Silero) as jobs on the dedicated inference pool. Emits backdated speech start/stop events.
SttStageStreams audio to the speech-to-text provider; at the VAD stop it sends Finalize immediately, in parallel with the end-of-turn model.
TurnDriverThe single authority over turns and epochs: end-of-turn decisions (silence, Smart Turn, the STT final), barge-in judgement, the fast Clear towards the transport.
LlmRunnerOne task per turn: streams the LLM, runs the tool loop, applies played-text truncation to the context.
SegmenterStageCuts the streaming text into speakable chunks; the first clause goes to the voice after one word.
TtsStageStreams text to the text-to-speech provider and cuts audio into 20 ms frames at the call's rate.
TransportOutPaces audio onto the transport, tracks what was actually played (marks, playback positions) and executes Clear.

A speech-to-speech model can replace everything between AudioIn and TransportOut with one stage; see Speech-to-speech models.

Rules the pipeline lives by

  • No heavy work on async workers. Anything heavier than about 50 microseconds (VAD, the end-of-turn model, resampling in bulk) runs on the dedicated brisvox-inference pool.
  • Every queue is bounded and has a documented overflow policy. Audio on the way in is lossy and counted (drop-oldest); control events are lossless; audio towards the caller applies backpressure.
  • Every network wait has a timeout and every task is cancellable. Cancellation leaves state consistent.
  • Time comes from an injected clock. That is what makes the simulations deterministic: a whole call runs on a paused clock, with mock providers, in milliseconds of real time.
  • Latency is a budget, and it is measured. The engine's own added time is budgeted at 3 ms (turn end to LLM request), 2 ms (TTS audio to transport) and 1 ms (barge-in decision to Clear applied), at p95. See Benchmark results for the measured values.

Read more: Production pipeline design (stage by stage, with the queues and the failure policy) and Architecture (crates, frame flow, routing).

Turns and interruptions

Turns and epochs

Every user turn gets a number, its epoch. The turn driver is the only component that advances it. Bot-side frames (LLM text, TTS audio, playback marks) carry the epoch they were produced for; every stage on the bot side drops frames from a stale epoch. This is the one mechanism that keeps a late audio chunk, a late LLM token or a late tool result from leaking into the next turn.

Deciding that the caller has finished

A pure state machine (TurnMachine) with injected time decides when a turn ends. The policies:

  • Adaptive (default). When the VAD sees the caller fall silent, two things start at once: the STT Finalize, and the Smart Turn model (an on-device end-of-turn classifier, run on the inference pool). If the model says the utterance is complete, the turn ends as soon as the STT final has arrived; if it says the caller is not done, the turn waits, with a fallback deadline (fallback_silence_ms) so an undecided pause cannot hang the call.
  • Fixed. The turn ends after a fixed silence.

Because the turn end waits for the STT final (a "finals gate", bounded by a timeout), the added wait is the larger of the two, not their sum.

With speculation enabled, the LLM request is started at a candidate end of turn and committed only when the turn really ends; a read-only tool may run on a speculation, a side-effect tool is held until the commit. What this buys, and what it costs in extra requests, is measured in bench/results/speculation-fixtures.md (summarised in Benchmark results).

Barge-in

When the caller starts speaking while the bot is speaking, the turn driver judges the candidate interruption (the interruption policy: minimum words, backchannel lists such as "yeah" or "uh-huh" per language, an echo guard) and, if it is real:

  1. starts the new epoch (an Interrupt always precedes the new UserTurnStarted);
  2. sends Clear straight to the transport on a fast path, so queued audio is dropped and the client is told to flush what it buffered;
  3. cancels the LLM round, the segmenter and the open TTS contexts;
  4. cuts the context to what was actually played (Played-text truncation).

The budget for step 2, decision to Clear applied, is 1 ms at p95. After it, no stale audio reaches the wire.

Read more: "Turn-taking" in Architecture (the state diagram and the policies), sections 2 and 3 of the pipeline design (epoch ownership and the barge-in path step by step), and the interruption keys in Configuration.

Played-text truncation

When a caller interrupts, the LLM has already produced (and the TTS may have synthesised) more text than the caller ever heard. If the context kept the whole answer, the model would believe it had said things the caller never heard, and the next turn would be built on a conversation that did not happen.

brisvox's rule: after an interruption, the LLM context contains only what was actually played.

How it is known what was played

The transport reports what reached the caller:

  • Marks. The engine ends every utterance with a mark; the client echoes it when the audio before it has played. A utterance whose end mark came back is kept whole.
  • Playback positions. For a cut utterance, the transport reports how much audio it had released to the wire. The heard position is that position minus the client's playback lead (reported by the carrier where it can, e.g. Twilio, else a configured margin).
  • Word timestamps. TTS providers that return word timings let the position be mapped to an exact word. For providers without them (for example Deepgram Aura) the engine falls back to a character-rate estimate and the cut is coarser.

The engine waits a short, clock-bounded time for these reports to settle (250 ms by default, counted from the barge-in), then cuts the assistant message at the heard word. Orphaned tool results are removed with the message that requested them, and the result is validated: a context that would break the LLM's message rules is a test failure, not a runtime surprise.

The text that was sent to the TTS is tracked as exact slices of the assistant text, so the cut is byte-accurate against what the LLM wrote, even when the TTS normalised numbers or abbreviations.

Read more: section 5 of the pipeline design (sources of truth and the procedure) and "Played-text truncation" in Architecture.

Tools

A tool is a function the LLM can call during a turn: look up an order, book a slot, transfer the call. brisvox's tool contract is built around the two things that go wrong in voice: a tool that runs when it should not, and a caller who is kept waiting in silence.

Kinds

  • read_only tools only look things up. They may run on a speculative LLM start, they are cancelled when the caller interrupts, and running one twice is harmless.
  • side_effect tools change the world (book, cancel, transfer, charge). They never run speculatively (they are held until the turn is committed and discarded if the speculation is cancelled), never run twice (the idempotency key is derived from the session, the turn epoch and the call id; a replay of that call returns the recorded result, and an identical call in a later response of the same turn is refused as already_done; a later turn or another call is not covered, so use your own idempotency key for rules such as "one refund per order, ever"), and are not cancelled by an interruption: they complete, are recorded, and the outcome is reported to the LLM on the next turn.

Lifecycle of a call

  1. The LLM streams a tool call; the argument deltas are buffered until complete. Partial JSON never executes.
  2. The arguments are validated against the tool's JSON Schema. A failure goes back to the LLM as a structured error, with bounded retries and then a spoken fallback.
  3. Independent calls run in parallel; results are appended to the context in the order the LLM issued them.
  4. If a tool has not answered after a delay (1.2 s by default, or the tool's own filler_after), the agent says a filler phrase so the line is not silent.

Where tools come from

  • Built-in telephony tools: transfer_call, send_dtmf, end_call, over the carrier's REST call control (Telephony).
  • In-process tools: Rust tools registered through brisvox_server::tools::AppTools; an agent file lists, by name, the ones the LLM may see. Your own binary is one line, brisvox_server::app::run_with_tools(Arc::new(MyTools)); examples/rust-tools is a complete project.
  • Remote tools in your own process, through the Python or TypeScript SDK and the agent protocol.

Read more: the Tools reference (the contract, the tool engine, filler and interruption semantics, per-request LLM metrics) and Configuration (the tools key of an agent file).

Agent definitions (configuration)

TOML and YAML. Code: crates/brisvox-server/src/config/. Examples: examples/agents/*.toml and their YAML twins *.yaml (each one is loaded by the test config::tests::the_examples_are_valid, and config::yaml::tests::the_yaml_examples_are_twins_of_the_toml_ones checks that every twin reads into the same definition, so they cannot rot or drift).

One TOML or YAML file describes one agent: who it is (system prompt, language, tools by name), how turns and barge-ins are judged, and one provider per stage (STT, LLM, TTS) with its options. Absent keys keep the engine's and the provider adapters' documented defaults; the defaults live next to the code that uses them (EngineConfig::DEFAULT, XxxConfig::new), not in this file.

Loading

#![allow(unused)]
fn main() {
use brisvox_server::config::{self, ProcessEnv};

// Blocking file read: at startup, or inside spawn_blocking.
let agent = config::load_file(Path::new("examples/agents/support-en.toml"), &ProcessEnv)?;
// Only the tools the definition names (all must be registered): the LLM never sees the rest.
let tools = Arc::new(agent.tool_registry(&server_registry)?);
let engine = agent.engine();                   // EngineConfig, already validated
let stt = agent.stt()?;                        // SttProviderConfig::Deepgram(DeepgramConfig) ...
let llm = agent.llm()?;                        // LlmProviderConfig::OpenAi(OpenAiConfig) ...
let tts = agent.tts(clock, shutdown)?;         // TtsProviderConfig::Cartesia(CartesiaConfig) ...
let builder = agent
    .configure(SessionBuilder::new(engine, clock, timeline, offload, s, l, t)?)
    .with_tools(Arc::clone(&tools), Arc::new(ToolEngine::new(tools, /* ... */)));
}

load_file picks the format by extension: .yaml/.yml (any case) is YAML, anything else is TOML. load_str(text, source_name, &env) does the same for TOML text held in memory, and load_str_as(text, source_name, Format::Yaml, &env) for either format; parse / parse_as read the file format only (no cross-field checks, no secrets). Env is the secret source: ProcessEnv in production, a BTreeMap/HashMap in tests.

Loading has three steps:

  1. Syntax and types. Every table rejects unknown keys (deny_unknown_fields): a typo is an error, never a silently ignored setting. Values are type- and range-checked while they are read. The first such error stops reading.
  2. Cross-field checks (all reported together, in file order), see below.
  3. Secrets. Each api_key_env names an environment variable; its value is read into a brisvox_core::Secret<String> (Debug/Display print [REDACTED]). A missing, empty or non-UTF-8 variable is an error naming the variable and the key, never a value. Surrounding whitespace (the trailing newline of an env-file or a mounted secret file) is trimmed; whitespace or control characters inside the value are an error. A key pasted into api_key_env by mistake (longer than 32 bytes, containing -, or mixing lower-case letters and digits) is never echoed: messages show its first 4 characters and its length, with a hint.

After that the mapped EngineConfig goes through EngineConfig::validate as a last net.

Errors

Every problem names the file, the line and column, the key path and what is wrong (the same key paths and messages for TOML and YAML; positions refer to the file as written):

invalid agent definition agents/tr.toml (3 problems)
  agents/tr.toml:4:12: `agent.language`: Deepgram Aura has no voice for `tr` (it speaks en, es, de, fr, nl, it, ja); use Cartesia or ElevenLabs for this language
  agents/tr.toml:21:15: `tts.sample_rate`: Deepgram Aura does not deliver PCM16 at 44100 Hz (8000, 16000, 24000 or 48000)
  agents/tr.toml:25:16: `tts.api_key_env`: environment variable `DEEPGRAM_API_KEY` (named by tts.api_key_env) is not set; secrets are read from the environment, never from the file

ConfigError { source_name, issues: Vec<Issue> }, Issue { key, position: Option<(line, column)>, message }; columns count characters, not bytes. The key path of a read error is found by walking the parsed document for the most specific key or value whose span contains the error position (toml's own error carries the span but does not expose its key path). The YAML reader tracks the key path itself while it reads. An api_key literal gets the hint to use api_key_env in both formats.

Checking a file

brisvox-server check <agent-file> [--require-secrets] validates a definition offline, before a deploy or inside CI. brisvox-server check --env does the same for the definition the environment names (BRISVOX_AGENT_DEFINITION, or the file BRISVOX_AGENT_FILE names). It runs the same loader the server runs at startup, so it reports every problem the server would refuse the file for, each with its key path and line and column. It opens no sockets, starts no runtime and makes no network call.

$ brisvox-server check agents/support.toml
error: agents/support.toml:17:1: `stt.deepgram.endpointing_msx`: unknown field `endpointing_msx`, expected one of ...
FAILED: agents/support.toml has 1 error
$ brisvox-server check agents/support.toml
warning: agents/support.toml:31:15: `llm.api_key_env`: environment variable `OPENAI_API_KEY` is not set; ...
OK: agent "support" (stt deepgram, llm openai, tts cartesia) (1 warning)
$ brisvox-server check agents/support.toml
error: agents/support.toml:17:10: `agent.tools[0]`: tool `lookup_order` is not registered by this binary, and the server refuses to start with it; ...
FAILED: agents/support.toml has 1 error

The exit status is 0 when the file has no errors (warnings do not count) and 1 when it has any. A wrong command line exits 2. Besides the errors the loader reports (unknown or misplaced keys, values out of range, a language or sample rate the provider does not support, a provider whose cargo feature is not compiled into this binary, with the feature to turn on), check reports:

findinglevel
an environment variable named by api_key_env / token_env is not setwarning; an error with --require-secrets (use it on the machine or CI job that has the real environment)
a variable is set but empty, or has spaces or control characters insideerror
a tool in agent.tools that this binary does not register: the built-in telephony tools need the call-control feature and BRISVOX_CALL_CONTROL set to a carrier (with both they count as registered; check does not verify the carrier's credentials, which the server needs at start), the demo tools the demo-tools feature, your own tools AppTools (docs/TOOLS.md)error: the server refuses to start with it. If it is a Python/TypeScript SDK tool, remove it from agent.tools: the SDK agent declares it over the agent link
adaptive turns, and no Smart Turn model: the binary lacks the smart-turn-ort feature, or the model file (BRISVOX_SMART_TURN_MODEL, default $BRISVOX_MODELS_DIR/smart-turn-v3.2-cpu.onnx) does not existwarning (without the model, turns end at their fallback deadline, or the start fails if the feature is on and the file is missing); BRISVOX_SMART_TURN_MODEL=off silences it

check answers for the binary it runs in: a server built with fewer provider features than the one you deploy reports errors the deployed one would not, so run it with the build you ship. A binary of your own that embeds the server with its own tools (brisvox_server::app::run_with_tools, docs/TOOLS.md) has the same check command and knows those tools' names. In Rust, brisvox_server::check::check_file returns the report as data (errors, warnings).

Editor support (JSON Schema)

docs/agent.schema.json is a JSON Schema (draft 2020-12) of the agent file. With it an editor completes keys, shows each key's description from this reference, and underlines a typo, an unknown table, a number out of range, a wrong enumeration value or a URL with the wrong scheme while you type, before brisvox-server check runs. The schema is generated from the same Rust types the loader reads (deny_unknown_fields is additionalProperties: false; the ranges of the millisecond, count, real-number and sample-rate settings are minimum/maximum/enum; names, environment variable names, language tags and URLs are patterns). A test keeps the committed file equal to the generated one and every file in examples/ valid against it.

TOML, with Taplo (the "Even Better TOML" VS Code extension, the taplo CLI, Helix, Neovim): put the schema directive on the first line of the file,

#:schema ./agent.schema.json
[agent]
name = "support"

(a path relative to the file, or the URL https://raw.githubusercontent.com/bilal07karadeniz/brisvox/main/docs/agent.schema.json, once published), or bind it to all your agent files once in .taplo.toml:

[[rule]]
include = ["agents/**/*.toml"]
schema.path = "docs/agent.schema.json"

YAML, with the YAML language server (the Red Hat "YAML" VS Code extension, yaml-language-server in Neovim, Helix and others): a modeline comment on the first line,

# yaml-language-server: $schema=./agent.schema.json
agent:
  name: support

or, in VS Code settings.json, "yaml.schemas": {"docs/agent.schema.json": "agents/*.yaml"}.

brisvox-server --print-schema prints the schema of the binary you have, so a copy always matches its version. It needs the schema cargo feature; without it the flag is an error that names the feature. To regenerate the committed file after a change to the config types:

cargo run -p brisvox-server --features schema -- --print-schema > docs/agent.schema.json

What the schema cannot say stays with brisvox-server check, which remains the authority: the cross-field rules (turn.silence_ms is required with fixed_silence, confirm_tools must be listed in tools), the environment variables and the tools of your deployment, and the provider cargo features of the binary. Two small differences: the schema takes no null (the YAML reader treats an empty turn: or silence_ms: ~ as an absent key, and the schema flags it; leave the key out), and its length limits count characters where the loader counts bytes.

YAML

The same file in YAML (examples/agents/support-en.yaml is the twin of support-en.toml): every TOML table is a mapping, every [stt.deepgram] options table is a deepgram: mapping under stt:, and the keys, values, defaults and checks are identical.

agent:
  name: support-en
  language: en
  system_prompt: |
    You are the voice assistant of Example Shop. Answer in one or two short spoken sentences.
  tools: [lookup_order]

stt:
  provider: deepgram
  api_key_env: DEEPGRAM_API_KEY
  deepgram:
    model: nova-3
    endpointing_ms: 300

llm:
  provider: openai
  api_key_env: OPENAI_API_KEY

tts:
  provider: cartesia
  api_key_env: CARTESIA_API_KEY
  voice: replace-with-a-cartesia-voice-id

Rules:

  • Scalars follow the YAML 1.2 core schema. true/false, integers (300, +5, 0x1F, 0o17), floats (0.7, 1e3) and null/~/nothing are typed; every other plain scalar and every quoted or block (|, >) scalar is a string. yes/no/on/off are strings, so enabled: yes is an error (invalid type: string "yes", expected a boolean), exactly as enabled = "yes" is in TOML. A voice id that looks like a number must be quoted (voice: "12345"); TOML needs the quotes anyway.
  • Null is the same as leaving the key out. An empty turn: is an empty [turn] table; an empty backchannels: keeps the preset's phrase sets ([] turns them off). A required value left empty (voice:) is invalid type: null, expected a string.
  • Not supported, each an error at its position: anchors (&x) and aliases (*x), tags (!!str, !x; quote the value instead), a second document (---), keys that are lists or mappings, duplicate keys (reported at the second one), nesting deeper than 16 levels.
  • Positions: line and character column of the value (or of the key, for an unknown key or a problem of a whole mapping such as an options table for another provider). A byte order mark and Windows line ends are fine; a lone carriage return (classic Mac line ends) is an error at its position, as in TOML. Reading is linear in the file size, also for one long line.
  • Syntax errors read YAML syntax error: <parser message> with the key path of the enclosing value.
  • The parser is saphyr-parser (pure Rust, YAML 1.2, MIT OR Apache-2.0) behind the brisvox-server feature yaml, on by default. A build with --no-default-features reads TOML only and rejects a YAML definition with a message naming the feature.

File reference

Durations are whole milliseconds (*_ms). Ranges are inclusive.

[agent] (required)

keytypenotes
namestring1-64 of A-Z a-z 0-9 _ -
languagestringen or tr, region allowed (en-US, tr-TR). Selects the segmenter and speech normalizer; default STT and TTS language
system_promptstringnon-empty, at most 100 000 bytes
toolsarray of stringstool names (same rule as name), no duplicates; AgentConfig::tool_registry builds the session's registry from exactly these (unlisted server tools are not offered to the LLM), AgentConfig::check_tools only checks them. The server binary refuses a definition naming a tool it does not provide (docs/TOOLS.md "In the server binary")
confirm_toolsarray of stringsoptional: tools of tools that are held for the user's confirmation: the model's call does not run them, it asks the user, and they run only after the user's next reply confirms (docs/TOOLS.md, "Confirmation gate"). Each must be listed in tools and be a SideEffect tool (AgentConfig::tool_registry reports a ReadOnly one at agent.confirm_tools[i]). Example: confirm_tools = ["transfer_call"]

[turn]

keytypedefaultnotes
policy"adaptive" / "fixed_silence"adaptiveSmart Turn, or a fixed silence
silence_ms1..=10000fixed_silence only, required there
fallback_silence_ms100..=300003000adaptive only
require_finalboolfalseadaptive only
final_grace_ms0..=10000800adaptive only
final_wait_ms1..=300001500must stay below the STT finalize timeout (3000)
response_deadline_ms1000..=30000020000must exceed final_wait_ms

[interruption]

preset ("recommended" default, or "off"), then per-field overrides: min_speech_ms (0..=5000), min_words (0..=20), confirm ("all"/"any"), echo_guard_ms (0..=5000), echo_min_words (1..=20), backchannels (array of "en", "tr"; [] turns them off), backchannel_wait_ms (0..=5000), backchannel_max_speech_ms (0..=10000), vad_hangover_ms (0..=2000). Meanings: InterruptionConfig in crates/brisvox-engine/src/config.rs.

[speculation] and [early_analysis]

[speculation]: enabled (false), on_partial (true), partial_stable_ms (0..=60000, 0; at most max_age_ms: the stable-partial gate, the partial of a VAD stop is offered only after the caller stayed silent and the text did not change for this long), max_per_turn (1..=16, 3), max_age_ms (100..=60000, 5000), max_buffered_events (1..=4096, 256).

[early_analysis]: enabled (false; needs turn.policy = "adaptive"), fall_delay_ms (0..=1000, 64), max_tail_ms (1..=5000, 300), max_per_turn (1..=32, 4). The assembly applies the VAD half itself (SessionBuilder::with_vad).

[audio]

frame_ms (5..=100, 20) must be a whole number of samples at 16 kHz and at call_sample_rate; call_sample_rate (8000, 16000, 22050, 24000, 44100 or 48000; default 16000) is only the fallback for a transport that reports no rate (telephony transports report 8000 themselves).

[filler]

When a slow tool round is announced with a filler phrase (docs/TOOLS.md "Tool UX"): enabled (true), delay_ms (100..=10000, 1200: the same delay for every tool that has a filler), per_tool (false; true takes each tool's own filler_after, 700 ms unless the tool sets another, and cannot be combined with delay_ms). The session default is the fixed 1.2 s (why: docs/TOOLS.md, "Why the default is a fixed 1.2 s"). The phrases and their pre-rendered clips are set in code (SessionBuilder::with_filler_policy, with_filler_clips).

[recording]

What call recording keeps and for how long (docs/PRIVACY.md). Recording itself is switched on by the environment (BRISVOX_RECORDING_DIR; the directory is infrastructure, not an agent setting), and a key set here wins over the matching environment variable. The table without BRISVOX_RECORDING_DIR is a start error: a privacy setting is never silently ignored. TOML and YAML read it identically (recording: in YAML).

keytypedefaultenvironment variable it overrides
transcriptbooltrueBRISVOX_RECORDING_TRANSCRIPTS
audioboolfalseBRISVOX_RECORDING_AUDIO (audio is never redacted)
redactbooltrueBRISVOX_RECORDING_REDACT_PII
redact_kindslist of email, iban, id, card, phone, numberallBRISVOX_RECORDING_REDACT_KINDS; not empty while redact is on, and not combined with redact = false
retention_daysinteger 1..=365030BRISVOX_RECORDING_RETENTION (which also accepts 90s..forever)
[recording]
audio = false
redact_kinds = ["email", "iban", "card", "phone"]
retention_days = 14

The merged result is checked like the environment's alone (something must be recorded; the retention must cover the longest recorded call).

Encryption at rest has no file key: BRISVOX_RECORDING_AGE_RECIPIENTS (age public keys, cargo feature recording-encryption) is environment-only, like the directory, because it belongs to the deployment and not to an agent. With it set, every file the table above governs is written as <name>.age (docs/PRIVACY.md, "Encryption at rest").

[telephony]

What the built-in telephony tools may do (docs/TELEPHONY.md). They exist only in a server with a carrier's call control (BRISVOX_CALL_CONTROL, docs/OPERATIONS.md); an agent lists the ones it uses in agent.tools like any other tool.

keytypenotes
destinationsarray of tableswhere transfer_call may send the call, at most 32: name (1-32 of A-Z a-z 0-9 _ -, unique; the model picks a name, never a number), number (E.164, + and 8 to 15 digits), description (optional, shown to the model, cut to 120 characters). Without any, transfer_call is not offered
dtmfbooloffer send_dtmf (default true); it is offered only when every configured carrier binding can send tones (Twilio can when the server hosts the Twilio listener: docs/TELEPHONY.md 5)
warmbooloffer warm transfers: transfer_call with mode = "warm", where the receiving party hears a summary first (default false). Offered only when the callback listener runs (BRISVOX_CALLBACK_ADDR), which finishes a transfer, and every configured carrier binding can do one (a caller id; Telnyx also BRISVOX_TELNYX_CONNECTION_ID); otherwise the server logs a warning and offers cold transfers only
voicemailtableanswering-machine handling, below
[telephony]
warm = true
destinations = [
  { name = "front_desk", number = "+15555550100", description = "the clinic's front desk" },
]

[telephony.voicemail]

For an agent that makes outbound calls (docs/TELEPHONY.md 6). The carrier's answering-machine detector classifies the answer, and the verdict reaches the call as the stream parameter answered_by or as a callback to the callback listener. A machine whose greeting ended is played message, then the call is hung up through the carrier; a person gets the normal conversation. A call that never gets a verdict (an inbound call) is not touched.

keytypenotes
messagestringrequired; the fixed message left on a machine, spoken as it is (1 to 1000 characters). Put nothing in it the callee did not consent to hear on a recording
on_unknownstringtreat_as_human (default), treat_as_machine (wait for the greeting to end by a fixed time, then leave the message) or hang_up
detect_onlyboolthe detector never reports the end of a greeting (Twilio MachineDetection=Enable, Telnyx detect): hang up on machines instead of speaking over a greeting. Default false (Twilio DetectMessageEnd, Telnyx greeting_end)
[telephony.voicemail]
message = "Hello, this is the clinic. Please call us back tomorrow."
on_unknown = "treat_as_human"

The bot speaks the message through the engine's say input, so it needs the call's engine to take lines: with an out-of-process agent (BRISVOX_AGENT_URL) the link owns that input and a machine verdict can only hang up.

Where the out-of-process agent of this definition's calls listens (docs/AGENT_PROTOCOL.md). Without the section the server-wide BRISVOX_AGENT_URL applies.

[agent_link]
url = "wss://agent.internal/ws"       # wins over BRISVOX_AGENT_URL
token_env = "AGENT_TOKEN"             # default: BRISVOX_AGENT_TOKEN
# allow_insecure = true               # accept plaintext ws:// to a host outside the private networks
# caller_identity = true              # tell the agent process who is calling (telephony calls)

[[agent_link.routes]]                 # picked by name in a call's start message
name = "billing"
url = "wss://billing-agent.internal/ws"
token_env = "BILLING_AGENT_TOKEN"     # a route never gets the default token
# caller_identity = true              # a route has its own switch

url and every route url must start with ws:// or wss://. A plaintext ws:// URL whose host is not on a private network (loopback, RFC 1918, link-local, localhost, *.internal, *.local, a single-label service name; a resolver's search list can turn the last into a public name, so it is accepted with a one-time warning) is refused at startup unless allow_insecure = true (on the section, or on one [[agent_link.routes]] entry) or BRISVOX_AGENT_ALLOW_INSECURE=1 is set (docs/AGENT_PROTOCOL.md, "Transport and connection"); route names are 1 to 32 letters, digits, _ or -, each once, at most 32 routes. A named token_env that is not set fails the start. A call picks a route with the websocket start message's metadata key agent_route, or the TwiML parameter agent_route; a start message never carries a URL, and an unknown route falls back to the default process.

caller_identity (default false, or BRISVOX_AGENT_CALLER_IDENTITY=1 when the key is not set) sends the caller's number, the called number and the carrier's call id to the agent process in session.start.call for Twilio, Telnyx and SIP calls (docs/AGENT_PROTOCOL.md, "Caller identity"). A route has its own caller_identity and never inherits the section's or the environment's. The numbers are withheld while the server redacts phone numbers (docs/PRIVACY.md).

[stt], [llm], [tts] (required)

Each stage has provider, api_key_env and an optional options table named after the provider ([stt.deepgram]). An options table for a provider other than the chosen one is an error.

stagekeynotes
sttproviderdeepgram, assemblyai, gladia, sherpa_onnx, openai_realtime_stt
sttapi_key_envrequired for deepgram, assemblyai, gladia and the openai dialect of openai_realtime_stt; optional for sherpa_onnx and the local dialects (then it names a bearer token for a proxy, or the server's key)
sttlanguagedefault agent.language
llmprovideropenai, anthropic, gemini, openai_compatible
llmapi_key_envrequired except for openai_compatible (a local vLLM/Ollama has no key; set it for a hosted service such as OpenRouter or Groq)
llmmax_tool_rounds1..=32, default 6
llmcontext_window_tokens256..=2000000, default 4000
ttsprovidercartesia, elevenlabs, deepgram_aura, openai_speech
ttsapi_key_envrequired except for openai_speech (a local Kokoro/Chatterbox server has no key)
ttsvoiceprovider voice id; for Aura the model string (aura-2-thalia-en); for openai_speech the server's voice (af_heart on Kokoro)
ttssample_raterate the provider delivers, default 24000; the engine resamples to the call
ttslanguagedefault agent.language

Options tables:

  • [stt.deepgram]: model (nova-3), endpointing_ms (0..=10000, 0 = off, 300), utterance_end_ms (1000..=5000), smart_format (true), keyterms (<= 100), region (global/eu/au/india), base_url.
  • [stt.assemblyai]: speech_model (universal-3-6-pro), region (edge/us/eu), keyterms (<= 100), format_turns, min_turn_silence_ms, max_turn_silence_ms (>= min), base_url.
  • [stt.gladia]: model (solaria-1), region (us_west/eu_west), code_switching, vocabulary (<= 100), endpointing_ms (10..=10000), base_url.
  • [stt.sherpa_onnx] (self-hosted, docs/providers/sherpa-onnx.md): base_url (ws://host[:port], default ws://127.0.0.1:6006; any host, no path), path (/), sample_rate (the model's rate, 16000), finalize_timeout_ms (100..=10000, 1500), finalize_padding_ms (0..=5000, 300: silence sent before Done, 0 for the C++ server). The model decides the language. A token (api_key_env) is only allowed over wss:// or to a loopback host.
  • [stt.openai_realtime_stt] (docs/providers/openai-realtime-stt.md): dialect (openai, nim, speaches, vllm; default openai), base_url (default per dialect), path_and_query, model (required for speaches and vllm; OpenAI default gpt-live-transcribe), prompt (<= 4096 bytes), punctuation (NIM, true), delta_replaces, min_commit_ms (0..=2000), finalize_timeout_ms (100..=10000, 2000), sample_rate.
  • [llm.openai]: model, reasoning_effort (disabled/low/medium/high/xhigh/max), max_output_tokens, parallel_tool_calls, service_tier, base_url.
  • [llm.anthropic]: model, max_tokens (1024), effort (low..max), thinking (disabled/adaptive/between_tools/model_default), base_url.
  • [llm.gemini]: model, thinking_level (minimal/low/medium/high), max_output_tokens, temperature (0..=2), base_url.
  • [llm.openai_compatible] (required for that provider): preset (groq/together/fireworks/vllm/ollama/openrouter), or base_url and model; max_tokens, temperature (0..=2), reasoning_effort (sent as the request field of that name, e.g. none or low for a reasoning model that thinks by default), openrouter_order (OpenRouter provider slugs, sent as provider.order) with openrouter_allow_fallbacks (provider.allow_fallbacks; false = served by the listed provider or refused). Both are OpenRouter-only: setting either one without preset = "openrouter" or a base_url whose host is openrouter.ai (or a subdomain) is a configuration error (brisvox-server check prints it with the line of the key and exits 1), because any other server would silently ignore the pin or refuse the request. A hosted service without a preset is this table with base_url and model. OpenRouter has the preset openrouter (base_url = "https://openrouter.ai/api/v1", default model openai/gpt-6-luna): preset = "openrouter", optionally model = "<author>/<model>" (the id from OpenRouter's model page) and api_key_env naming the variable that holds your OpenRouter key; the full recipe is "OpenRouter" in docs/PROVIDERS.md.
  • [tts.cartesia]: model (sonic-3.6), max_buffer_delay_ms (0..=5000), url.
  • [tts.elevenlabs]: model (eleven_flash_v2_5), stability, similarity_boost, style (0..=1), speed (0.7..=1.2), use_speaker_boost, url.
  • [tts.deepgram_aura]: speed (0.7..=1.5), url.
  • [tts.openai_speech] (required for that provider; a local server speaking POST {base_url}/audio/speech, docs/providers/openai-speech.md): base_url (required, with the /v1 prefix; http:// to any host is accepted, but not together with an api_key_env), preset (generic default, kokoro, chatterbox, speaches, vllm_omni; sets the model, the audio format and the request fields), model (required for generic), speed (0.25..=4), format (pcm or wav), first_audio_timeout_ms (500..=120000, default 15000), warm_text (text of the background model warm-up request started at call start, audio discarded; "" = off; default "Hi." for kokoro, off for the other presets), warm_interval_ms (1000..=3600000, 60000: at most one warm-up per interval across all calls). The server delivers 24 kHz: leave tts.sample_rate unset or set it to exactly 24000 (the example agents under examples/agents/local/ set it, which is the same thing written out); any other value is a load error, because only the speaches preset can resample on the server. The engine resamples 24 kHz to the call's rate itself. examples/agents/local-kokoro.toml is a complete agent.

Secondary providers ([stt.secondary], [llm.secondary], [tts.secondary])

Optional. A second provider of the same stage that calls fail over to when the primary is down (the mechanism is described in docs/PIPELINE.md, "Resilience layer"): a circuit breaker per provider is shared by all calls, a failed connect or a session that dies is retried on the other provider within the call's and the process's retry budgets, and an LLM request that failed before any content is re-issued to the secondary. Without a secondary every call still runs behind the breaker, the retry budgets and the TTS idle refresh; it just has nowhere to go.

A secondary table takes the same keys as its stage (provider, api_key_env, the options table named after the provider; voice for TTS), so it can be a different provider or the same one with another key or region. These keys belong to the call and come from the primary's table, so a secondary may not set them: language (STT, TTS), sample_rate (TTS), max_tool_rounds and context_window_tokens (LLM), and secondary itself. A TTS secondary needs its own voice: a voice id belongs to one provider, and the engine's voice id is the primary's. The secondary is checked like a primary against the call's language and rate (for example, a Turkish call cannot name a secondary Deepgram Aura voice, and Aura must deliver the primary's sample_rate).

Its key is read from its own api_key_env at startup: a missing key stops the start like a primary's. Keys are never in the file. examples/agents/failover-en.toml (and .yaml) shows all three:

[stt]
provider = "deepgram"
api_key_env = "DEEPGRAM_API_KEY"

[stt.secondary]
provider = "assemblyai"
api_key_env = "ASSEMBLYAI_API_KEY"

[tts.secondary]
provider = "elevenlabs"
api_key_env = "ELEVENLABS_API_KEY"
voice = "voice-id-at-elevenlabs"

Endpoint overrides are checked while the file is read, at the value's position, with the rule the adapter applies when it is built: Deepgram and AssemblyAI STT base_url and every TTS url are WebSocket URLs (wss://), Gladia STT and every LLM base_url are HTTP URLs (https://). The plain scheme (ws://, http://) is accepted only to a loopback host (localhost, 127.0.0.x, ::1), since the API key would otherwise travel unencrypted. Messages never repeat the URL (it may carry credentials). Path and query rules (some adapters take a base URL without a path) are still checked by the adapter.

Not exposed on purpose: STT/TTS audio encodings (the engine always sends 16 kHz PCM16 to STT and expects PCM16 from TTS, resampling at the edges) and the adapters' internal timeouts and replay settings (their defaults are tuned in the adapters; change them in code if needed).

Language subtags: Deepgram STT receives the tag as written (tr-TR is accepted there); AssemblyAI, Gladia, Cartesia and ElevenLabs receive the primary subtag (tr).

WebRTC reachability (environment variables)

Not part of the agent file ([turn] above is turn-taking, unrelated to TURN relays): these are process environment variables read by brisvox-server with the webrtc feature, next to the BRISVOX_WEBRTC_* ones in docs/OPERATIONS.md. How to choose and deploy: docs/DEPLOY.md "WebRTC: making the server reachable from browsers"; protocol facts and sources: docs/providers/webrtc.md section 11. A bad value refuses to start and names the variable.

VariableDefaultMeaning
BRISVOX_WEBRTC_WS_PATH/wsPath of the WebSocket fallback on the signaling port, or off. Starts with /, no query or trailing /, not under /whip. A WebSocket upgrade there is authenticated like POST /whip (same static token or session tokens, audience brisvox-webrtc; Authorization: Bearer, the subprotocol brisvox-token.<token> or ?token=), judged by the Origin rule (BRISVOX_WEBRTC_CORS_ORIGIN, else the page's own host), and counted against the same offer rate, per-address call cap, handshake pool and call limit. Bounds: HTTP head 8 KiB, frames 64 KiB audio / 16 KiB control, start within 5 s, pings every 10 s.
BRISVOX_WEBRTC_TCP_ADDRunset (off)Address of the ICE-TCP listener, e.g. 0.0.0.0:50000. Adds a passive TCP host candidate (RFC 6544; RFC 4571 framing) to every answer; UDP stays preferred. Needs BRISVOX_WEBRTC_CANDIDATE_IP when bound to an unspecified address.
BRISVOX_WEBRTC_TCP_CANDIDATE_PORTthe listener's portPort written into the TCP candidate when a proxy/port mapping in front of the server uses another (1 to 65535).
BRISVOX_WEBRTC_TCP_MAX_CONNECTIONS256ICE-TCP connections served at once (1 to 65536); more are closed unanswered.
BRISVOX_WEBRTC_TCP_MAX_PER_IP16ICE-TCP connections one source IP may hold (0 = no limit). Set 0 when a TCP proxy sits in front of the server: every peer then has the proxy's address and the limit would apply to all browsers together.
BRISVOX_WEBRTC_TCP_IDLE_TIMEOUT10sHow long a connection may take to name a live call (its first STUN request) before it is closed. The deadline is absolute; junk does not extend it.
BRISVOX_WEBRTC_TCP_MAX_FRAME2048Largest RFC 4571 frame in bytes (512 to 65535); a larger one closes the connection.
BRISVOX_WEBRTC_TCP_QUEUE20Frames queued per connection towards the browser (8 to 4096, drop-oldest). A writer stuck longer than the write timeout closes the connection.
BRISVOX_TURN_PROVIDERunset (off)coturn, cloudflare or twilio. Unset: the server offers no relay and GET /whip/ice returns {"iceServers":[]}.
BRISVOX_TURN_TTL600Lifetime of issued credentials, seconds, 30 to 86400. Fetched credentials are cached for 60 s (at most half of it).
BRISVOX_TURN_URLSrequired for coturnComma-separated turn: / turns: URLs of your coturn server (up to 16).
BRISVOX_TURN_SECRETrequired for coturncoturn's static-auth-secret (use-auth-secret mode). Never sent to browsers, never logged.
BRISVOX_TURN_CLOUDFLARE_KEY_IDrequired for cloudflareThe TURN key id (Cloudflare dashboard > Realtime > TURN).
BRISVOX_TURN_CLOUDFLARE_API_TOKENrequired for cloudflareThe TURN key API token.
BRISVOX_TURN_TWILIO_ACCOUNT_SIDrequired for twilioThe account SID (in the request path).
BRISVOX_TURN_TWILIO_AUTH_TOKENrequired for twilioThe account auth token, or the API key secret when BRISVOX_TURN_TWILIO_API_KEY_SID is set.
BRISVOX_TURN_TWILIO_API_KEY_SIDunsetAuthenticate with this API key SID instead of the account SID.
BRISVOX_WEBRTC_TURN_RELAYunset (off)The server's own TURN relay: on, only or off. Each call opens a TLS (or TCP) connection to the TURN service of BRISVOX_TURN_PROVIDER, allocates a relayed address and advertises it as a host candidate. on: next to the direct candidates (lower priority; a failed allocation is logged and the call answers normally). only: no direct UDP candidate, BRISVOX_WEBRTC_CANDIDATE_IP not required, a failed allocation refuses the offer with 503. Requires BRISVOX_TURN_PROVIDER. For hosts without UDP ingress (a one-port PaaS such as Railway): docs/DEPLOY.md.
BRISVOX_WEBRTC_TURN_RELAY_TIMEOUT3sLongest the relay setup of one call may take (credentials, DNS, TCP, TLS, allocate, permissions).

Library-level knobs of the server's relay (RelayConfig, no environment variable): max_permissions (8: offer IPs a permission is created for), queue (20 packets per direction, 400 ms of audio, drop-oldest; 8 to 4096), allow_local_peers (false: loopback and link-local offer IPs get no permission; tests only), max_frame (4096: a larger or malformed frame from the TURN server ends that allocation), write_timeout (2 s), release_timeout (1 s: the Refresh of lifetime 0 at call end) and extra_roots (DER certificates trusted next to the compiled-in webpki roots, for a private TURN CA). Counters: relay_* in WebRtcStats (including the queue dwell and slow-write high-water marks). The TURN client crates (turn_client_proto, stun_proto, turn_types) are held at warn whatever RUST_LOG says; BRISVOX_LOG_TURN_CLIENT=1 lifts that (debugging only: at trace they log whole STUN messages with the nonce and the response's integrity HMAC). The SIP stack (rsipstack) is held at warn the same way (it logs whole SIP messages at info, SDES SRTP master keys included); BRISVOX_LOG_SIP_STACK=1 lifts it (docs/TELEPHONY.md, "SIP over TLS and SRTP"). The log writer also removes the key of any SDES a=crypto:... inline:<base64> line, whoever wrote it. The SIPS idle timeout (BRISVOX_SIP_TLS_IDLE_TIMEOUT, seconds, default 300, 0 = off) and the SRTP authentication-failure limit (BRISVOX_SIP_SRTP_AUTH_FAIL_LIMIT, per call and second, default 50, 0 = off) are in docs/OPERATIONS.md; library defaults are SipTlsConfig::idle_timeout and SipConfig::srtp_auth_failure_limit.

Library-level knobs (no environment variable, defaults in WebRtcConfig): turn.cache (60 s, at most half the TTL) and turn.fetch_timeout (5 s) for the credential fetch. POST /whip waits at most 1.5 s for credentials (a provider outage cannot stall an offer: it gets the last credentials that still have a quarter of their life left, or none); GET /whip/ice waits the full fetch timeout.

Which endpoint hands out credentials. The bundled pages read GET /whip/ice before createOffer, because the Link: rel="ice-server" headers of the WHIP 201 arrive after the offer is built (a page that wants them must create a second peer connection). The Link headers are there for standard WHIP clients (RFC 9725 section 4.6).

Security: who may mint relay credentials. Both endpoints issue credentials to whoever asks. Without BRISVOX_WEBRTC_TOKEN or BRISVOX_SESSION_SECRET (allowed only on a loopback address or with BRISVOX_INSECURE_LISTENERS=1), anyone who can reach the signaling port can obtain TURN credentials: for coturn they are computed locally and valid for the whole TTL, and every byte relayed with them is yours (bandwidth cost, abuse as an open relay for that TTL). GET /whip/ice is rate limited per client address (a burst of 5, then one request per 2 s; IPv6 clients by /64; 429 with Retry-After), which bounds minting but does not authenticate it. For coturn a request that carried a session token gets the username <expiry>:<label> with the label the first 16 hex digits of SHA-256 of the token's sid, so relay traffic in coturn's logs traces back to the session your backend issued; without a session token the label is a random id. Cloudflare and Twilio credentials are fetched once and shared by every caller for the cache period (60 s): the provider cannot tell callers apart, and a visitor who copies them can use them until they expire. Use coturn when relay usage must be attributable. When relay traffic costs money, set BRISVOX_WEBRTC_TOKEN (the page then needs the token, so serve it from your own backend), keep the TTL short, and use the provider's usage caps. Behind a reverse proxy every client has the proxy's address: the limiter then applies to all of them together, so put the limit at the proxy as well.

Process environment: security

Authentication and flood limits are infrastructure, not agent settings, so they are environment variables of the server process, not keys of the agent file. They are all described in docs/SECURITY.md and listed in docs/OPERATIONS.md:

VariableMeaning
BRISVOX_SESSION_SECRET / BRISVOX_SESSION_SECRET_PREVSecret (at least 32 bytes) that signs the short-lived single-use session tokens of the generic websocket, RTVI and WebRTC listeners, and the previous one still accepted during a rotation.
BRISVOX_WS_TOKENStatic token of the generic websocket listener (development). The RTVI, WebRTC and Telnyx ones are BRISVOX_RTVI_TOKEN, BRISVOX_WEBRTC_TOKEN, BRISVOX_TELNYX_STREAM_TOKEN.
BRISVOX_INSECURE_LISTENERSLets those listeners bind a non-loopback address without any of the above (logged loudly), and serves /metrics without a token.
BRISVOX_TRUSTED_PROXIESCIDR list of reverse proxies whose forwarding header the WebRTC listener believes (never 0.0.0.0/0).
BRISVOX_CLIENT_IP_HEADERThe one forwarding header believed behind them: x-forwarded-for (default), forwarded or x-real-ip (one address; Railway).
BRISVOX_WEBRTC_HTTP_MAX_PER_IPSignaling connections one address may hold (default 8, 0 = no limit; trusted proxies exempt).
BRISVOX_WEBRTC_MAX_CALLS_PER_IP / BRISVOX_WEBRTC_MAX_HANDSHAKING / BRISVOX_WEBRTC_OFFERS_PER_MINUTEWebRTC flood limits: calls per address, calls still handshaking, offers per address per minute.
BRISVOX_METRICS_TOKENBearer token /metrics requires when the health address is not loopback.

A session token's agent and meta claims arrive in the call's metadata (session.agent, session.meta.<key>); the agent file is chosen by BRISVOX_AGENT_FILE as before.

The agent definition from the environment

A host that takes environment variables but no file mount (a one-port PaaS such as Railway) can carry the definition itself in BRISVOX_AGENT_DEFINITION: the text of the TOML file. It goes through the same loader as the file (same 1 MiB limit, same checks and messages; errors say $BRISVOX_AGENT_DEFINITION:LINE:COL), and it is mutually exclusive with BRISVOX_AGENT_FILE (both set is a startup error). BRISVOX_AGENT_FORMAT=yaml says the text is YAML (default toml; the format is named, never guessed). Secrets still do not go into it: the definition names the variables that hold them with api_key_env, and those are variables of their own. The operating system limits one variable well below 1 MiB (about 128 KiB on Linux, about 32 KB on Windows); an agent definition is a few kilobytes.

BRISVOX_AGENT_DEFINITION='
[agent]
name = "demo"
language = "en"
system_prompt = "You are a helpful voice assistant."
[stt]
provider = "deepgram"
api_key_env = "DEEPGRAM_API_KEY"
[llm]
provider = "openai_compatible"
api_key_env = "OPENROUTER_API_KEY"
[llm.openai_compatible]
preset = "openrouter"
model = "openai/gpt-6-luna"
reasoning_effort = "none"
[tts]
provider = "cartesia"
api_key_env = "CARTESIA_API_KEY"
voice = "a-cartesia-voice-id"
'

brisvox-server check --env validates what the server would start with (the variable, or the file BRISVOX_AGENT_FILE names) and prints the same report as check <file>.

Port placeholder in listener addresses

The variables that bind a listener (BRISVOX_WS_ADDR, BRISVOX_TWILIO_ADDR, BRISVOX_TELNYX_ADDR, BRISVOX_RTVI_ADDR, BRISVOX_SIP_ADDR, BRISVOX_SIP_TLS_ADDR, BRISVOX_WEBRTC_ADDR, BRISVOX_WEBRTC_UDP_ADDR, BRISVOX_WEBRTC_TCP_ADDR, BRISVOX_CALLBACK_ADDR, BRISVOX_HEALTH_ADDR) accept the exact token ${PORT}, replaced by the value of the PORT variable that PaaS hosts set: BRISVOX_WS_ADDR=[::]:${PORT}. Nothing else is expanded ($PORT and ${port} are not). If the token is used and PORT is unset or not a number from 1 to 65535, the start fails with a message naming both variables.

Cross-field checks

rulereported at
the chosen provider's cargo feature is compiled in<stage>.provider
no options table for another provider<stage>.<other>
agent.language is en or tragent.language
Deepgram Aura speaks the TTS language (en, es, de, fr, nl, it, ja; no Turkish)tts.language or agent.language
an Aura voice's language suffix matches the TTS languagetts.voice
Aura delivers the tts.sample_rate (8000, 16000, 24000, 48000)tts.sample_rate
tts.api_key_env present (except openai_speech); openai_speech has base_url (and a model for generic), no key over plain http:// to a remote host, and tts.sample_rate 24000 (except speaches)tts.*
eleven_flash_v2 only with Englishtts.elevenlabs.model
Turkish STT: Deepgram nova-3/nova-2/base, AssemblyAI *-pro modelsthe model key, or the language
turn keys match the policy; silence_ms with fixed_silencethe key
early analysis needs the adaptive policyearly_analysis.enabled
final_wait_ms < STT finalize timeout; response_deadline_ms > final_wait_msthe key
frame_ms whole samples at 16 kHz and call_sample_rateaudio.frame_ms
filler.delay_ms not given with filler.per_tool = truefiller.delay_ms
recording.retention_days 1 to 3650; recording.redact_kinds not empty and not given with recording.redact = falsethe key
stt.api_key_env present (except sherpa_onnx and the local dialects); speaches/vllm have a model; a token is never sent over plain ws:// to another machinestt.*
tool names valid and unique; keyterm/vocabulary lists boundedthe entry
llm.api_key_env present (except openai_compatible); compatible server has a preset or URL+modelllm.*
a secondary is checked like a primary (same rules, with the primary's language and rate)<stage>.secondary.*
a secondary sets no language, sample_rate, max_tool_rounds, context_window_tokens and has no secondarythe key

The provider facts behind these rules come from docs/providers/*.md (fetched 2026-10).

Provider features

brisvox-server features provider-deepgram, provider-assemblyai, provider-gladia, provider-sherpa-onnx, provider-openai-realtime-stt, provider-openai, provider-anthropic, provider-gemini, provider-openai-compatible, provider-cartesia, provider-elevenlabs, provider-deepgram-aura, provider-openai-speech, and all-providers. None is on by default (light builds); a definition naming a provider whose feature is off is rejected with the feature to enable. SttProviderConfig / LlmProviderConfig / TtsProviderConfig have one variant per compiled adapter.

Why TOML is the reference and how YAML is read

TOML came first: the definition types carry toml::Spanned positions for the cross-field checks, so YAML needed its own span story, not just a second from_str. YAML is read like this:

  • The serde_yaml successors (checked on crates.io 2026-10-13: yaml_serde 0.10.7 by The YAML Organization, MIT OR Apache-2.0, last release 2026-08; serde_yaml_ng 0.10.0, MIT, last release 2024-05; serde_norway 0.9.42, last release 2024-12; serde_yaml itself 0.9.34+deprecated) are serde-only: a position appears in an error but never on a value, so a cross-field check (Turkish on Aura at agent.language) could not say where the value is. They also cannot fill Spanned.
  • saphyr-parser 0.2.1 (MIT OR Apache-2.0, pure safe Rust, YAML 1.2, active: releases 2026-07 to 2026-10) emits events with line and column. config::yaml builds a small tree with byte ranges from them and runs the derived Deserialize of AgentFile over it; Spanned<T> is filled through serde_spanned::de (the same crate toml::Spanned comes from, already in the tree). So both formats produce the same AgentFile, and the cross-field checks, secret lookups and mappings are shared code with shared messages; parity tests load each broken TOML definition next to its YAML form and compare key paths and messages.

Telephony: DTMF, call transfer, answering machines

Protocol facts live in docs/providers/call-control.md (Twilio and Telnyx REST, fetched and fact-checked 2026-10-08). This page is the design and the wiring: what was built, where it lives, how an application connects it, and what is still unverified. Anything marked UNVERIFIED is a provider behavior the documentation does not state; the code handles the possibilities defensively and the live tests settle them.

1. Where the code lives

PieceLocationDepends on
Vocabulary (DtmfString, PhoneNumber, TransferRequest, AnsweredBy, TelephonyOps, errors)brisvox_agent::telephonycore
Tools transfer_call, send_dtmf, end_call; TelephonyPolicybrisvox_agent::telephony::toolsthe vocabulary only
DTMF in: note, DtmfCollectorbrisvox_agent::telephony::dtmfcore
Voicemail: voicemail_flow, VoicemailDriver, voicemail_hint / looks_like_voicemailbrisvox_agent::telephony::voicemailflows
REST call control: CallControl, TwilioCallControl, TelnyxCallControl, AMD config and parsers, BoundCallbrisvox_transport::call_control (cargo feature call-control, off by default)agent (vocabulary), reqwest, rustls
A stream that ends on purpose and comes back (Twilio StreamResumer, Telnyx StreamStopper)brisvox_transport::twilio (registry.rs), brisvox_transport::telnyxtransport only
The binary's wiring: telephony tools, the live-call hub, warm-transfer and AMD callbacks, voicemail handlingbrisvox_server::tools::telephony, brisvox_server::callbacks, brisvox_server::runner (cargo feature call-control)all of the above

Decision: a module of the transport crate, not a new crate. It is the same kind of thing as the Twilio media adapter (a provider protocol for a call), it shares the crate's error and feature conventions, and a new crate would add a manifest and nothing else. The agent tools depend only on the TelephonyOps trait object, never on transport; transport depends on agent for the shared vocabulary, which the crate graph allows (agent is below transport). The TLS setup (rustls with ring, webpki roots) is a 15-line copy of brisvox_providers::net::tls_config so transport does not depend on its sibling, the providers crate. If a third consumer appears, move it to core.

2. DTMF in

provider "dtmf" message
  -> WireEvent::Dtmf (transport adapter: Twilio dtmf.digit, Telnyx dtmf.digit)
  -> TransportEvent::Dtmf { digit, at }              (session)
  -> DataFrame::Dtmf(DtmfFrame { epoch: 0, digit, at })   (engine pump, lane "dtmf")
  -> TurnDriver re-tags epoch, forwards downstream
  -> LLM runner: DtmfCollector::push(digit, at)
       -> Collected { digits, reason: Terminator | MaxDigits | Timeout }
       -> context.push(dtmf_message(&digits, language))   // a user message
       -> next LLM round

DTMF never goes through STT. The runner arms a timer for DtmfCollector::next_deadline() on the injected clock and calls poll(now) when it fires, so a half-typed entry is flushed (the engine rule: every wait has a timeout). The note is [The caller pressed the keys: 1 2 3 #] (Turkish: [Arayan şu tuşlara bastı: 1 2 3 #]); digits are the only variable content, so it cannot carry an injection. Whether a key press interrupts the bot is the application's interruption policy; the helpers only shape text. The runner side (feeding the collector, arming the timer) is up to the engine.

Twilio only sends dtmf on bidirectional streams, inbound direction, with no duration or timestamp; whether the tone audio is also in the media payload is UNVERIFIED. Telnyx sends dtmf.digit and an optional occurred_at (order by it; frames may arrive out of order).

Ordering is the transport adapter's job. DtmfFrame carries only at (receive time), not occurred_at, and the collector only sees frames in the order the pump delivers them. The Telnyx media adapter must therefore emit dtmf frames in occurred_at order (a short reorder buffer keyed by occurred_at/sequence_number) and stamp at accordingly; this is an open requirement for that task, not something the agent crate can repair. The collector is robust against a non-monotonic at (it never moves the inter-digit deadline backwards).

A lone terminator is an entry. # (the default terminator) or * pressed with nothing pending ("press # to skip") completes an entry of its own, CollectReason::LoneTerminator, whose only key is the terminator, so a caller who skips or goes back reaches the model. DtmfCollectorConfig::report_lone_terminator = false drops it instead.

3. DTMF out

TransportHowStatus
TelnyxPOST /calls/{id}/actions/send_dtmf with digits, duration_millis (default 250), command_idworks; may be refused with 90045 while a media stream is active (UNVERIFIED which commands): reported as Error::StreamActive, the stream is not stopped because the bot continues after the tones
Twiliocannot be sent over the media stream (documented). Opt-in route: update the call with <Play digits="..."/> followed by a new <Connect><Stream> (ResumeStream)needs TwilioConfig::resume_stream and TwilioConfig::adapter_resumes_session = true; the update ends the old stream, the new one has a new streamSid and custom.resume = "dtmf" in the call metadata, there is an audio gap; heavy, use for deliberate IVR navigation. The shipped Twilio media adapter handles the old stream's end as a hang-up and does not map the new stream back to the live session, so the capability is off by default: without both settings capabilities().send_dtmf is false and send_dtmf is Unsupported, nothing is sent (the model is told "not available on this call", not "sent" while the conversation is lost). Register the tool from BoundCall::capabilities(): TelephonyPolicy::with_dtmf(bound.capabilities().send_dtmf)
WebSocket / loopbackno REST routeUnsupported

In-band synthesis of the two-tone waveform as media frames is a documented alternative whose far-end behavior is carrier-dependent and unverified; it is future work (call-control.md 1.4).

send_dtmf tool: SideEffect, digits validated against [0-9A-Da-d*#wW]{1,32} (schema pattern and DtmfString), lowercase a-d normalized, at least one real key. The Debug form of a digit string prints only its length (it may be a PIN).

4. Transfer

Tool

transfer_call { destination, mode?, summary?, reason? }, SideEffect.

Transfer by an out-of-process agent. The agent process of docs/AGENT_PROTOCOL.md sends a transfer command (E.164 number, cold or warm): it is trusted like the operator (it holds the application logic), so the number comes from it and not from the destination list. It runs through the same call binding as the tool (CallOps), including the hand-off after a cold transfer and the warm-transfer gate ([telephony] warm, callback listener); the call gets this binding even when the definition names no telephony tool.

  • The model never produces a number. destination is an enum of the names in TelephonyPolicy (sales, duty_manager), each mapped to an E.164 number in configuration. Prompt injection cannot reach a premium-rate number. No destination configured: the tool is not registered. Numbers appear in no prompt, tool result or log (PhoneNumber debug-prints +90******33).
  • mode: cold (default) or warm (only offered when TelephonyPolicy::with_warm(true)).
  • One transfer per call. A second call is refused while one runs or was accepted. A definite failure (Rejected, Unavailable, InvalidArgument) releases the claim so the model can try another destination; OutcomeUnknown keeps it and tells the model not to repeat it.
  • The hand-off sentence is played first. The tool awaits TelephonyOps::settle_speech() (bounded by settle_timeout, default 8 s, on the injected clock), then asks for the transfer. The application implements settle_speech from the engine's play-out marks (TransportEvent::MarkPlayed); BoundCall::with_settle takes the function.
  • The tool timeout covers the whole chain. TelephonyPolicy::effective_transfer_timeout() is settle_timeout + provider_budget + 2 s (default 8 + 40 + 2 = 50 s). The provider budget is four sequential requests at the transport's default 10 s deadline, the longest chain (Telnyx: refused conference command, stream stop, repeat, dial; Twilio: park, create agent leg, roll back). Set provider_budget to requests * request_timeout when the REST deadline is changed; an explicit transfer_timeout is used as given (a shorter one can answer the model "timed out, do not repeat" for a transfer that then succeeds).
  • The idempotency key (ToolCallCtx::idempotency_key().digest()) reaches every request as the Telnyx command_id; the engine's store replays the recorded result for a repeated call id.
  • Result: {status: "initiated", mode, destination, summary_delivered, instruction}. After initiated a cold transfer ends the bot's part of the call.

Cold transfer

  • Twilio: update the call with <Response><Dial callerId=".." answerOnBridge="true" timeout="30" action="..">+90..</Dial></Response> (callerId/action only when configured). Twilio ends the call after the <Dial>; the dial outcome reaches only the action URL of the user's HTTP app.
  • Telnyx: POST .../actions/transfer with to, optional from, timeout_secs, command_id. If refused with 90045 the binding issues streaming_stop and repeats the command once under the command id ...r1 (the refused one was not applied). The stream is not restarted if the retry fails (stop_stream_on_conflict = false disables the whole retry). A retry that fails after the stop succeeded is Error::StreamStopped { cause } (an unknown outcome as far as the model is concerned): the bot's stream is gone, so the tool must not say "nothing was done, the call continues with you". The transfer tool keeps its claim and tells the model not to repeat; the application hangs the caller up or moves them on. A failing streaming_stop itself is reported as it is (the stream is then still up).
  • The host's media adapter must not take the stream that ended because of that streaming_stop for the end of the call. TelnyxConfig::on_stream_stop is a hook (StreamStopHook) called with the call right before the binding sends streaming_stop (once per conflict), so the host can mark that stream as stopped on purpose in the media adapter (the adapter's own API lives in docs/providers/telnyx.md); the binding itself has no dependency on the adapter.

Warm transfer (conference based)

Twilio (TwilioCallControl::transfer_warm), all on one background task (see "Cancellation"):

  1. Update the caller's call: <Dial><Conference startConferenceOnEnter="false" endConferenceOnExit="true" beep="false" participantLabel="caller" [waitUrl]>brisvox-<key></Conference></Dial>. The caller waits (hold music or conference_wait_url); the bot's stream ends (UNVERIFIED how, see 7).
  2. Create a call to the destination (From = caller_id, required) whose TwiML is only a silent <Pause> of agent_accept_window_secs (default 120, in pauses of at most 60 s). The agent leg is not in the conference and nothing is read to it. StatusCallback events initiated ringing answered completed when configured, and AMD parameters when agent_leg_amd is set (the sync verdict is readable with TwilioCallControl::call_status once the leg is answered, answered_by; with async_amd it arrives at the callback URL).
  3. complete_warm is the accept gate. When the application has seen the agent leg answered by a person (AMD verdict, status callback), complete_warm updates the agent leg with [<Say language=..>summary</Say>]<Dial><Conference startConferenceOnEnter="true" endConferenceOnExit="false" ..>brisvox-<key></Conference></Dial>: the agent hears the summary (the caller does not), joins, and the two meet. A voicemail on the agent's phone is never joined to the caller and never hears the summary: if the application does nothing, the leg ends when the pause ends. (A <Gather> accept prompt, as call-control.md 1.5.2 suggests for the no-conference shape, would need a webhook, which brisvox does not serve; the REST step makes the application's decision explicit instead.) The agent leg must be answered: an update of a ringing call is rejected by Twilio.
  4. abort_warm (no answer, declined, voicemail) ends the agent leg (Status=completed) and then resumes the caller through ResumeStream; it stops at the first error so a retry repeats it safely, and returns where the caller was left (Resumed, or Parked without a resume stream). The agent leg counts as ended when the update succeeds, when Twilio answers 404, or when it answers another 4xx (not 401/403) and a read of the leg (GET) shows a final status (completed, busy, failed, no-answer, canceled): a leg that already ended on its own (no answer, busy) is the usual case, and the error Twilio gives for updating a finished call is UNVERIFIED, so the leg is read instead of trusting one status code. A queued or ringing leg is ended with Status=canceled (the status Twilio documents for it) after completed was refused. If the leg is still live (or unreadable) the original refusal is returned and the caller is not resumed behind it; every retry repeats the same sequence.
  5. If step 2 fails definitively (4xx), the caller is put back on the bot through ResumeStream (resume = warm_failed); Error::WarmFailed says where the caller was left. If its outcome is unknown (timeout, dropped connection, 5xx; also a failed step 1) the caller is not resumed behind a leg that may exist: the error reports Parked and carries a recovery handle (WarmFailed::transfer: the conference, no agent leg) that BoundCall keeps for abort_warm. The possibly existing leg only rings and then ends after its pause (nothing joins it to the empty conference); its SID is unknown, look it up in the console if it matters. The whole TwiML (including the later join, with the summary) is checked against the 4000-character limit and the caller id before step 1, so a bad request touches nothing.

Telnyx (TelnyxCallControl::transfer_warm; needs connection_id and caller_id):

  1. POST /conferences with the caller's leg (and hold_audio_url; without it the caller hears silence while waiting): needs data.id in the response (the response shape is not on the fetched pages; a missing id is Error::Protocol). A 90045 here triggers the same stop-and-retry as the cold transfer (tested: conferences, streaming_stop, conferences). If the retry fails after the stop succeeded, the error is WarmFailed { Parked } wrapping Error::StreamStopped, without a recovery handle (no conference id is known): the caller's stream is gone and the application must act.
  2. POST /calls (dial) with link_to the caller's leg over connection_id, optional AMD fields. Failure (or a dial answer without a leg id) leaves the caller parked in the conference: WarmFailed { Parked } with a recovery handle (the conference id, no agent leg) that BoundCall keeps.
  3. When the application sees call.answered for the agent leg and the AMD verdict says a person, it calls complete_warm, which joins the agent leg to the conference. Until then the agent leg is not in the conference.
  4. abort_warm hangs up the agent leg (if there is one) and reports CallerState::Parked always: the stream was stopped to make the conference command possible and cannot be restarted from here, so there is no way back to the bot. The application must hang the caller up (or move them on) itself; the state is returned instead of an Ok(()) that would hide it. An outcome-unknown dial may have created a leg whose id is unknown; it is never joined (the join is explicit), but an answered one stays up until its far end hangs up.
  5. The summary cannot be delivered (the speak / playback_start commands were not fetched): the report says Briefing::NotSupported and the tool result summary_delivered: false.

BoundCall and the warm-transfer handle: the REST steps and the recording of their result run on one spawned task, so a tool task that is aborted after the caller was moved cannot lose the handle. BoundCall::warm_transfer() returns it (after a half-failed transfer: the recovery handle). complete_warm() forgets the transfer once the provider accepted the join; abort_warm() forgets it only after the provider confirmed the abort, so after a timeout or a 5xx the call can be repeated, and returns the CallerState. Without a transfer, abort_warm does nothing and reports Unchanged.

Both providers deliver the later events (agent answered, declined, hung up) as callbacks that the transport crate does not serve (scope boundary in twilio.md). The application's HTTP app receives them and calls BoundCall::complete_warm() / abort_warm(); Twilio status callbacks carry CallSid, CallStatus, Telnyx webhooks data.event_type (call.answered, call.hangup, ...), deduplicated on data.id. The server binary serves the endpoints that finish a transfer (section 5, "Warm transfers in the binary"): the application's HTTP app calls them instead of linking BoundCall.

Rules every REST call follows

Deadline on the injected clock around send + body (default 10 s, connect 3 s; a server that sends headers and then stalls, or streams an endless body, ends at the deadline or at the 64 KiB body cap, both tested; a test with a clock that runs 1000 times faster than real time proves the deadline comes from the injected Clock and not from tokio::time); no redirects (a 302/307 answer is an error after exactly one request, tested), no proxy; http:// only on loopback; credentials as Secret, header values sensitive, never in an error or Debug; the request URL (account SID, call id, conference id) is stripped from transport errors (reqwest::Error::without_url), because the error text reaches the model as a tool result and the logs; provider error text is stripped of control characters, cut to 200 characters (tested with a 5000-character reply on both providers) and dropped for 401/403. No automatic retry except the one Telnyx 90045 case: a timeout or a dropped connection may mean the command was applied (Error::outcome_unknown()), and the tool reports "do not repeat". After a Twilio timeout use TwilioCallControl::call_status before anything is repeated.

Cancellation

Warm transfers run their steps on their own tokio task, and BoundCall::transfer records the resulting handle on that task too: dropping the caller's future (the tool engine's backstop, a session end) does not leave the caller half-moved between the REST steps, and does not lose the handle that complete_warm and abort_warm need (a test drops the tool future after step 1 and finds the handle). Every step has a deadline, so the task is bounded. What the REST sequence cannot do is keep the caller off the conference when the whole process dies between steps; that is the application's recovery (the conference name brisvox-<key> is deterministic). SideEffect tools are not dropped by the engine for a timeout or an interruption anyway.

5. Wiring an application

#![allow(unused)]
fn main() {
let control = Arc::new(TwilioCallControl::new(twilio_config, clock.clone())?);
let call = CallRef::new(call_sid)?;                       // CallInfo.metadata["call_sid"]
let bound = Arc::new(BoundCall::new(control, call).with_settle(settle_fn));

let ops: Arc<dyn TelephonyOps> = bound.clone();
let caps = bound.capabilities();
let policy = TelephonyPolicy::new()
    .destination("sales", "+905551112233", "the sales team")?
    .with_warm(caps.warm_transfer)
    .with_dtmf(caps.send_dtmf);             // false for Twilio unless adapter_resumes_session
let mut registry = ToolRegistry::new();
register_telephony_tools(&mut registry, &ops, &policy, &clock)?;

// after each response was played:
if bound.hangup_requested() { /* send TransportCommand::Stop after the farewell mark */ }
// from the HTTP app's callbacks:
bound.complete_warm().await?;                       // the agent is a person: join
let left = bound.abort_warm().await?;               // CallerState::{Resumed, Parked, ..}
}

end_call records the request (hangup_requested()); the model says one goodbye sentence next and the application ends the call after that sentence's utterance-end mark, as in the Twilio adapter's "Hang-up" rule. BoundCall::hangup (REST) is for transports whose stream cannot end the call.

In the server binary

brisvox-server wires all of this itself (cargo feature call-control, brisvox_server::tools::telephony), from BRISVOX_CALL_CONTROL=twilio,telnyx and the definition's [telephony] section (docs/OPERATIONS.md, docs/CONFIG.md):

  • Which call. The transport session records the call's Started info (TransportSession::call_started); the tools bind to the carrier call the first time they act (Twilio call_sid, Telnyx call_control_id, chosen by the transport metadata). A call on a listener without a configured carrier (generic websocket, RTVI, SIP) has the same tools: transfer_call and send_dtmf answer Unsupported ("not available on this call"), end_call ends the call through the transport. So a definition's tool set does not depend on the listener.
  • settle_speech follows the call's engine events (EngineEvents, the RTVI bridge's ring when it has one): it returns once the bot is not speaking and produced nothing for 1 s, bounded by the policy's settle_timeout (8 s).
  • end_call: the runner waits for the end of the next bot speech (the farewell; at most 10 s), hangs up through the carrier when the call has one (bounded at 12 s, abandoned when the session is cancelled; a failure is logged) and stops the call. A farewell the model speaks in the same response as the end_call call counts: the engine reports the end of a response's speech once per response, after its tool loop, so after the tool ran. A response that says nothing ends its speech at once, so a silent end_call hangs up right away.
  • send_dtmf is offered only when every configured binding can send tones: Telnyx always, Twilio when the server also hosts the Twilio listener (below).

Streams that end on purpose

Two REST operations end the bot's media stream while the call goes on: Twilio's TwiML update (DTMF and the first step of a warm transfer replace the call's TwiML, which ends the <Connect><Stream>; the new TwiML starts a new stream) and Telnyx's streaming_stop before a transfer retry (error 90045). Left alone, the media adapter reads the old stream's stop or close as the caller hanging up, ends the engine's session and the tool call in flight with it. The binary wires both sides:

  • Twilio. HostSettings::stream_links() gives the call control the Twilio listener's StreamResumer (a late-bound cell, filled when the listener binds) and the stream URL, the listener's BRISVOX_TWILIO_PUBLIC_URL as wss://. The Twilio binding then has a ResumeStream (resume parameter dtmf or warm_failed), adapter_resumes_session = true (so send_dtmf is offered) and on_stream_restart, which announces the stream's end to the adapter right before the REST update. The adapter (brisvox_transport::twilio, module docs "Streams that end on purpose and come back"): for 30 s after the announcement a stop, a close frame or a vanished socket of that call is the host's doing; the engine gets no hang-up, its session is parked, and what it says meanwhile (up to a minute of audio) is held for the new stream, a barge-in clear dropping it. A stream of the same callSid whose start carries the resume parameter is handed to the parked session (it may arrive before the old stream finished closing). A session nobody resumed within the announced time (DTMF 30 s; a warm transfer: the agent leg's accept window + ring time + 60 s, at most 15 min) ends as a disconnect; a resume stream nobody expects is refused (1008). The resumed stream passes the same signature check and the same admission gate as any other: at the call limit it is refused like a new call (keep headroom in BRISVOX_MAX_SESSIONS).
  • Telnyx. on_stream_stop announces the streaming_stop to the Telnyx listener's StreamStopper (Wiring the Telnyx stream stop, section 7): the close is not a hang-up, the session stays up (at most STOPPED_LINGER, 120 s) and the retried transfer completes.

After a cold transfer returns, the call has left the bot: the runner stops the session without hanging the call up (it would drop the transferred call). The same for a completed warm transfer.

Warm transfers in the binary

[telephony] warm = true in the definition offers transfer_call with mode = "warm", when the callback listener runs (BRISVOX_CALLBACK_ADDR) and every configured binding can do one (Twilio: BRISVOX_TWILIO_CALLER_ID; Telnyx: BRISVOX_TELNYX_CALLER_ID and BRISVOX_TELNYX_CONNECTION_ID); otherwise the server warns at startup and offers cold transfers only. The tool starts the transfer (caller to a conference; receiving party called). The rest is two requests to the callback listener (docs/OPERATIONS.md, "Callback listener"):

  • POST /v1/calls/{carrier}/{call}/warm/complete: the receiving party accepted. The listener calls BoundCall::complete_warm (Twilio: the agent leg reads the summary and joins the conference), then the runner stops the bot's session without a hang-up.
  • POST /v1/calls/{carrier}/{call}/warm/abort: the receiving party did not answer, declined or is a machine. BoundCall::abort_warm ends their leg; on Twilio the caller goes back to the bot's stream (the parked session continues) and the bot says so (a fixed line in the agent's language, en or tr); on Telnyx there is no way back (CallerState::Parked) and the call is hung up.

The decision to accept belongs to the application: it watches the receiving leg (Twilio BRISVOX_TWILIO_STATUS_CALLBACK, AMD, its own phone logic; Telnyx webhooks) and calls one of the two. A transfer nobody finishes is cleaned up when the call's session ends: the server aborts it and hangs the caller up, so the caller is never left in a conference alone. Telnyx's session survives the missing stream for STOPPED_LINGER (120 s): finish a Telnyx transfer within that.

Caller identity for the agent process

An agent process (docs/AGENT_PROTOCOL.md) can be told who is calling, off by default ([agent_link] caller_identity = true, docs/CONFIG.md; withheld while phone numbers are redacted, docs/PRIVACY.md). Where the server finds each value:

carriercarrier_call_idfrom / to
TwilioCallInfo.metadata["call_sid"] (the CallSid of the stream's start)the stream parameters from and to (custom.from, custom.to; From and To also work)
Telnyxcall_control_idstart.from and start.to
SIPthe Call-ID (CallInfo::call_id)the user parts of the INVITE's From and To, only when made of 0-9 A-Z a-z + * # . _ - (the headers are the caller's own, unauthenticated text; anything else drops the field)

The Twilio start message has no numbers, so the TwiML that opens the stream has to pass them: your voice webhook receives From and To and answers with

<Response><Connect><Stream url="wss://voice.example.com/twilio/stream">
  <Parameter name="from" value="+15551230001"/>
  <Parameter name="to" value="+15551230002"/>
</Stream></Connect></Response>

with the real values filled in per call (the voice webhook of examples/phone-receptionist does exactly this). Stream parameters are not covered by the signature of the stream URL, so treat them as hints, like a caller ID. Without the parameters an opted-in agent still gets the CallSid.

6. Answering machines

(The server binary's handling is at the end of this section.)

Strategy (layers, in order of authority)

  1. Provider AMD when brisvox originates the call. Twilio: MachineDetection=DetectMessageEnd (the mode for leaving a message; Enable returns early with machine_start), sync (the call waits for the verdict, about 4 s typical) or AsyncAmd with a callback. Telnyx: answering_machine_detection greeting_end (or detect_beep, premium), results as webhooks. Leaving a message needs the end of the greeting: only Twilio DetectMessageEnd and Telnyx greeting_end / premium send it (AmdConfig::twilio_provides_greeting_end(), telnyx_provides_greeting_end()). Twilio Enable and Telnyx detect end at machine_start / machine; with them use VoicemailPolicy::detect_only() (hang up on machines; with speak_when_greeting_end_uncertain a fixed hint_wait of 6 s instead of waiting out the 30 s greeting timeout). A test feeds a Telnyx detect event to both policies. AmdConfig is the neutral configuration with documented ranges; twilio_params() and telnyx_fields() render it for the originating request (the same is used for the warm-transfer agent leg). The Telnyx mapping of the timing fields is an approximation (UNVERIFIED equivalence, documented on telnyx_fields).
  2. Results to the session: parse_twilio_amd_callback(form body) and parse_telnyx_amd_event(json body) return an AnsweredBy; Twilio synchronous AMD can instead put AnsweredBy into a stream <Parameter> (CallInfo.metadata["custom.answered_by"]) so no plumbing is needed in core. Telnyx detection machine maps to MachineStart (greeting still playing); the greeting result (beep_detected / ended) maps to MachineEnd*.
  3. In-engine hint voicemail_hint(text, speech_duration) returns a strength: HintStrength::Phrase for voicemail phrases in English and Turkish ("leave a message", "bip sesinden sonra", "mesaj bırakın", "sesli mesaj", ...) and HintStrength::Length for a greeting longer than 3.5 s with more than 5 words (Telnyx's documented thresholds; looks_like_voicemail is the plain yes/no of the two). It is fed to VoicemailDriver::on_voicemail_hint(strength, now) and only ever upgrades toward "machine", but the strengths are not equal, because a live person's "hello, this is the Smith residence, how can I help you" is also long:
    • An Unknown verdict settles as a machine if a hint of either strength came before it.
    • A Human verdict is overridden only by a phrase: when the phrase arrives within VoicemailPolicy::human_upgrade_window (10 s, zero disables) after the verdict, or when it was heard up to that long before the verdict (the speech-to-text final can beat the provider's verdict; Twilio DetectMessageEnd answers human at once for a short greeting that goes on with "leave a message after the beep"). A length hint never changes a Human verdict, in either order.
    • The driver then waits hint_wait (6 s) for the greeting to end and speaks. The window and the phrase specificity are the guard against reading the message to a real person. A machine verdict already given is never reversed. The phrase lists are suggestions, to be tuned on real Turkish voicemail.
  4. Fallback VoicemailPolicy::on_unknown (TreatAsHuman by default, as Telnyx advises for not_sure; TreatAsMachine; HangUp) and speak_when_greeting_end_uncertain. TreatAsMachine (an Unknown verdict or the classify timeout) does not speak at once: an Unknown usually is a detection timeout, so the greeting may still be playing. Like the hint path it waits hint_wait (6 s, ends early on a MachineEnd* verdict), then speaks, or hangs up when speak_when_greeting_end_uncertain is off.
  5. Future work: an in-engine beep detector (DSP on the callee audio) and greeting-length analysis without provider help; per-country AMD availability and Turkish carrier behavior are UNVERIFIED (live test).

Leave-message flow

voicemail_flow(&VoicemailConfig, registry) builds a Flow with these nodes; the verdicts come from the application, so transitions are applied by FlowManager::transition_to as VoicemailDriver directs, not by tool results.

classify  (respond_immediately off: the bot is silent)
   Human          -> human          (respond_immediately: greets; end_call -> farewell -> EndCall)
   MachineStart   -> wait_greeting  (silent, barge-in off)
   MachineEnd*    -> leave_message  (Reset; pre actions in order:
                                     Custom("barge_in", {"enabled": false}), Say(message), EndCall)
   Fax            -> hang up
   Unknown/timeout-> VoicemailPolicy

The action contract is documented on flows::Action: a node's action list runs strictly in order, Say finishes when its utterance-end mark was played (bounded by the runner), and EndCall hangs up after everything queued before it was played, so EndCall after Say in leave_message hangs up after the message's utterance-end mark (with the runner's own timeout), not immediately. There is no model turn and no tool, so nothing the callee says can change the fixed message. After the mark, call VoicemailDriver::message_played(). VoicemailDriver::deadline() is the instant the runner must call on_timeout at (classification 45 s, greeting 30 s by default). Outcomes for metrics: human, machine_message_left, machine_hangup, fax, unknown.

Compliance: the message is spoken and may be recorded; put nothing in it the callee did not consent to hear on a recording. Voicemail systems cut recordings at an unknown limit; keep it short (20 to 30 seconds).

Voicemail in the binary

An agent with [telephony.voicemail] (docs/CONFIG.md) handles the verdicts of calls the carrier's detector classifies. The binary does not originate calls: the application places the call with the detector on (AmdConfig::twilio_params / telnyx_fields) and points the carrier's stream at the binary. The verdict reaches the call two ways:

  1. Stream parameter. Twilio's synchronous MachineDetection answers before the call's TwiML runs; the application's TwiML generator puts the result in the stream: <Parameter name="answered_by" value="machine_end_beep"/> (any Twilio AnsweredBy value). It is read at the call's start (CallInfo.metadata["custom.answered_by"]).
  2. Callback. POST /v1/amd/twilio (AsyncAmdStatusCallback: CallSid, AnsweredBy) or POST /v1/amd/telnyx (the webhook), on the callback listener. The call is found by the carrier's call id. An asynchronous detector can answer before the media stream connected: a verdict for a call that is not live waits for it (up to 60 s, at most 256 waiting, answered 202 queued) and is applied when the call starts.

Each call with a verdict gets a VoicemailDriver (no driver for a call without one, so inbound calls are never touched) with the policy of the definition (on_unknown, detect_only; the other timings are the driver's defaults: greeting timeout 30 s, hint_wait 6 s, human upgrade window 10 s). Its steps: a machine whose greeting ended (machine_end_*) -> the bot speaks the message (a line through the engine's say input, cutting anything the bot was saying), and when the speech ended (bounded at 90 s) the call is hung up through the carrier; fax, or a machine with detect_only, or on_unknown = "hang_up" -> hung up at once; a person -> nothing, the conversation goes on. The callee's final transcripts are also fed to voicemail_hint: a voicemail phrase ("leave a message after the beep") upgrades a human or unknown verdict, as in the driver's rules above. A line that cannot be spoken (the engine takes none, as with an out-of-process agent) hangs the call up instead.

Limits: the bot is not silenced while the verdict is pending (the flow's classify node does that in the library, the binary has no flows). Arrange for the verdict to come first (the stream parameter) or make the agent wait for the callee to speak; and the barge-in settings of the definition apply to the message, so a beep that the VAD takes for speech can cut it (the message is spoken after the greeting ended, when the line should be quiet).

7. Unverified, guessed around, and live tests

Items marked here are provider behavior not stated in the fetched documentation. The code is defensive about each.

  1. Twilio: effect of a REST update on an active <Connect><Stream> (order of stop, socket close, whether a new stream in the update TwiML is allowed). The DTMF route and warm-transfer rollback depend on it. The Twilio adapter treats a stream ending right after an announced REST update as expected, not as a hang-up: it handles stop, a close frame and a vanished socket alike, and a new stream that arrives before the old one closed. Whether Twilio really starts the new stream, and how soon, is settled by the live test. Update Twiml length limit: the 4000 characters of the Create page are applied.
  2. Twilio: <Play digits> tone duration and level at the far end; error codes when updating an ended call (a 404 is mapped to CallGone, anything else is Rejected with the code); AnsweredBy field name in the sync-AMD webhook; whether async AMD may run beside a bidirectional stream; AMD in Turkey.
  3. Telnyx: which commands 90045 blocks while streaming; effect of transfer on the stream (stop frame?); the response shape of POST /conferences and POST /calls (data.id, data.call_control_id are assumed and validated); command_id accepted by Dial; hold_audio_url behavior for a lone participant; premium AMD payload schemas.
  4. Both: rate limits, billing of parent and child legs, REST error body shapes beyond the documented samples (both Twilio code/message and error_code/error_message are read).

Live checks still pending (need accounts, numbers, a public wss:// URL): DTMF both ways and IVR; cold and warm transfers on both providers, stream interplay; AMD on Turkish carriers, voicemail message flow.

Wiring the Telnyx stream stop

When the Telnyx media adapter and Telnyx call control run in the same process, set TelnyxConfig::on_stream_stop = Some(StreamStopHook::from_stopper(server.stream_stopper())). The 90045 retry path then announces its streaming_stop to the adapter first, so the stream close it causes is neither reported as a caller hang-up nor answered with a REST hang-up (test the_call_control_stop_hook_marks_the_stream_as_stopped_on_purpose). The server binary does this itself (section 5): HostSettings::stream_links() carries the Telnyx listener's StreamStopper to the binding (test telnyx_stream_stop_of_a_transfer_retry_is_not_a_hang_up in crates/brisvox-server/tests/telephony_extras.rs).

8. Security

What an eavesdropper or a stranger can do on each telephony transport, and what the server does about it. (See docs/SECURITY.md for the cross-cutting review.)

  • Twilio. The listener is plaintext: terminate TLS in front of it. Every upgrade must carry a valid X-Twilio-Signature (HMAC-SHA1 over the public URL with the account's Auth Token). A fixed URL has one constant signature that can be replayed by anyone who saw it once, so the stream URL is per call: BRISVOX_TWILIO_STREAM_SECRET makes every URL end in a signed, single-use, expiring segment bound to the call's SID (docs/providers/twilio.md, "Per-call stream URLs"). BRISVOX_TWILIO_ALLOW_STATIC_URL=1 is the legacy escape hatch and logs a warning. The used-set of segments is per process: after a restart, or against another instance, a captured signed URL is replayable until its exp (at most 1 h; 120 s for URLs the server mints). Migrating from the fixed URL: see "Migrating from the fixed URL" in docs/providers/twilio.md. start.customParameters are not signed; do not put authority in them.
  • Telnyx. Telnyx signs nothing. The path and the optional stream_auth_token are the only checks, so a Telnyx listener bound to a non-loopback address refuses to start without BRISVOX_TELNYX_STREAM_TOKEN (set the same value as stream_auth_token in the stream request); BRISVOX_INSECURE_LISTENERS=1 overrides it with a warning. Keep the path unguessable too (BRISVOX_TELNYX_PATH).
  • SIP. UDP and TCP signaling and plain RTP are cleartext, and the digest Authorization protects only who may call, not who can listen. For a trunk across the internet turn on SIPS and SRTP ("SIP over TLS and SRTP" below): BRISVOX_SIP_TLS_ADDR with a certificate and key, and BRISVOX_SIP_SRTP=required. Without them run it on a private trunk, a VPN or behind an SBC that terminates TLS/SRTP toward the carrier. The listener refuses to start without at least one inbound check: BRISVOX_SIP_ALLOWED_SOURCES (a CIDR ACL of the carrier's signaling networks) and/or BRISVOX_SIP_AUTH_USER with BRISVOX_SIP_AUTH_PASSWORD (digest, single-use nonces); this holds for every bind address, loopback included. Use both when the trunk allows it: an ACL alone trusts source addresses, which are spoofable over UDP, and a digest alone leaves the port open to scans.
  • Prompt-injected actions. Any carrier's caller can try to talk the model into a transfer or another SideEffect tool. List transfer_call (and any payment or booking tool) in agent.confirm_tools so it runs only after the caller's next reply confirms (docs/TOOLS.md, "Confirmation gate").
  • Agent link. Transcripts, the LLM context and tool calls cross the link: plaintext ws:// to a host outside the private networks is refused (docs/AGENT_PROTOCOL.md).

SIP over TLS and SRTP

Turning it on. All of it is in BRISVOX_SIP_* (docs/CONFIG.md):

BRISVOX_SIP_ADDR=0.0.0.0:5060
BRISVOX_SIP_PUBLIC_IP=203.0.113.10
BRISVOX_SIP_ALLOWED_SOURCES=198.51.100.0/24                 # the carrier's signaling networks
BRISVOX_SIP_TLS_ADDR=0.0.0.0:5061
BRISVOX_SIP_TLS_CERT=/etc/brisvox/sips.crt                  # PEM chain, leaf first
BRISVOX_SIP_TLS_KEY=/etc/brisvox/sips.key                   # PEM, read once at startup
BRISVOX_SIP_SRTP=required                                   # off | optional | required
  • SIPS. TLS 1.2 and 1.3 only (rustls, ring), no client certificate. The accept loop is Brisvox's own (src/sip/tls.rs), not the stack's, because the stack's TLS listener does the handshake without a deadline and without a limit. Before any SIP is read: the source ACL, then BRISVOX_SIP_TLS_MAX_CONNECTIONS (1024) and BRISVOX_SIP_TLS_MAX_PER_IP (32), then BRISVOX_SIP_TLS_HANDSHAKE_MS (10 s). After the handshake a connection is the same thing as a TCP one: digest, ACL, the admission gate and the call limit apply unchanged. The Contact of a call that came in over TLS is sip:brisvox@ip:5061;transport=tls (sips: when the INVITE's Request-URI was sips:), and the peer's BYE and re-INVITE come back on the same connection. The certificate is read once; there is no hot reload (restart to rotate, a draining restart loses no call). Idle timeout: an established connection that carries no byte in either direction for BRISVOX_SIP_TLS_IDLE_TIMEOUT seconds (default 300, 0 = off) is closed and its slots freed (SipStats::tls_idle_closed); an RFC 5626 CRLF keepalive is a byte and keeps it open. The stack reports no activity, so the TLS endpoint talks to a loopback socket and a relay task (src/sip/tls_relay.rs) copies the bytes to the peer's socket through an AsyncRead/AsyncWrite adapter that notes the time of each transfer; the check runs on the injected clock. Cost: two more descriptors and one task per TLS connection. Keep the ACL on a public address all the same.
  • SRTP (SDES, RFC 4568). AES_CM_128_HMAC_SHA1_80 and AES_CM_128_HMAC_SHA1_32 (RFC 3711), keys exchanged in the SDP. The first usable a=crypto line of the offer wins, the answer repeats its tag and suite with Brisvox's own key, each direction has its own key, and every call gets fresh random keys wiped on drop. The packet code (src/sip/srtp.rs) is Brisvox's: AES-128 counter mode from RustCrypto (aes, ctr), HMAC-SHA1 from ring, in place and allocation-free per packet (a few microseconds on the call's task), replay window of 128 packets, tag compared in constant time, a forged packet changes no state. Key schedules wipe themselves on drop (zeroize features of aes and ctr), as do the master and session keys and the IV buffers (ring's HMAC key has no wipe of its own, and the base64 text of a key in an SDP buffer is not wiped either: the places a key outlives its call in memory until the allocator reuses it). A crypto line whose key lifetime is below 2^31 packets (|2^20, |1048576) is ignored: this endpoint neither counts packets per key nor re-keys by itself, so it does not pretend to honour a short lifetime. Packets that fail authentication are counted per call per second: more than BRISVOX_SIP_SRTP_AUTH_FAIL_LIMIT (default 50, 0 = off) ends the call with a log line and SipStats::srtp_flood_hangups (a _32 tag is 32 bits, so a flood is a guessing attack; the price is that whoever can send UDP to the call's RTP port can end the call, which is a cheaper attack than guessing). It is pinned to the RFC 3711 test vectors (appendix B.2 and B.3) and cross-checked against the independent webrtc-srtp crate in both directions across the sequence-number wrap.
  • Policy. off: plain RTP only; an RTP/SAVP-only offer gets 488. optional (the default when TLS is configured): SRTP when the offer has a usable crypto line and came in over TLS, otherwise plain RTP; with both on offer SRTP is preferred. required: nothing but SRTP over TLS, everything else is 488 (SipStats::rejected_insecure). SDES keys are never taken from UDP or TCP signaling (RFC 4568 section 7.1: keys need confidentiality and integrity from the signaling), under any policy: such an offer gets 488 and no crypto line is answered. Setting anything but off without the TLS listener is a startup error. The mode of a call is fixed at setup: a re-INVITE cannot turn SRTP off (488, the call stays up). In-dialog requests stay on TLS: the transport of a dialog is recorded from the real connection when the call is set up, never read from a Via (written by the sender); an in-dialog request for a TLS dialog that does not arrive on a TLS connection gets 488 (re-INVITE, UPDATE) or 403 (anything else) before the dialog sees it, with no SDP processed and no key answered (SipStats::insecure_dialog_requests). The ACK of a re-INVITE is delivered by the stack straight to the re-INVITE's transaction without its connection, so a message inspector strips the SDP from an ACK that came in off TLS on a TLS dialog. Re-keying: a peer key that changed is answered with a fresh key of ours and fresh sender and receiver state (a leaked answer exposes nothing older); the same key again (hold, resume, refresh) changes nothing; a suite change must come with a new peer key (a new suite with the old key is refused: the _32 tag is the prefix of the _80 tag, so a receiver started over on the same key would take every earlier _80 packet cut to 4 bytes as new). The answer to an offer we made in a 200 (body-less re-INVITE) keeps the key we offered.
  • Outbound. A DialRequest.to of sips:... or ...;transport=tls is called over TLS, the callee's certificate verified against BRISVOX_SIP_TLS_CA (a PEM bundle; there are no built-in roots in the stack, so a public carrier's CA must be listed there). With optional or required a TLS call offers RTP/SAVP with AES_CM_128_HMAC_SHA1_80; an answer that is not SRTP with the offered tag and suite ends the call (BYE, DialError::BadAnswer). required refuses a non-TLS target before anything is sent. With DialRequest::destination (an explicit proxy address) and a callee named by a domain, the connection is made by Brisvox and the certificate is verified for the callee's domain (the stack would verify the proxy's IP, which public CAs do not certify); a live connection verified for the same name is reused, and the handshake follows BRISVOX_SIP_TLS_HANDSHAKE_MS. A callee given as an IP address is verified for that address. Not done: DNS SRV/NAPTR for TLS targets.
  • Logs. rsipstack logs every SIP message at info, which on a secure call includes the SDP and with it the SRTP master keys. The server binary therefore holds the target rsipstack at warn whatever RUST_LOG says (like the TURN client crates), and DEFAULT_FILTER names it too. BRISVOX_LOG_SIP_STACK=1 lifts that for debugging only. A host that installs its own subscriber must apply brisvox_core::telemetry::sip_stack_log_allowed. The stack also logs one warning at start about the TLS address entry that is not a listener it can serve (a placeholder that makes it write Via: SIP/2.0/TLS for requests to TLS callees); it is expected. Brisvox's own events, Debug output and errors never contain key material (test key_material_never_reaches_brisvoxs_logs_or_debug_output).

Which trunks were checked (documentation fetched 2026-10-13; nothing was tested live against a carrier; that live run is pending):

PeerWhat its documentation saysSource
Twilio Elastic SIP TrunkingTLS 1.2 and above, default port 5061, origination URI sip:host:5061;transport=tls; SRTP by SDES (RFC 4568); origination (Twilio to us) offers only AES_CM_128_HMAC_SHA1_80, termination accepts _80 and _32; the MKI parameter is not supported; sips: is treated as sip: over TLS (Twilio recommends ;transport=tls); with Secure Trunking enabled unencrypted calls are rejectedhttps://www.twilio.com/docs/sip-trunking (Secure Trunking)
TelnyxTLS 1.2 and 1.3 on port 5061 and an "SRTP" encrypted-media setting on the trunk; its default offer lists AEAD_AES_256_GCM_8 first and AES_CM_128_HMAC_SHA1_80 as well (fetched 2026-10-08, docs/providers/sip.md), so Brisvox answers the _80 line. Not re-fetched on 2026-10-13: the Telnyx pages (a search found only third-party guides)sip.telnyx.com as recorded in docs/providers/sip.md; https://docs.livekit.io/telephony/features/secure-trunking/ (LiveKit's Telnyx steps)
Asterisk (PJSIP)media_encryption=sdes over a TLS transport; default offer AES_CM_128_HMAC_SHA1_80, srtp_tag_32 selects the 32-bit tag; media_encryption_optimistic allows plain fallbackhttps://issues.asterisk.org/jira/browse/ASTERISK-26190, https://community.asterisk.org/t/sdes-support-in-asterisk-18-and-pjsip/107792
FreeSWITCHrtp_secure_media (mandatory/optional) with a suite list that includes AES_CM_128_HMAC_SHA1_80 and _32 next to AEAD and 256-bit suites; offers strongest first by default, so Brisvox takes the first line it supports (_80/_32)https://developer.signalwire.com/freeswitch/Channel-Variables-Catalog/rtp_secure_media_16353121
Vonagenot checked
RFC 4568 (SDES), RFC 3711 (SRTP)`a=crypto: inline:<base64 key

Facts about the stack itself (rsipstack 0.7.3 source, read 2026-10-13): its TLS listener has no handshake deadline or connection limit (hence Brisvox's accept loop); TlsConnection::from_server_stream takes a finished stream; the connection type of an outbound request comes from the URI's transport parameter, not from the sips: scheme (Brisvox adds ;transport=tls to a sips: callee); a request's Via transport is chosen from the stack's listener addresses; dropping any TransportLayer clone cancels the whole stack (so connection tasks never hold one); it keeps no trust roots of its own (empty store plus the PEM it is given).

What remains.

  • DTLS-SRTP (RFC 5763/5764, a=fingerprint, a=setup) is not implemented; it is what WebRTC peers use, and trunks mostly do not. Not cheap: a DTLS handshake per call plus SRTP key export (whether str0m's DTLS can be reused for it was not evaluated).
  • AEAD suites (AEAD_AES_128_GCM/256_GCM, RFC 7714) and the 256-bit CM suites; SRTCP and RTCP (none is sent, the peer's is ignored); MKI; SDES session parameters (KDR, WSH, FEC_*, UNENCRYPTED_*) — lines with them are ignored, never half-honoured; optimistic SRTP (RTP/AVP with a=crypto) is read as plain RTP.
  • Mutual TLS (a client certificate from the carrier), certificate hot reload, outbound DNS SRV/NAPTR, a built-in public trust store for outbound TLS. A TLS connection verified for one callee domain and reused for another domain through the same proxy address stays verified for the first name only (the stack caches connections by address).
  • Interop against a real carrier or SBC over TLS+SRTP: the tests use a scripted peer that speaks the same RFCs as the implementation, so a shared misreading would not show.

Twilio Media Streams (bidirectional) — telephony transport

Scope: Twilio Media Streams (mu-law 8k; interruption sends the buffer-clear message; playback tracking via marks feeds played-text truncation; TwiML example). Related: docs/providers/g711.md (codec), docs/providers/call-control.md (DTMF, transfer). Researched: 2026-10-08. Every fact below was read from the pages listed under Sources on that date. Anything not confirmed is marked UNVERIFIED. This is a transport, not an STT/LLM/TTS provider: Twilio is the WebSocket CLIENT, brisvox is the WebSocket SERVER.

  • Docs: https://www.twilio.com/docs/voice/media-streams/websocket-messages (fetched 2026-10-08); full source list at the bottom. JSON examples below come from the .md rendition of that page (.../websocket-messages.md), where the plain page fetch would only summarize them. Values like MZXXXX... are the docs' placeholders.
  • Auth / trust:
    • There is no API key for the stream. Twilio connects to the wss:// URL in the TwiML. The only documented authenticity check is the X-Twilio-Signature header on the WebSocket upgrade request (see "Request validation" below).
    • Brisvox: the TwiML is served by the user's HTTP webhook (not by brisvox core); the WS endpoint must validate the signature with the account's primary Auth Token. Never log the token.
  • Endpoint(s) / regions:
    • Brisvox hosts a wss:// endpoint. wss is the only supported protocol and the <Stream url> does not support query string parameters (use <Parameter> instead); a relative or an absolute URL is accepted (stream TwiML page). Error 31923 "Malformed URL" = URL scheme malformed; 31920 "Handshake Error" = server did not answer the upgrade with HTTP 101 (wrong path, no WebSocket support, query string in the URL).
    • Twilio must reach the server on TCP 443 "from any public IP address"; the firewall page lists NO IP ranges, hostnames or other ports. TLS version/certificate requirements: not stated (UNVERIFIED; use a normal publicly trusted certificate). Terminate TLS in front of brisvox (proxy) or in-process; behind a proxy the URL used for signature validation must be the public one.
    • Regions: Media Streams is available in Ireland (IE1) and Australia (AU1); the default region is US1 (media-streams overview). <Connect><Stream> is also stated available in IE1/AU1 (connect TwiML page). Non-US setup follows separate Twilio guides (not fetched: UNVERIFIED details).
  • TwiML to start a bidirectional stream (stream TwiML page; <Connect><Stream>):
    <Response>
      <Connect>
        <Stream url="wss://example.com/audiostream" />
      </Connect>
      <Say>This TwiML instruction is unreachable unless the Stream is ended by your WebSocket server.</Say>
    </Response>
    
    • With custom parameters (docs example is for <Start><Stream>; the same <Parameter> noun nests in <Connect><Stream>: the page says <Parameter> is nested in <Stream> and the Start message carries the values; a <Connect> example with <Parameter> is not printed: UNVERIFIED but implied):
      <Response>
        <Start>
          <Stream url="wss://mystream.ngrok.io/example">
            <Parameter name="FirstName" value="Jane" />
            <Parameter name="LastName" value="Doe" />
          </Stream>
        </Start>
      </Response>
      
      Brisvox example (derived, not a doc sample):
      <Response>
        <Connect>
          <Stream url="wss://agent.example.com/twilio/stream" name="brisvox">
            <Parameter name="agent" value="support-tr" />
          </Stream>
        </Connect>
      </Response>
      
    • <Stream> attributes (stream TwiML page): url (required), name (optional, unique per call), track (inbound_track default | outbound_track | both_tracks; unidirectional only, a bidirectional stream "can receive only inbound_track"), statusCallback (absolute URL), statusCallbackMethod (GET|POST, default POST).
    • <Parameter name= value=>: the combined length of each <Parameter> name and value must be under 500 characters (stream TwiML page). No maximum parameter count is stated (UNVERIFIED). Values arrive in start.customParameters (all as strings in the doc example).
    • <Connect><Stream> blocks: Twilio does not execute later TwiML until the server closes the WebSocket (stream TwiML page; overview page says the same). With no further verb the call ends (general <Connect> page: "If there is no further verb, Twilio will end the phone call", stated for the case without an action URL). The Stream resource (REST) cannot start a bidirectional stream.
  • Protocol (messages in/out, with field names). All frames are JSON text frames (binary frames are not mentioned: UNVERIFIED).
    • Twilio -> server, in order: connected, then start, then media (repeating) and optionally dtmf/mark, finally stop.
      { "event": "connected", "protocol": "Call", "version": "1.0.0" }
      
      {
        "event": "start",
        "sequenceNumber": "1",
        "start": {
          "accountSid": "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
          "streamSid": "MZXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
          "callSid": "CAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
          "tracks": [ "inbound" ],
          "mediaFormat": { "encoding": "audio/x-mulaw", "sampleRate": 8000, "channels": 1 },
          "customParameters": { "FirstName": "Jane", "LastName": "Doe", "RemoteParty": "Bob" }
        },
        "streamSid": "MZXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
      }
      
      {
        "event": "media",
        "sequenceNumber": "4",
        "media": { "track": "inbound", "chunk": "2", "timestamp": "5", "payload": "no+JhoaJjpzS..." },
        "streamSid": "MZXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
      }
      
      {
        "event": "stop",
        "sequenceNumber": "5",
        "stop": { "accountSid": "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "callSid": "CAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" },
        "streamSid": "MZXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
      }
      
      { "event": "dtmf", "streamSid": "MZXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "sequenceNumber": "5",
        "dtmf": { "track": "inbound_track", "digit": "1" } }
      
      { "event": "mark", "sequenceNumber": "4", "streamSid": "MZXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
        "mark": { "name": "my label" } }
      
    • Field notes (from the messages page):
      • connected: protocol ("protocol for the connection's lifetime", example "Call"), version (semantic version, example 1.0.0). First message.
      • start: sent once, immediately after connected. start.tracks array of inbound/outbound; start.mediaFormat.encoding is always audio/x-mulaw, sampleRate always 8000, channels always 1; start.customParameters = the <Parameter>s; streamSid (also repeated at top level of every message). sequenceNumber starts at 1 and increases per message.
      • media.track: inbound or outbound (one of the page's media examples shows "outbound"). For a bidirectional stream only the inbound track is delivered (stream TwiML page / overview page). media.chunk: starts at 1, increments per media message. media.timestamp: "Presentation Timestamp in Milliseconds from the start of the stream". media.payload: raw audio, base64.
      • TYPES: in every doc example sequenceNumber, media.chunk and media.timestamp are JSON STRINGS ("1", "2", "5"), while sampleRate and channels are numbers. The property tables describe them as numbers ("Number used to keep track of message sending order"). Brisvox parser must accept both string and number for these three fields. (Parsing as integer from a string is the safe approach.)
      • dtmf (bidirectional only): dtmf.track is always inbound_track (note: differs from the inbound value used in media.track), dtmf.digit the key. Digit alphabet beyond a single key (0-9, *, #, A-D) is not specified: UNVERIFIED. Outbound DTMF from the server to Twilio is NOT supported (overview page: DTMF only inbound, from Twilio toward your media server).
      • mark (Twilio -> server): bidirectional only; sent when audio the server sent has finished playing (or was cleared), with the mark.name the server chose.
      • stop: sent when the stream stops or the call ends. The messages page says for bidirectional streams "the only way to stop a Stream is to end the call", but the overview page says a bidirectional stream can be stopped "by closing the WebSocket connection from your server or by ending the Call", and error 31921 (paraphrase) says the close stops a bidirectional Stream. Treat server close as stream end.
    • Server -> Twilio (bidirectional streams only, started with <Connect><Stream>). Only media, mark and clear are accepted (errors 31951/31924); anything else, bad JSON, missing or unexpected fields or a streamSid that does not match the active stream is logged as "Invalid Message" (31951).
      { "event": "media", "streamSid": "MZXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "media": { "payload": "a3242sa..." } }
      
      { "event": "mark", "streamSid": "MZXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "mark": { "name": "my label" } }
      
      { "event": "clear", "streamSid": "MZXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" }
      
      Use the streamSid from the start message (31951 page: never reuse a SID from another call or stream).
  • Outbound audio format and chunking:
    • media.payload must be (messages page) "encoded audio/x-mulaw with a sample rate of 8000 and must be base64 encoded" and "should not contain audio file type header bytes" (i.e. no WAV/AU header; raw G.711 mu-law, 8000 Hz, mono, 1 byte per sample = 8000 bytes/s). Error 31924 repeats (paraphrase): send base64-encoded audio/x-mulaw at 8000 Hz without audio file header bytes.
    • Chunk size: "The audio can be of any size." No required or recommended chunk size or send interval is documented (UNVERIFIED claim that 20 ms = 160 bytes frames are ideal: it is arithmetic only, 160 bytes = 20 ms at 8 kHz mu-law; that is a brisvox choice and matches the 20 ms pacer frame in the engine). The same applies to inbound: the docs give no inbound frame duration (UNVERIFIED; examples show timestamp "5" next to chunk "1"/"2", which cannot be used to infer it).
    • Buffering: "The media messages are buffered and played in the order received." So Twilio keeps its OWN jitter/playout buffer on its side; brisvox may send ahead of real time, but anything sent ahead can only be removed with clear (see below) and is invisible to brisvox's pacer unless marks track it.
    • Buffer limit (error 31931 "Media Discarded", a warning logged once per stream): once buffered audio exceeds 10 minutes, Twilio stops accepting more audio for that Stream (page wording paraphrased). Causes listed: sending media faster than it plays, no mark tracking. Docs' remedies: pace outbound media, send a mark after each media message and wait for the matching mark event before sending large amounts of more audio, and clear to empty the buffer before replacement audio.
    • Inbound overflow (error 31930 "Buffer Overflow", warning): if limited bandwidth stops the stream from keeping up, Twilio discards audio (inbound audio is lost; no reconnect documented).
  • Mark semantics:
    • Server sends mark after the media it wants to track. Twilio returns a mark event with the same mark.name "when the audio ends (or if there is no audio buffered)" (messages page wording). The page does not use the word "immediately"; an immediate return for an empty buffer is implied (UNVERIFIED as to timing).
    • On clear: the page says it "empties all buffered audio and causes any mark messages to be sent back to your WebSocket server", i.e. Twilio sends back a mark for every remaining (unplayed) mark so you can tell which media will not play (purpose paraphrased, not verbatim). The mark event for a CLEARED mark is indistinguishable from a played one on the wire (same JSON); brisvox must therefore know, locally, that it sent clear and treat any mark arriving after its own clear (until the next audio it sent after the clear) as "dropped" rather than "played". Order and timing of those returned marks relative to the clear are not specified (UNVERIFIED), nor whether a mark covering audio currently being played is returned at clear time or after the buffered sample finishes (UNVERIFIED).
    • Marks carry NO timestamp or played-sample count. A mark only says "everything sent before this mark has been played (or discarded)". Playback position at an arbitrary moment (needed by played-text truncation when the caller interrupts mid-chunk) is therefore not reported by Twilio: UNVERIFIED that no other field exists; none is documented. Granularity of the playback position = granularity of the marks.
    • Name: free string (page: "A custom value"). Maximum length and allowed characters not documented (UNVERIFIED; use short ASCII like a monotonically increasing counter or "<epoch>:<seq>"). Whether two pending marks may share a name: not stated (use unique names).
    • Brisvox design for played-text tracking (a proposal, not a doc claim): send a mark after every N ms of audio (e.g. every 100-200 ms or every TTS frame group) with name encoding (turn epoch, cumulative sample count sent). On a returned mark (not preceded by our own clear) advance the confirmed-played position; at barge-in the played position is confirmed_marks + estimate of the unconfirmed tail (the estimate comes from the local wall clock since the last mark; Twilio's own buffer depth is invisible), and the played text is truncated to that bound. Mark spacing trades accuracy against message count; every mark is one extra WS frame each way.
  • Interruption (barge-in):
    • Send {"event":"clear","streamSid":"..."}. Per the page: empties the audio buffered on Twilio's side and returns the pending marks. Brisvox rules: (1) send clear the moment the turn epoch advances (engine rule "Clear-on-epoch-advance"), before any new-epoch audio; (2) stop sending old epoch media; (3) media sent after the clear plays normally (the page implies buffer reuse; not explicitly stated: UNVERIFIED), (4) discard returned marks per the previous section.
    • Race: audio already handed to the phone network before clear arrives still plays. The size of that residual (Twilio-side playout buffer, carrier jitter buffer) is not documented: UNVERIFIED; measure on a live call.
    • The changelog (2020-06-17) introduced Mark and Clear for building barge-in (the caller can interrupt playback; wording paraphrased); the Twilio samples connect-basic and dialogflow-integration are named there.
  • Keepalive / timeouts:
    • No keepalive, ping interval, idle timeout, maximum call/stream duration or reconnect behaviour is documented on the pages fetched (UNVERIFIED). Each Media Stream uses one WebSocket connection (error 31921); if the connection drops the stream stops (31921) and there is no documented resume. Brisvox must treat a WS close as the end of the call leg.
    • Brisvox own timeouts apply (engine rule: every network await has an explicit timeout): e.g. time to first start after upgrade, and a stall timer on inbound media (Twilio sends inbound audio continuously while the call is up; the exact cadence is UNVERIFIED so size the stall timer generously and verify live).
    • Closing: closing the WebSocket from the server stops a bidirectional stream and lets the TwiML continue (31921 / stream TwiML page). Ending the call sends stop. Close code/reason Twilio uses on its side: not documented (UNVERIFIED).
  • Request validation (X-Twilio-Signature on the upgrade request):
    • Media Streams page: "validate the X-Twilio-Signature header" (firewall page: how your application verifies that a Media Stream is coming from an authentic Twilio source). The Security page adds for voice WSS handshakes only: "try appending a trailing / character to the URL that you pass to the signature validation method" if verification of a WebSocket handshake request (for example Programmable Voice Media Streams) fails.
    • Algorithm (security page, HTTP requests): take the full request URL (scheme through query string); for POST append sorted (case-sensitive) parameter names+values with no delimiters (GET: params are in the query string); HMAC-SHA1 with the PRIMARY Auth Token as key; base64; compare to the X-Twilio-Signature header. A WS upgrade is a GET without params, so the signed string is just the URL (Twilio's own search-summary statement, not a verbatim doc sentence: UNVERIFIED). Because <Stream url> cannot carry a query string, the signed URL is the bare wss://host/path as written in the TwiML. Whether Twilio signs the URL with wss:// or https:// scheme, and whether the port :443 is included, is NOT stated for WSS (UNVERIFIED): the security page says "Voice over HTTPS" drops the port (and username/password in the URL are always dropped). Implementation: try the candidate URL forms (as configured; with/without trailing /; wss/https scheme) and accept if any matches, constant-time compare; test live.
    • Primary Auth Token only (security page: if a secondary token was created, "you still need to use your previous AuthToken until the secondary one has been" promoted to primary). Behind a reverse proxy rebuild the public URL from configuration, not from the Host header you see.
    • Custom parameters (start.customParameters) arrive in a WS message AFTER the handshake and are not part of the signed URL; treat them as untrusted unless the handshake signature validated. (Twilio does not document signing them: UNVERIFIED that they are unsigned.)
    • Helper libs documented: Node validateRequest/validateRequestWithBody; Python/C#/Java/PHP RequestValidator; Ruby Twilio::Security::RequestValidator; Go client.NewRequestValidator (Validate / ValidateBody). No Rust SDK validator was found in these docs; a Twilio blog post "validate Twilio Event Streams webhooks in Rust" appeared in search results but concerns Event Streams, not this (not fetched: UNVERIFIED). Implement HMAC-SHA1 by hand (RustCrypto hmac + sha1, check crates.io for versions/licenses before adding; not researched here).
  • Timestamps / interim results / tool-call deltas: not applicable. Relevant timing data: inbound media.timestamp (ms since stream start, string) and chunk counter (gaps = lost inbound audio, 31930). sequenceNumber also counts every message (gaps detectable). Outbound media has no timestamp field.
  • Concurrency / rate limits:
    • Bidirectional: only one Stream per Call. Unidirectional: up to four tracks per call, shared with SIPREC, Real-Time Transcription, Answering Machine Detection (the overview page names SIPREC and Real-Time Transcription as examples of forking features sharing the limit; Answering Machine Detection in that list is UNVERIFIED). Each stream = one WebSocket.
    • Per-account concurrent stream / call limits, messages-per-second limit, and pricing: NOT found on the pages fetched (UNVERIFIED). Voice pricing page: https://www.twilio.com/en-us/voice/pricing (not fetched). The overview page links to "Forked Audio Streams" for fork costs (not fetched).
    • Outbound buffer cap: 10 minutes (31931, above).
  • Status callbacks (note only; not needed for the core adapter): statusCallback on <Stream> gets an HTTP request (method statusCallbackMethod, default POST) with AccountSid, CallSid, StreamSid, StreamName (defaults to StreamSid), StreamEvent (stream-started | stream-stopped | stream-error), StreamError (detail text), Timestamp (ISO 8601). Useful to debug failed starts (31920). Call-level status callbacks (call completed etc.) are separate Voice webhooks: not researched.
  • Turkish support: not applicable to the transport (language-agnostic G.711 audio). Telephony quality note: 8 kHz mu-law narrowband is typically harder for STT; Deepgram etc. accept mulaw @ 8000 directly (see deepgram.md for the exact encoding parameters); Cartesia can output pcm_mulaw @ 8000 directly (see cartesia.md). Whether Turkish <Say> etc. matters is irrelevant (brisvox does TTS).
  • Mapping to the brisvox transport trait (crates/brisvox-transport, check the real trait when implementing) — proposal:
    • Inbound: base64-decode media.payload (only track == "inbound") -> G.711 mu-law bytes -> decode via brisvox_audio::codec -> PCM16 8 kHz -> resample (rubato) to the pipeline rate. Drop messages whose streamSid differs from start.streamSid. Handle dtmf -> DTMF event. Handle stop -> end of session. Ignore unknown event values (forward compatibility).
    • Outbound: PCM16 -> resample to 8 kHz -> mu-law encode -> base64 -> media. Prefer 160-byte frames on the engine's 20 ms pacer (a design choice, see chunking above). Keep a reusable String/Vec buffer for base64 and the JSON frame (hot-path no-allocation rule).
    • Barge-in -> clear; marks -> playback position for played-text truncation (see Mark semantics).
  • Gotchas found during implementation: see "Brisvox implementation" below.

Brisvox implementation

Code: brisvox_transport::twilio (feature twilio, on by default; needs websocket). Tests: crates/brisvox-transport/tests/twilio.rs (scripted Twilio client over real sockets, local mock of the wire protocol), unit tests in src/twilio/*. Design notes: docs/ARCHITECTURE.md, "Twilio transport".

Setup

  1. Run a TwilioServer (its own listener, the same server infrastructure as the generic WebSocket transport: call limit and 503, accept queue, pings, idle and write timeouts, shutdown), behind a TLS-terminating proxy that exposes it as wss://agent.example.com/twilio/stream.
    #![allow(unused)]
    fn main() {
    let mut server = TwilioServer::bind(
        ServerConfig { bind_addr: "127.0.0.1:8081".parse()?, ..ServerConfig::default() },
        TwilioConfig {
            path: "/twilio/stream".into(),
            auth_token: Some(Secret::new(std::env::var("TWILIO_AUTH_TOKEN")?)),
            public_url: Some("wss://agent.example.com/twilio/stream".into()),
            ..TwilioConfig::default()
        },
        clock, &cancel,
    ).await?;
    // then: loop { let link = server.accept(&cancel).await?; TransportSession::open(.., link, ..) }
    }
  2. Point the voice webhook of the Twilio number at an HTTP endpoint that answers with TwiML (brisvox does not serve it; any web framework or a Twilio Function will do). The recommended form has a per-call stream URL: the public URL plus a signed single-use segment minted from the call's CallSid ("Per-call stream URLs" below; a runnable webhook that does it, with tests, is examples/phone-receptionist/twiml_server.py, set BRISVOX_TWILIO_STREAM_SECRET on both sides). The TwiML has this shape:
    <?xml version="1.0" encoding="UTF-8"?>
    <Response>
      <Connect>
        <Stream url="wss://agent.example.com/twilio/stream/CAxxxx.1760000120.mac" name="brisvox">
          <Parameter name="agent" value="support-tr" />
        </Stream>
      </Connect>
    </Response>
    
    Legacy (static TwiML Bin, needs BRISVOX_TWILIO_ALLOW_STATIC_URL=1 on the server, replayable signature): the same TwiML with url="wss://agent.example.com/twilio/stream", the fixed public URL. Without a stream secret and without that flag the listener does not start. The URL starts with TwilioConfig::public_url and must not contain a query string (use <Parameter>). The values arrive in CallInfo.metadata as custom.<name> (custom.agent = support-tr). Anything after <Connect> runs only after brisvox closes the socket (engine hang-up), e.g. <Say> or <Dial> for a hand-off.

What the adapter does

Twilio messageBrisvox
connectedremembered (twilio_protocol, twilio_version metadata); optional
startCallInfo (below); media format must be audio/x-mulaw, 8000, 1 channel when stated, else the connection is closed with 1008
media (track inbound or absent)base64 -> mu-law -> PCM16 8 kHz AudioFrame (WireEvent::Audio, DropOldest); other tracks and other streamSids are ignored
dtmfWireEvent::Dtmf (single key 0-9 * # A-D; else a fault)
marke<n>: MarkAck with the engine's id (TransportEvent::MarkPlayed); p<n>: progress bookkeeping; anything else or not in flight: dropped (marks_unmatched)
stop or close framehang-up (StopReason::PeerHangup); a vanished socket is PeerDisconnected
unknown eventignored
malformed JSON, bad base64, missing fieldsnon-fatal fault (TransportEvent::Error); more than 16 per call close it (1008)
EngineTwilio
WireOut::Audio (PCM16 8 kHz){"event":"media","streamSid":..,"media":{"payload":..}} with 160 mu-law bytes (20 ms, chunk_bytes) per message; larger engine frames are cut into that size
Mark{id}{"event":"mark","streamSid":..,"mark":{"name":"e<n>"}}; the id stays in the adapter
(adapter)a progress mark p<n> after every mark_interval (200 ms) of audio
Clear{epoch}{"event":"clear","streamSid":..} after dropping the marks in flight
Hangupclose frame 1000 (Twilio accepts no stop from the server); immediate, see "Ending the call"

CallInfo.metadata: transport=twilio, sample_rate=8000, encoding=mulaw, stream_sid, call_sid, account_sid, tracks, twilio_protocol, twilio_version, peer_addr, custom.<name>; call_id is the callSid. All outbound messages use the streamSid of start.

Audio rates: the adapter converts mu-law <-> PCM16 but does not resample (as the generic adapter): the engine must produce 8 kHz audio for a Twilio call (the output chain resamples). Bot audio at another rate is dropped and reported once per call as a fault. No audio is passed through as mu-law: the core is PCM16 only.

Marks, clear and played-text tracking

  • Twilio returns a mark both when audio played and when a clear flushed it, with identical JSON. The adapter tells them apart locally, as the notes under "Mark semantics" require: it keeps the marks in flight (name -> engine id, at most 512) and forgets all of them when it sends clear. An echo that arrives afterwards finds nothing and is dropped. This is more precise than "marks after a clear are dropped until new audio": a mark sent after the clear has a new name and counts. The core reports the engine's unechoed marks as MarkCleared at the same moment.
  • The wire name is a letter and a counter (e0, p17), never the engine's id: Twilio documents no length or character limit for names (UNVERIFIED), so ids of any length and content work.
  • Progress marks give TwilioStats::max_echo_latency (time from sending a mark to its echo, which is the playout delay plus the audio buffered ahead of it: open item 8) and max_unplayed (the most audio sent but not yet known played).
  • Playback tracking for played-text truncation has two signals. The engine's utterance-end marks come back as TransportEvent::MarkPlayed (the utterance was heard whole). Every echoed mark, progress or engine, also yields the audio sent after it (not yet played) and the adapter reports that as WireEvent::Lead, forwarded as TransportEvent::PlaybackLead { lead } (a level; a new report replaces a queued one). For a barge-in in the middle of an utterance the heard position is PlaybackPosition.played - PlaybackLead.lead, never below zero; it replaces the fixed lead_margin guess with what Twilio's buffer actually holds, to within the 200 ms mark spacing (Twilio sends no position inside a mark interval). Status: the transport side is done and tested (a lead report per echoed mark, coalesced to the latest, reset by a clear), brisvox-engine stores it (PlayoutBook::set_client_lead) and PlayoutTruncation reads it: heard = min(released, total_audio) - lead, where a reported lead REPLACES the fixed lead_margin (in both directions: a lead above the margin cuts more, a lead below it cuts less), less the audio of later utterances in the client's buffer; the lead in force when a Clear covered the utterance is the one used, and an uninterrupted response loses no lead. The replace rule is reasoned, not measured. Open risk (RISKS.md R15): a small reported lead (about 50 ms) drops the margin for audio still queued on the engine-to-Twilio path, which the lead (audio sent after the last echoed mark, as Twilio's buffer holds it) may not cover, so one more word than was heard can be kept. The conservative alternative is max(lead, lead_margin); switch to it if a live Twilio measurement (released position against the echo times of progress marks, with the wire queue loaded) shows the engine-side queue is not in the reported lead. clear_hook must be installed for the lead to be tied to its clear.

Ending the call

Closing the socket is the only hang-up, and it is immediate: the adapter does not wait for audio still queued toward Twilio or buffered by Twilio, and the docs do not say whether Twilio plays what it already buffered once the socket is closed (UNVERIFIED, open item 9). A goodbye utterance followed directly by TransportCommand::Stop can therefore be cut off. The engine must send the audio, then an utterance-end TransportCommand::Mark, wait for its TransportEvent::MarkPlayed (with its own timeout, since a mark can also be lost), and only then send Stop.

Request validation

With an auth_token, the upgrade needs X-Twilio-Signature = base64(HMAC-SHA1(token, URL)) or the answer is HTTP 403 (no header, bad base64, wrong token, wrong URL). The HMAC comes from ring (already in the tree through rustls; HMAC_SHA1_FOR_LEGACY_USE_ONLY is the name of the primitive, the algorithm is the one Twilio mandates) with a constant-time compare. Because the signed URL form for wss is not documented (UNVERIFIED, open item 1), the signature may match any of: the configured public_url; the same with https for wss (and http for ws); with the default port :443/:80 added or removed; with a trailing / added or removed (up to 8 forms, all public information). Unit tests pin HMAC-SHA1 to RFC 2202 and the digest to Twilio's published worked example. Requests for any other path get 404 before the signature is looked at. Without an auth token nothing is checked: use that only behind another check. Auth token and public_url are validated together at startup (TwilioConfig::validate); the token is a Secret and never printed. The signature is computed over public_url as configured, so path (the route the listener serves) may differ from the path of public_url: a gateway can serve wss://agent.example.com/voice/twilio and forward to /twilio/stream. The proxy must pass the X-Twilio-Signature header through unchanged.

What the signature does not give: it authenticates the URL, and for a <Stream url> without a query string the URL is the same on every call, so the signature is one constant value. Anyone who has seen it once (a proxy or access log) can open upgrades with it until max_calls is reached. start.customParameters are not signed at all. That is why a signed listener now needs per-call URLs (stream_secret, see "Per-call stream URLs" below) or the explicit legacy flag allow_static_url; the fixed-URL mode described here is the legacy one. Treat custom parameters as untrusted either way.

Per-call stream URLs (review MEDIUM-1)

The signature above covers the URL only, and the URL is the same for every call, so a signed upgrade that was captured once (a proxy or access log) is valid for ever. Brisvox therefore makes the URL different for every call and accepts each URL once.

Sources, read 2026-10-13: https://www.twilio.com/docs/usage/security (the signed string is the full request URL including the query string; for a POST the sorted parameters follow, a WebSocket upgrade is a GET without any; HMAC-SHA1 with the primary Auth Token, base64; "Voice over HTTPS" callbacks drop the port; a trailing slash may be missing; for voice WSS handshakes it says to try a trailing / if validation fails), and https://www.twilio.com/docs/voice/twiml/stream (the url attribute "does not support query string parameters", so per-call data goes either into a custom <Parameter>, which arrives after the handshake and is not signed, or into the URL path, which is signed). Twilio signs the path like any other part of the URL, so a per-call path segment is covered by X-Twilio-Signature with no change to the check. The URL forms tried (signature::candidate_urls: scheme wss/https, with or without :443, with or without a trailing /) apply to the full per-call URL unchanged.

The segment is <callSid>.<exp>.<mac> appended to the base URL (BRISVOX_TWILIO_PUBLIC_URL):

mac = base64url_nopad( HMAC-SHA256( secret, "brisvox-twilio-stream-v1|" + callSid + "|" + exp ) )

callSid is the call's SID (ASCII letters and digits), exp the Unix time in seconds after which the URL is dead (at most one hour ahead), secret is BRISVOX_TWILIO_STREAM_SECRET (at least 32 bytes). The upgrade is accepted only if all hold: the segment parses and its MAC verifies (constant time); exp is in the future on the injected clock; the segment was not used before (a bounded set, entries dropped at their expiry, at most 65 536 live ones, a full set refuses); X-Twilio-Signature matches the full per-call URL; and the start message that follows carries the same callSid (a URL minted for one call cannot open another: close code 1008). The segment is used up only after the signature held, so someone who holds a URL but no signature cannot burn a legitimate call's token. The used-set is per process: a restart empties it and several instances behind a load balancer each have their own, so a captured URL with a valid signature is replayable against another instance (or after a restart) until its exp, at most one hour ahead (120 s for a URL the server mints itself). Keep exp short (the lifetime of the call set-up, not the call) and pin the stream path to one instance if replay matters. Segments the server mints for the same call within one second get consecutive exp values, so each is a distinct token. Everything else is HTTP 403 (404 for a path that is not the base or the base plus one segment).

Who mints it:

  • Streams the server restarts itself (DTMF through call control, a failed warm transfer): the server writes the TwiML and signs the URL (StreamUrlSigner, lifetime 120 s).
  • The first stream of an inbound call is named by the operator's webhook, which mints the segment with the same secret:
import base64, hashlib, hmac, time

def stream_url(base, secret, call_sid, ttl=120):
    exp = int(time.time()) + ttl
    msg = f"brisvox-twilio-stream-v1|{call_sid}|{exp}".encode()
    mac = hmac.new(secret.encode(), msg, hashlib.sha256).digest()
    mac = base64.urlsafe_b64encode(mac).rstrip(b"=").decode()
    return f"{base.rstrip('/')}/{call_sid}.{exp}.{mac}"
const crypto = require("crypto");
function streamUrl(base, secret, callSid, ttl = 120) {
  const exp = Math.floor(Date.now() / 1000) + ttl;
  const mac = crypto.createHmac("sha256", secret)
    .update(`brisvox-twilio-stream-v1|${callSid}|${exp}`).digest("base64url");
  return `${base.replace(/\/+$/, "")}/${callSid}.${exp}.${mac}`;
}

Known answer (secret 0123456789abcdef0123456789abcdef-secret, call CAabc123, exp 1900000000): CAabc123.1900000000.ixBbTZftQyn5JYCeFPFULx9O_UM1uxv0OZ_JhN6GIm0 (a unit test pins it).

  • Legacy: a deployment whose own TwiML cannot do that sets BRISVOX_TWILIO_ALLOW_STATIC_URL=1 (TwilioConfig::allow_static_url): the fixed URL is accepted again (also next to per-call URLs when a secret is set), with the replay risk described above, and the server logs a warning at startup. Without a secret and without the flag the Twilio listener does not start.

Migrating from the fixed URL. (1) Set BRISVOX_TWILIO_STREAM_SECRET (32 bytes or more) and keep BRISVOX_TWILIO_ALLOW_STATIC_URL=1 for the transition, so both forms are accepted. (2) Change the webhook that answers inbound calls to return <Stream url="wss://host/twilio/stream/<segment>"> with a segment minted per call (the snippets above). Outbound calls and server-restarted streams need nothing. (3) Check that calls connect (a refused segment is an HTTP 403 to Twilio). (4) Remove BRISVOX_TWILIO_ALLOW_STATIC_URL; the fixed URL is then refused.

The listener's path is the base: /twilio/stream accepts /twilio/stream/<segment>. A gateway that rewrites the public path must keep the suffix (/voice/twilio/<segment> -> /twilio/stream/<segment>). Not verified live: that Twilio signs the per-call URL in the form the candidates cover (the same open item 1 as for the fixed URL).

Defensive handling of UNVERIFIED points

  • Numbers as strings or numbers (sequenceNumber, chunk, timestamp, sampleRate, channels): both parse. The three the adapter does not depend on (sequenceNumber, chunk, timestamp) and connected.protocol / version are read softly: any other type ("1.0", "", a float, an object) reads as absent and never costs the message that carries it. A connected message that cannot be read is skipped. sampleRate and channels are checked, so a malformed one is a protocol error on start. customParameters values that are not strings are kept as JSON text.
  • Binary frames from Twilio: ignored. Unknown events and fields: ignored. A missing connected: accepted. A missing track on media: treated as inbound.
  • Keepalive: Twilio is not documented to ping or answer pings. The server pings every ping_interval and closes after ping_timeout without any frame; inbound media (every 20 ms while the call is up, UNVERIFIED) counts as life, so set ping_timeout well above a plausible silent gap (default 30 s).
  • Chunk size: 160 bytes per outbound message is a brisvox choice, not a documented requirement; TwilioConfig::chunk_bytes changes it.
  • Inbound audio loss: skipped media.chunk numbers are counted (inbound_chunks_lost), not reported to the engine.
  • A clear while Twilio has nothing buffered is still sent (cheap, and the engine's ordering rule is "clear before new-epoch audio").

Gotchas found while implementing

  • tokio-tungstenite's Callback for the upgrade returns the full HTTP response as the error (clippy result_large_err); the adapter implements Callback on a small struct and boxes the refusal internally.
  • The accept loop of the generic server is shared by passing the per-connection serve function (ServeFn); TwilioServer wraps a WebSocketServer, so accept, shutdown and the active-call count are the same code.
  • A clear must forget the marks in flight before the message goes out; otherwise an echo that races the clear would be taken for a played mark.
  • Twilio accepts nothing but media/mark/clear from the server, so there is no way to send an error or a goodbye: faults go to the engine, fatal ones close the socket with a reason.

Open items to settle with a live test (needs a Twilio account, a number and a public wss URL)

  1. Exact URL form Twilio signs for the WSS upgrade (scheme, port, trailing slash) and that header name casing is X-Twilio-Signature.
  2. Inbound media cadence and frame size (ms per message); behaviour under backpressure (31930).
  3. Residual audio heard after clear (ms), order/timing of the marks returned by clear, and whether a mark for the audio currently playing is returned immediately.
  4. Whether media sent right after clear plays immediately and whether mark names may repeat or have a length limit.
  5. Idle/keepalive behaviour (does Twilio ping? does it close idle sockets?), close codes, and what happens to the call when brisvox closes the WS (TwiML continues).
  6. Whether numeric fields are ever sent as JSON numbers; whether binary frames ever occur.
  7. Whether <Parameter> inside <Connect><Stream> works exactly as in <Start><Stream> (expected yes).
  8. Playback latency Twilio adds between our media and the caller's ear (feeds the LATENCY.md budget).
  9. Whether Twilio plays audio it buffered after brisvox closed the WebSocket (decides whether the "wait for the final MarkPlayed before Stop" rule is needed or merely safe), and whether a bounded drain in the adapter is worth adding.
  10. Whether PlaybackPosition - PlaybackLead tracks what the caller heard (compare with the barge-in point reported by a test caller) and how stale PlaybackLead is at the 200 ms mark spacing.

Sources (all fetched 2026-10-08)

  • https://www.twilio.com/docs/voice/media-streams (and .md)
  • https://www.twilio.com/docs/voice/media-streams/websocket-messages (and .md; JSON examples taken from the .md)
  • https://www.twilio.com/docs/voice/twiml/stream (and .md; TwiML examples, attributes, <Parameter>, status callback fields)
  • https://www.twilio.com/docs/voice/twiml/connect (regions note; <Connect> action behaviour; no <Stream> section)
  • https://www.twilio.com/docs/usage/security (and .md; signature algorithm, WSS trailing-slash note, bodySHA256)
  • https://static1.twilio.com/docs/global-infrastructure/firewall-configurations/media-streams-configuration (port 443, any public IP, validate signature)
  • https://www.twilio.com/docs/api/errors/31920 (WebSocket handshake error), /31921 (close error), /31923 (malformed URL), /31924 (protocol error), /31930 (buffer overflow, inbound), /31931 (media discarded, outbound 10 min), /31951 (invalid message)
  • https://www.twilio.com/en-us/changelog/bi-directional-streaming-support-with-media-streams (2020-06-17; introduces Mark and Clear)
  • Web search results (summaries, not primary text, used only where labeled UNVERIFIED): chunk size not documented; custom parameters not in the signed URL.
  • Not fetched: Twilio sample apps (twilio/media-streams node/connect-basic, dialogflow-integration), the Stream REST resource page (https://www.twilio.com/docs/voice/api/stream-resource), voice pricing.

Fact-checked 2026-10-08: 58 claims checked, 11 corrected. Corrections: (1) outbound payload format quote replaced with the real wording ("encoded audio/x-mulaw with a sample rate of 8000..."; the unfound sentence "Providing header bytes causes the media to be streamed incorrectly" and "raw mulaw/8000" removed); (2) "Audio can be any size" -> "The audio can be of any size."; (3) mark return wording is "or if there is no audio buffered" (no word "immediately"; timing now UNVERIFIED); (4) clear quote replaced with the real wording and the invented "so you know which media was discarded" quote dropped; (5) mark name description is "A custom value", not "a name of your choosing"; (6) secondary Auth Token quote corrected; (7) stop semantics: messages page says only ending the call stops a bidirectional stream, overview page and 31921 say closing the WebSocket does too (both now stated; the old 31921 and "Bidirectional Streams stop only..." quotes removed as non-verbatim); (8) "Only wss is supported"/"Query string parameters are not supported" and other non-verbatim quotes (changelog, 31930, 31931, 31951, "one WebSocket connection") turned into paraphrases; (9) Ruby/Go validator names completed; (10) a media example on the messages page shows track: "outbound", noted; (11) Answering Machine Detection in the shared four-track list marked UNVERIFIED (overview names only SIPREC and Real-Time Transcription).

Telnyx Media Streaming (bidirectional) — telephony transport

Scope: the Telnyx counterpart of the Twilio adapter (verify docs). Twin of docs/providers/twilio.md (read that first for the shared design: mu-law/A-law via docs/providers/g711.md, buffer-clear on interruption, playback tracking via marks feeds played-text truncation). Related: docs/providers/call-control.md (DTMF, transfer). Researched: 2026-10-08. Every fact below was read from the pages listed under Sources on that date. Anything not confirmed is marked UNVERIFIED. This is a transport, not an STT/LLM/TTS provider: Telnyx is the WebSocket CLIENT, brisvox is the WebSocket SERVER (same role as Twilio).

How the facts were obtained: the Telnyx pages were read through a summarizing fetch tool. The authoritative JSON examples and schemas below come from the AsyncAPI 3.0.0 spec (v2.0.0) that Telnyx links from its docs (https://telnyx-openapi-ng.s3.us-east-1.amazonaws.com/media-streaming/media-streaming.yml, read raw on 2026-10-08), so field names, types, enums and examples are exact. Prose rules (limits, mark semantics) are quoted/paraphrased from the guide and API reference. The guide's own JSON blocks could not be reproduced by the tool; they were checked only as key lists and agree with the spec.

  • Docs: https://developers.telnyx.com/docs/voice/programmable-voice/media-streaming (fetched 2026-10-08); WebSocket spec https://developers.telnyx.com/api-reference/websockets/stream-call-media-over-websocket (fetched 2026-10-08); full source list at the bottom.
  • Auth / trust:
    • Starting a stream (REST): Authorization: Bearer <API key> on POST https://api.telnyx.com/v2/.... That is Telnyx's API, not brisvox's; brisvox core does not hold it unless it also hangs up or transfers via REST (see "Ending the call").
    • The WebSocket itself has NO signature (no X-Telnyx-Signature-style header is documented for the upgrade). The only documented authenticity mechanism is an optional shared token: the stream_auth_token field of streaming_start and of dial (string, max length 4000; streaming_start reference: "An authentication token to be sent as part of the WebSocket connection"). When configured, Telnyx sends it as the WebSocket upgrade header x-telnyx-streaming-auth-token AND in the first frame as connected.x-telnyx-streaming-auth-token (spec: "When a streaming auth token is configured, Telnyx includes it in the connected.x-telnyx-streaming-auth-token field and sends it as the x-telnyx-streaming-auth-token WebSocket header"). Brisvox should compare it in constant time and refuse the upgrade (HTTP 401/403) on mismatch. Never log it.
    • stream_auth_token is listed on the Call Control streaming_start reference and on the dial (POST /v2/calls) reference (first ~100k of 168k characters of the dial page read; the stream_* parameters are all in that part). It is NOT listed in the answer request parameters (whole page, 129k characters, read in two fetches on 2026-10-08), not in the media-streaming guide, and not a TeXML <Stream> attribute. So with answer or TeXML you cannot set it; start the stream with streaming_start after answering, or authenticate with a secret in the URL path or a custom parameter (see below). Exact upgrade-header casing: UNVERIFIED (spec writes it lower case).
    • custom_parameters (streaming_start: array of {name, value}; TeXML: <Parameter name= value=>) arrive in start.custom_parameters (a map of strings). They are part of a WS message, not the upgrade, and not signed; treat as untrusted unless the token/URL secret validated. Top-level custom_parameters was not found on answer or in the part of dial that was read (UNVERIFIED there; it exists on streaming_start).
    • Network allowlist: Telnyx does not publish a separate stream source-IP list. The IP-whitelisting page says for the webhook CIDRs: "These ranges also apply to WebSocket stream connections." Webhook CIDRs: CH1 192.76.120.128/29, DC2 192.76.120.136/29, SV1 192.76.120.144/29, LD6 185.246.41.0/29, FR5 185.246.41.8/29, AM6 185.246.41.16/29, SY1 103.115.244.0/29, SG1 103.115.244.8/29; webhooks use TCP 443. The list may change (the page does not say how often): fetch it again before pinning (UNVERIFIED that it is complete for WS).
    • Webhooks (not the WS) are signed: headers telnyx-signature-ed25519 and telnyx-timestamp (stated on the TeXML stream callback reference page; that page notes the headers are compatible with the Standard Webhooks spec and can be mapped to webhook-timestamp/webhook-signature). The algorithm being Ed25519 is inferred from the header name; the page does not describe verification steps (UNVERIFIED). Only relevant if brisvox ever serves the Call Control webhook; it does not (the user's HTTP app does). Crate/verification details not researched.
  • Endpoint(s) / regions:
    • Brisvox hosts a wss:// endpoint (the spec's server is wss, host/path chosen by the customer, address /). TLS certificate requirements, supported TLS versions, accepted ports and whether ws:// is accepted: NOT stated (UNVERIFIED; use a publicly trusted certificate on 443). Query strings in stream_url: not forbidden in the docs seen (Twilio forbids them); UNVERIFIED, so prefer path secret + parameters.
    • Regions: no region selection for streaming found (UNVERIFIED). Telnyx source regions are the webhook CIDRs above.
    • Prerequisite (third-party integration guide found by search, not Telnyx docs, so UNVERIFIED; not re-checked in the 2026-10-08 fact check): a Call Control app with a webhook URL is needed for programmable calls; a plain number connection is not enough.
  • How a call is set up to stream. Four ways (all documented):
    1. Call Control dial (POST /v2/calls) with stream_url, stream_track, optionally stream_bidirectional_mode, stream_bidirectional_codec (guide's examples). The dial reference also lists stream_codec, stream_bidirectional_target_legs, stream_bidirectional_sampling_rate, stream_auth_token and stream_establish_before_call_originate (boolean, default false; meaning not read: UNVERIFIED).
    2. Call Control answer (POST /v2/calls/{call_control_id}/actions/answer) with the same stream_* fields (answer reference lists stream_url, stream_track, stream_codec, stream_bidirectional_mode, stream_bidirectional_codec, stream_bidirectional_target_legs; it does NOT list stream_bidirectional_sampling_rate or stream_auth_token, whole page read). Setting stream_url also triggers streaming.started / streaming.stopped / streaming.failed webhooks (streaming.failed is not named in the pages re-read on 2026-10-08; the guide and streaming-stop page name streaming.started and streaming.stopped: streaming.failed UNVERIFIED).
    3. Call Control streaming_start on a live call (POST https://api.telnyx.com/v2/calls/{call_control_id}/actions/streaming_start).
    4. TeXML: <Start><Stream> (asynchronous, call flow continues) or <Connect><Stream> (synchronous: the call flow waits until streaming stops, then the instructions after <Connect> run; the Connect page says "The following instructions will be executed when the service is stopped"; it has no XML example, so the <Connect><Stream ...> snippet further below is derived, not a doc sample). TeXML REST also has POST /v2/texml/Accounts/{account_sid}/Calls/{call_sid}/Streams.json (form-encoded Url, Track, Name, BidirectionalMode, BidirectionalCodec (PCMU|PCMA|G722 there), StatusCallback, StatusCallbackMethod).
    • Only ONE streaming/fork operation per call ("Currently only one streaming/fork operation is supported per call"); a media-forking request "will be stopped and replaced by a RTP connection" (the WS stream is replaced). The streaming_start reference shows generic 422 examples (not stream-specific): invalid call control ID (90015), call already ended (90018), call not answered yet (90034), "Media Streaming is used" (90045, detail "This command can't be issued when media streaming is used"), plus 10004/10032/10033 validation errors.
    • Brisvox example (derived, not a doc sample; JSON key names are from the streaming_start reference):
      POST https://api.telnyx.com/v2/calls/{call_control_id}/actions/streaming_start
      Authorization: Bearer <API key>
      {
        "stream_url": "wss://agent.example.com/telnyx/stream",
        "stream_track": "inbound_track",
        "stream_codec": "PCMU",
        "stream_bidirectional_mode": "rtp",
        "stream_bidirectional_codec": "PCMU",
        "stream_bidirectional_target_legs": "opposite",
        "stream_bidirectional_sampling_rate": 8000,
        "stream_auth_token": "<random secret>",
        "custom_parameters": [ { "name": "agent", "value": "support-tr" } ],
        "client_state": "<base64>",
        "command_id": "<unique>"
      }
      
      The same fields exist as top-level stream_* fields on dial (with stream_auth_token) and, except stream_bidirectional_sampling_rate/stream_auth_token, on answer. TeXML equivalent (derived; attributes from the TeXML <Stream> page; no bidirectional XML example exists in the docs):
      <Response>
        <Connect>
          <Stream url="wss://agent.example.com/telnyx/stream" bidirectionalMode="rtp" bidirectionalCodec="PCMU"
                  bidirectionalSamplingRate="8000" enableReconnect="false">
            <Parameter name="agent" value="support-tr" />
          </Stream>
        </Connect>
      </Response>
      
    • streaming_start parameters (exact, reference page; no field is marked required): stream_url (string, e.g. wss://www.example.com/websocket); stream_track (inbound_track default | outbound_track | both_tracks); stream_codec (PCMU|PCMA|G722|OPUS| AMR-WB|L16|default, default default; "When set to 'default' or when transcoding is not possible, the codec from the call will be used"); stream_bidirectional_mode (mp3 DEFAULT | rtp); stream_bidirectional_codec (PCMU default | PCMA|G722|OPUS|AMR-WB|L16; used only with mode rtp; case sensitive); stream_bidirectional_target_legs (both|self|opposite, default opposite); stream_bidirectional_sampling_rate (integer 8000 default | 16000 | 22050 | 24000 | 48000); enable_dialogflow (bool, default false), dialogflow_config (analyze_sentiment, partial_automated_agent_reply, both bool default false); client_state (base64); command_id (dedupe per call_control_id); custom_parameters; stream_auth_token (max 4000). Response 200 {"data":{"result":"ok"}}; 422/default errors[] with code, title, detail, source.
    • TeXML <Stream> attributes (exact): url (required), track, name, codec (PCMU|PCMA|G722|OPUS|AMR-WB| default; "Currently, transcoding is only supported between PCMU and PCMA codecs"), bidirectionalMode (mp3 default|rtp), bidirectionalCodec (PCMU default|PCMA|G722|OPUS|AMR-WB), bidirectionalSamplingRate (8000 default|16000| 24000), statusCallback, statusCallbackMethod (GET|POST, default POST), enableReconnect (default true). Child <Parameter name= value=>. Status callbacks: stream-started, stream-stopped, stream-failed. Doc examples on the page use <Start><Stream url="wss://yourdomain.com/stream" track="both_tracks" /> and a statusCallback + <Parameter> variant. Docs discrepancy: Call Control allows rates 22050/48000, TeXML only 8000/16000/24000, and the TeXML <Stream> codec list has no L16; use Call Control for L16.
  • Protocol (messages in/out, with field names). All frames are JSON text frames (binary frames not mentioned: UNVERIFIED). Field names are snake_case; stream id key is stream_id (a UUID), not streamSid.
    • Telnyx -> server, in order (spec "Connection Flow"): connected, start, then media (repeating) and optionally dtmf / mark / error, finally stop.
      { "event": "connected", "version": "1.0.0" }
      
      With a configured token:
      { "event": "connected", "connected": { "x-telnyx-streaming-auth-token": "3c8b06a4-94a8-4d03-9fd1-941bc3f7166a" }, "version": "1.0.0" }
      
      {
        "event": "start",
        "sequence_number": "1",
        "stream_id": "32DE0DEA-53CB-4B21-89A4-9E1819C043BC",
        "start": {
          "user_id": "3E6F995F-85F7-4705-9741-53B116D28237",
          "call_control_id": "v2:T02llQxIyaRkhfRKxgAP8nY511EhFLizdvdUKJiSw8d6A9BborherQ",
          "call_session_id": "ff55a038-6f5d-11ef-9692-02420aeffb1f",
          "from": "+13122010094",
          "to": "+13122123456",
          "tags": [ "TAG1", "TAG2" ],
          "client_state": "aGF2ZSBhIG5pY2UgZGF5ID1d",
          "custom_parameters": { "customer_id": "customer_123" },
          "media_format": { "encoding": "PCMU", "sample_rate": 8000, "channels": 1 }
        }
      }
      
      {
        "event": "media",
        "sequence_number": "4",
        "stream_id": "32DE0DEA-53CB-4B21-89A4-9E1819C043BC",
        "media": { "track": "inbound", "chunk": "2", "timestamp": "5", "payload": "no+JhoaJjpzSHxAKBgYJDhtEopGKh4aIjZm7JhILBwYIDRg1qZSLh4aIjJev" }
      }
      
      { "event": "dtmf", "stream_id": "32DE0DEA-53CB-4B21-89A4-9E1819C043BC", "occurred_at": "2025-06-05T08:54:19.698408Z",
        "sequence_number": "5", "dtmf": { "digit": "1" } }
      
      { "event": "mark", "sequence_number": "5", "stream_id": "32DE0DEA-53CB-4B21-89A4-9E1819C043BC", "mark": { "name": "some_mark_name" } }
      
      { "event": "stop", "sequence_number": "5", "stream_id": "32DE0DEA-53CB-4B21-89A4-9E1819C043BC",
        "stop": { "user_id": "3E6F995F-85F7-4705-9741-53B116D28237", "call_control_id": "v2:T02llQxIyaRkhfRKxgAP8nY511EhFLizdvdUKJiSw8d6A9BborherQ" } }
      
      { "event": "error", "stream_id": "32DE0DEA-53CB-4B21-89A4-9E1819C043BC",
        "payload": { "code": 100004, "title": "invalid_media", "detail": "Media provided was not base64 encoded" } }
      
    • Field notes (spec schemas; every Telnyx-sent object has additionalProperties: false, so unknown fields are not expected, but ignore them anyway for forward compatibility):
      • connected: required event, version (string, protocol version, example 1.0.0); optional connected object with optional x-telnyx-streaming-auth-token. NOTE: connected has no sequence_number/stream_id.
      • start: required event, sequence_number, stream_id (UUID), start. Required inside start: user_id (UUID), call_control_id, media_format. Optional: call_session_id (UUID), from, to ("Caller number or SIP URI when available" for from; "Callee number or SIP URI when available" for to), tags (strings), client_state (base64 string), custom_parameters (string -> string). media_format.encoding enum PCMU|PCMA|G722|OPUS|AMR-WB|L16; sample_rate integer enum 8000|16000; channels integer, constant 1. The spec has no track list in start (Twilio has tracks).
      • media: required event, sequence_number, stream_id, media{track, chunk, timestamp, payload}. track enum inbound|outbound ("Call leg track that produced the media frame"); chunk string, "Per-stream media chunk number. Use to reorder media frames if needed."; timestamp string, "RTP timestamp associated with this media frame." (so NOT milliseconds like Twilio; units/clock rate, wrap-around and relation to ms: UNVERIFIED; the example "5" next to chunk "2" can't be interpreted); payload base64 "RTP payload without RTP headers".
      • sequence_number: string, "Monotonic sequence number for frames sent by Telnyx on this WebSocket connection." connected and error frames carry none.
      • TYPES: sequence_number, chunk, timestamp are JSON STRINGS in schema and examples; sample_rate, channels and error code are numbers. Parse strings; accept numbers too, defensively (as for Twilio).
      • dtmf: required event, sequence_number, stream_id, dtmf.digit; optional occurred_at (date-time, "Timestamp captured by the telephony engine. Use this to order DTMF events if they arrive out of order."). Digit enum: 0-9, *, #, A, B, C, D. No track field (Twilio has dtmf.track).
      • stop: sent "when Telnyx stops media streaming for the call" (spec). The guide says it is sent when the call ends, after the streaming.stopped webhook; whether it is also sent when the stream is stopped via REST while the call stays up: not stated in the pages (UNVERIFIED). Carries stop.user_id and stop.call_control_id.
      • error: required event, stream_id, payload{code, title, detail}; code/title enum: 100002 unknown_error, 100003 malformed_frame, 100004 invalid_media (example detail "Media provided was not base64 encoded"), 100005 rate_limit_reached (guide: "Too many requests"). Whether the stream/connection survives an error frame: not stated (UNVERIFIED).
    • Ordering: "The order of events is not guaranteed and the chunk number can be used to reorder the events." (guide); the spec says to use media.chunk, media.timestamp and sequence_number. This is a real difference from Twilio: brisvox needs a small bounded reorder buffer keyed on media.chunk (and tolerate gaps), and DTMF should be ordered by occurred_at. How far out of order frames get, and the reorder window to use: UNVERIFIED (measure live). Whether the start frame can arrive after a media frame: not stated.
    • Server -> Telnyx (bidirectional; client schemas also have additionalProperties: false):
      { "event": "media", "media": { "payload": "eW91ciBiYXNlNjQgZW5jb2RlZCBhdWRpbyBwYXlsb2Fk" } }
      
      { "event": "mark", "mark": { "name": "some_mark_name" } }
      
      { "event": "clear" }
      
      These carry NO stream_id (Twilio requires streamSid on every message). Because the schemas forbid extra properties, sending a stream_id may be rejected as malformed_frame (100003): inference from the schema, not a doc sentence, UNVERIFIED. Pipecat's official Telnyx serializer sends exactly {"event":"media","media":{"payload":...}} and {"event":"clear"} with no stream id (source read 2026-10-08; it stores stream_id but never sends it), which is consistent. There is no server->Telnyx stop, and no documented way to send DTMF or an error to Telnyx over the socket.
  • Outbound audio format and chunking:
    • Bidirectional mode rtp: payload = base64 of the RTP payload (no RTP header) in the codec chosen by stream_bidirectional_codec (default PCMU). Guide table of codecs and rates: PCMU 8 kHz (default), PCMA 8 kHz, G722 8 kHz, OPUS 8 and 16 kHz, AMR-WB 8 and 16 kHz, L16 16 kHz. Verbatim warnings: when audio is sent in a different encoding than the call's "it will be transcoded, which may cause a degradation in quality"; the guide recommends L16 for AI voice agents (lower latency, no transcoding overhead). So the call's own codec (negotiated by the carrier/SIP leg) matters; the start.media_format tells the INBOUND encoding actually streamed.
    • Chunk size: "Provided chunks of audio can be in a size of 20 milliseconds to 30 seconds" (RTP mode). So Telnyx accepts large chunks, unlike a fixed 20 ms pacing; brisvox can still use 20 ms frames (160 bytes PCMU at 8 kHz). Minimum is 20 ms: smaller chunks are probably rejected or merged (UNVERIFIED; do not send < 20 ms).
    • L16 byte order (big/little endian), and whether it is raw linear PCM16: the docs say "L16" and "linear PCM" only; RFC 3551 L16 is network byte order, but Telnyx does not state it: UNVERIFIED (test live before using L16).
    • Rate limit: the guide says "Media payloads can only be submitted once per second." and this sentence sits under the guide's "Sending media files" subsection (MP3 files) of "Bidirectional media streaming", not under "Sending RTP stream". The spec adds MP3 frames are "played in submission order, with a one-message-per-second rate limit"; its RTP sentence ("RTP media chunks sent back to Telnyx can contain 20 milliseconds to 30 seconds of audio") carries no rate limit. So per the docs the once-per-second limit is for MP3 file payloads; that it does not apply to RTP-mode media frames (50/s at 20 ms) is an inference, not a doc sentence (UNVERIFIED): confirm live and watch for error 100005.
    • MP3 mode (the DEFAULT mode when stream_bidirectional_mode is not set): media.payload is a base64 MP3 FILE, queued and played in order. Not suitable for streaming TTS: always set stream_bidirectional_mode to rtp. A stream started without it would take brisvox PCM/G.711 frames as broken MP3 (UNVERIFIED consequence). (Note the guide's dial example for rtp also shows rtp explicitly.)
    • stream_bidirectional_target_legs: opposite (default), self, both "Specifies which call legs should receive the bidirectional stream audio". For an inbound call answered by brisvox the stream is on the caller's leg; the meaning of opposite for a single-leg inbound call is not explained in the pages read: UNVERIFIED. Test whether the caller hears the audio with defaults, else set self.
    • Tracks: stream_track selects inbound_track (caller audio; default), outbound_track (audio Telnyx plays to the caller, i.e. includes brisvox's own bidirectional audio, UNVERIFIED), or both_tracks. For the agent use inbound_track only; media.track is inbound for that. Drop frames whose track is not inbound.
    • Buffer capacity (Twilio: 10 minutes): not documented for Telnyx (UNVERIFIED). Whether brisvox may send ahead of real time and rely on clear: implied by the clear/mark text below but the queue size limit is unknown.
  • Mark semantics (guide + spec):
    • Server sends mark after the media to track. Telnyx returns {"event":"mark",...,"mark":{"name":..}} "when the media immediately preceding a submitted mark finishes playing. If no audio is playing or queued, the mark is returned immediately." (spec). Guide: "Submitting marks when no audio is played or queued will result in them being immediately sent back." This is stated explicitly for Telnyx (Twilio only implies it).
    • clear: guide "Sending a clear message will immediately stop the media playing on the stream and clear the media queue." Spec: "Stops the current media playback and clears queued media. Queued marks are sent back by Telnyx." So the pattern is the same as Twilio: marks for cleared audio are echoed with the same JSON as played marks; brisvox must track locally that it sent clear and drop the echoes (exactly as the Twilio adapter forgets in-flight marks before sending clear). Note the improvement over Twilio: clear "immediately stops the media playing", i.e. also the currently playing chunk (Twilio says it empties the buffer). Residual audio already in the phone network: UNVERIFIED.
    • Marks carry no timestamp or played-sample count; the mark name is a free string ("Customer-defined mark name"; length and charset limits not documented, UNVERIFIED; reuse the Twilio scheme e<n> / p<n>). Whether two in-flight marks may share a name: not stated (use unique names).
    • Whether a mark echo arrives in order relative to media/dtmf frames: no ordering guarantee exists for Telnyx frames in general (see Ordering); use mark names, not arrival order, to match.
    • Whether marks work in MP3 mode: the guide says they track when media "ends on the stream"; not stated which modes. Use rtp mode.
    • Playback tracking design for played-text truncation is identical to Twilio's (progress marks every ~200 ms plus utterance-end marks; heard = confirmed marks + estimate of the unconfirmed tail); see twilio.md "Marks, clear and played-text tracking".
  • Interruption (barge-in): send {"event":"clear"} the moment the turn epoch advances, before any new-epoch audio; stop sending old-epoch media; drop mark echoes that follow the clear (they refer to cleared audio). Whether media sent right after clear plays normally: implied (queue emptied), not stated (UNVERIFIED).
  • Keepalive / timeouts / reconnect:
    • No ping interval, idle timeout, maximum stream duration or close codes are documented (UNVERIFIED). Brisvox applies its own timeouts (time to first start after upgrade, stall timer on inbound media, ping/pong from our side as in the generic WebSocket server). Inbound media cadence and frame duration: not documented (UNVERIFIED; the spec says a media frame carries one RTP payload, plausibly 20 ms).
    • Reconnect: TeXML <Stream enableReconnect> (default true): "Whether the platform should automatically attempt to reconnect the WebSocket stream if disconnected." The TeXML page suggests enableReconnect="false" "to end the stream when the WebSocket disconnects" (page: end the stream immediately on disconnection). If reconnect is on and brisvox closes the socket (e.g. hang-up attempt) Telnyx may reconnect and deliver a fresh connected/start for the same call; for the engine this means the same call_control_id could reach the server twice (inference, UNVERIFIED). Brisvox must (a) recommend enableReconnect="false" for <Stream>, (b) key sessions by call_control_id and refuse/close a second connection for a live or ended call. Whether Call Control streams have an equivalent setting (none in streaming_start) and whether stream_id stays the same after a reconnect: UNVERIFIED.
    • streaming.failed webhook (and TeXML stream-failed) = "Stream failed to start or was interrupted". The TeXML stream-failed wording ("failed to start or was interrupted") is confirmed on the TeXML stream page; the Call Control streaming.failed webhook name was not found on the pages re-read on 2026-10-08 (UNVERIFIED).
    • Closing the WS from the server: the guide does NOT say closing the WebSocket stops the stream or ends the call (UNVERIFIED). For <Connect><Stream> the next verbs run after the service stops; whether a server-initiated close counts as "stops" is not stated.
  • Ending the call / stopping the stream (differs from Twilio):
    • There is no server->Telnyx stop message and (unlike Twilio) closing the socket is not documented as a hang-up. To hang up, call REST POST /v2/calls/{call_control_id}/actions/hangup (body optional fields client_state, command_id, custom_headers (SIP BYE headers); 200 {"data":{"result":"ok"}}; 422 codes 90015 invalid call control ID, 90018 call already ended; webhooks call.hangup, call.recording.saved). Pipecat's Telnyx serializer does exactly this (auto_hang_up default on, needs call_control_id and API key) on its EndFrame/CancelFrame.
    • To stop only the stream: POST /v2/calls/{call_control_id}/actions/streaming_stop with optional stream_id (UUID; if omitted "stops all streams associated with the call_control_id"), client_state, command_id (the request body object itself is marked required, all its fields optional, so send {}); sends the streaming.stopped webhook; 422 codes 90015/90018/90034. The TeXML equivalent ("update streaming") "moves the stream to a completed state and closes its WebSockets" (TeXML streams overview page).
    • Consequence for brisvox: the engine's Hangup needs a REST client (API key + call_control_id from start), supplied by the host app; a transport-level hang-up by closing the socket alone may leave the call up (UNVERIFIED either way). Provide a pluggable hang-up callback (e.g. a Hangup hook that calls the REST API) in the adapter config, and still close the socket after.
    • As with Twilio, to play a goodbye: send audio, then an utterance-end mark, wait for the echoed mark (own timeout), then hang up.
  • Webhooks around the stream (Call Control; field names from the guide as key lists): streaming.started and streaming.stopped have top-level event_type, id, occurred_at, payload, record_type; payload has call_control_id, stream_url (the top-level key list was not re-confirmed on 2026-10-08, only the payload keys: UNVERIFIED). call.answered carries stream_url, stream_track (not re-confirmed: UNVERIFIED). TeXML statusCallback payload fields: not found (the stream callback reference page had no schema: UNVERIFIED). These are HTTP callbacks to the user's app, not to brisvox core.
  • Timestamps / interim results / tool-call deltas: not applicable. Relevant timing data: inbound media.timestamp is an RTP timestamp string (not ms), chunk counter (gaps = lost or reordered inbound audio), sequence_number counts every Telnyx-sent frame; outbound media has no timestamp or chunk field.
  • Concurrency / rate limits: one streaming/fork operation per call; one bidirectional RTP stream per call (guide: "The number of bidirectional RTP streams per call is limited to 1."); media chunk 20 ms to 30 s; "Media payloads can only be submitted once per second" (guide places it under media-file/MP3 sending, see above); error 100005 rate_limit_reached. Per-account concurrent stream limits, pricing and messages-per-second for RTP: NOT found (UNVERIFIED).
  • Turkish support: not applicable to the transport (language-agnostic audio). Telephony quality note as in twilio.md: PCMU/PCMA 8 kHz is narrowband; L16/G722/OPUS 16 kHz (if the call leg negotiates it) gives wideband audio that Deepgram/Cartesia can use directly. Whether a Turkish PSTN call ever negotiates a wideband codec: UNVERIFIED (carrier dependent).

Differences from Twilio (summary)

(The Twilio column was not re-checked in the 2026-10-08 fact check; see twilio.md for its sources.)

TopicTwilio Media StreamsTelnyx Media Streaming
Start of callTwiML <Connect><Stream url>Call Control dial/answer/streaming_start with stream_url; or TeXML <Connect><Stream> / <Start><Stream>
Bidirectional enableimplicit with <Connect>explicit stream_bidirectional_mode: rtp (default is mp3)
Key namingcamelCase (streamSid, callSid, mediaFormat, sequenceNumber)snake_case (stream_id, call_control_id, media_format, sequence_number)
Stream idstreamSid (MZ...), required in every server messagestream_id UUID in Telnyx frames; NOT sent by the server
connectedhas protocol, versionhas version only (+ optional token)
startstreamSid, callSid, accountSid, tracks, mediaFormat, customParametersstream_id, start{user_id, call_control_id, call_session_id, from, to, tags, client_state, custom_parameters, media_format}
Codecalways audio/x-mulaw 8000 monoPCMU/PCMA/G722/OPUS/AMR-WB/L16; 8000 or 16000 in start; bidirectional codec set separately (default PCMU)
media.timestampms since stream start"RTP timestamp" (unit unverified)
Frame orderin orderNOT guaranteed; reorder by chunk
DTMFdtmf.track, dtmf.digitdtmf.digit, occurred_at (no track)
Mark on empty queueimpliedexplicit: returned immediately
Clearempties buffer, returns marks"immediately stop[s] the media playing" and clears queue, returns marks
Chunk sizeany20 ms to 30 s (RTP)
Send-rate rulenone stated (10-minute buffer cap)"once per second" for media-file (MP3) payloads per the guide; RTP scope unverified; rate_limit_reached
Errors to servernone; only logserror frame codes 100002..100005
Hang-upclose the WSREST hangup (WS close not documented as hang-up)
stop from servernot acceptednot accepted
Request authenticityX-Twilio-Signature HMAC-SHA1 over URLnone on WS; optional stream_auth_token header/field (streaming_start/dial only); webhooks signed (telnyx-signature-ed25519)
Reconnectnone documentedTeXML enableReconnect default true
Custom params<Parameter> (<500 chars name+value)custom_parameters / <Parameter> (no limits documented)

Mapping to the brisvox transport trait (proposal reusing the Twilio adapter structure, check the real trait when implementing)

  • Inbound: parse by event; connected (check token header/field if configured), start -> CallInfo (call_id = call_control_id; metadata transport=telnyx, stream_id, call_control_id, call_session_id, user_id, from, to, tags, client_state, encoding, sample_rate, custom.<name>), media (track inbound) -> reorder buffer (by chunk) -> decode by start.media_format.encoding: PCMU/PCMA via brisvox_audio::codec (g711.md); L16 (16 kHz) as PCM16; G722/OPUS/AMR-WB not decodable in-tree (refuse with a clear error, or require a codec in stream_codec) -> PCM16 AudioFrame. dtmf -> DTMF event (ordered by occurred_at), mark -> MarkAck or drop (post-clear), stop -> hang-up (PeerHangup), error -> fault (code in message), unknown event ignored. Drop frames whose stream_id differs from start.
  • Outbound: PCM16 at the stream rate -> encode in the SAME codec set as stream_bidirectional_codec (document that the host must set stream_bidirectional_codec equal to the inbound encoding to avoid transcoding; start-time config is the host's job) -> base64 -> {"event":"media","media":{"payload":..}}; marks e<n>/p<n> as in the Twilio adapter; clear per rule above. The adapter must know the outbound codec: it is NOT announced in start (that is the inbound media_format). Make it a TelnyxConfig field (default PCMU 8 kHz) and assert it equals start.media_format when inbound==outbound is expected.
  • Auth: constant-time compare of x-telnyx-streaming-auth-token (upgrade header; fall back to connected.*) with the configured secret; additional option of a secret path segment. 401/403 on mismatch before the upgrade completes. The token can only be set via streaming_start or dial, not answer/TeXML (see Auth), so the path secret is the fallback.
  • Hang-up: host-provided async hook (REST hangup), plus close frame; see "Ending the call".
  • Reuse of the Twilio server infrastructure (call limit, accept queue, ping/idle/write timeouts, mark bookkeeping) is expected; the new parts are the reorder buffer, snake_case/UUID parsing, the codec choice and the hang-up hook.
  • Gotchas found during implementation (brisvox_transport::telnyx, tests with a scripted peer only):
    • The reorder buffer anchors on the first frame it sees. If chunk 2 overtakes chunk 1 at the very start, chunk 1 is dropped as late (20 ms). The first chunk number is not documented (UNVERIFIED), so nothing is assumed.
    • A frame arriving while earlier ones are held takes the buffer path even when it is the chunk due (it is released at once); media_reordered counts those too.
    • Frames may overtake start (nothing says they cannot): they are dropped and counted, at most 64, then the connection is refused (1008). Tested with a start timeout of 30 s: the 65th frame refuses (media_before_start == 65), 64 frames followed by start open the call.
    • Review fixes (verified findings), all by the adapter's own tests with a scripted peer:
      • A stream the HOST stops on purpose (streaming_stop, e.g. the retry after a transfer/conference command was refused with 90045) must not look like the caller hanging up. Telnyx then closes the socket; whether it sends stop first is UNVERIFIED (open item 7). Without help the adapter would report a hang-up to the engine (ending its session and the tool call in flight) or have the hook REST-hang-up the call being transferred. Additive API: TelnyxServer::stream_stopper() returns a cloneable StreamStopper; expect_stop(call_control_id) is called BEFORE streaming_stop and returns whether the call streams here. For STOP_EXPECTED_WITHIN (30 s; a conservative window that covers the REST call plus the close, and makes a forgotten announcement harmless) a stop frame, a close frame or a vanished socket of that call is the host's own doing: no hook call, no hang-up event (the session is kept, what the engine sends is discarded), the call key stays claimed (a TeXML reconnect is refused). The session ends when the engine ends it, the server shuts down or after STOPPED_LINGER (120 s, conservative upper bound for a retried transfer; the engine then sees the peer disconnect). Intended caller: the call-control binding's 90045 retry path, which must call expect_stop before it issues streaming_stop (the API is additive; the binding itself is a separate change).
      • max_control_bytes (default 16 KiB) bounds control messages only. A media frame is bounded by max_audio_bytes (default 64 KiB; about 32 KiB of G.711, 43 KB of base64) through the websocket limit and the payload check; it used to be rejected as "control message too large" (1009, call hung up) above 16 KiB although Telnyx documents no size for inbound chunks. Everything that is not media, or does not parse, is still bounded by max_control_bytes.
    • Header casing of the token is irrelevant to the code (HTTP header names are case-insensitive), so open item 1 only concerns whether the header is sent at all; the adapter accepts the token from either place and refuses a wrong one from either.
    • DTMF is ordered by sequence_number (a per-connection counter shared with every other frame, so it has gaps) after a short hold (reorder_timeout), not by occurred_at; both work, the counter avoids parsing RFC 3339.
    • Not done: L16 (byte order), G722/OPUS/AMR-WB, stream_track=both_tracks (chunk numbering across tracks is undocumented), a transport-level REST hang-up (the host passes a HangupHook), per-account stream limits.

Open items to settle with a live test (needs a Telnyx account, a number, a Call Control app and a public wss URL)

  1. Exact header casing of x-telnyx-streaming-auth-token on the upgrade; whether answer/TeXML have an undocumented way to set it (docs: only streaming_start and dial list stream_auth_token).
  2. Whether media frames in rtp mode are subject to the "once per second" rule (expected not; the guide puts the rule under media-file sending), and the behavior on 100005.
  3. How out-of-order inbound frames really are (window size for the reorder buffer); unit of media.timestamp; media cadence.
  4. L16 byte order and exact sample format; effect of stream_bidirectional_sampling_rate vs codec (PCMU at 16000?) and which sample rate start.media_format reports for each combination; whether 24000/48000 work with L16/OPUS.
  5. Meaning of stream_bidirectional_target_legs for a single-leg inbound call (does opposite reach the caller?).
  6. Residual audio after clear; whether marks echoed by clear arrive before or after other frames; whether media after clear plays at once.
  7. Whether closing the WS ends the call, stops the stream, or triggers reconnect (and enableReconnect behavior); close codes; keepalive.
  8. Whether a stream_id in server frames is rejected (malformed_frame) or ignored; whether extra properties are tolerated.
  9. Whether Telnyx plays queued audio after the server closes the socket; error frame effect on the connection.
  10. Latency Telnyx adds between our media and the caller's ear (feeds the LATENCY.md budget).
  11. Query strings in stream_url; TLS requirements; source IPs actually used (compare against the webhook CIDRs).

Sources (all fetched 2026-10-08; re-fetched and fact-checked 2026-10-08)

  • https://developers.telnyx.com/docs/voice/programmable-voice/media-streaming (and .md; limits, codec table, mark/clear rules, error table, webhooks, dtmf, ordering)
  • https://developers.telnyx.com/api-reference/websockets/stream-call-media-over-websocket (and .md; AsyncAPI summary, field descriptions)
  • https://telnyx-openapi-ng.s3.us-east-1.amazonaws.com/media-streaming/media-streaming.yml (AsyncAPI 3.0.0 v2.0.0 raw; all JSON examples and schemas; token header)
  • https://developers.telnyx.com/api-reference/call-commands/streaming-start.md (parameters, enums, defaults, response)
  • https://developers.telnyx.com/api-reference/call-commands/streaming-stop.md (stream_id optional, streaming.stopped)
  • https://developers.telnyx.com/api-reference/call-commands/answer-call.md (stream_* parameters; whole page read in two fetches, 129k characters)
  • https://developers.telnyx.com/api-reference/call-commands/dial.md (stream_* parameters incl. stream_auth_token, stream_bidirectional_sampling_rate; first 100k of 168k characters read)
  • https://developers.telnyx.com/api-reference/call-commands/hangup-call.md (hangup endpoint and error codes)
  • https://developers.telnyx.com/docs/voice/programmable-voice/texml-verbs/stream (and .md; attributes, enableReconnect, Parameter, callbacks)
  • https://developers.telnyx.com/docs/voice/programmable-voice/texml-verbs/connect.md (<Connect> synchronous; instructions after run when the service stops; no Stream XML example)
  • https://developers.telnyx.com/docs/voice/texml/rest-api/streams.md and https://developers.telnyx.com/api-reference/texml-rest-commands/start-streaming-media-from-a-call.md (TeXML REST start/stop)
  • https://developers.telnyx.com/api-reference/callbacks/texml-stream.md (webhook signing headers telnyx-timestamp, telnyx-signature-ed25519; no payload schema)
  • https://developers.telnyx.com/docs/voice/sip-trunking/network-configuration/ip-whitelisting.md (webhook CIDRs apply to WebSocket streams; TCP 443)
  • https://raw.githubusercontent.com/pipecat-ai/pipecat/main/src/pipecat/serializers/telnyx.py (official Pipecat serializer source: media/clear without stream id; REST auto hang-up)
  • https://docs.pipecat.ai/api-reference/server/services/serializers/telnyx.md (serializer parameters; auto_hang_up)
  • Web search summaries (not primary text, used only where labeled UNVERIFIED): Call Control app prerequisite from a third-party guide; the IP-allowlist answer was cross-checked against the fetched IP page.
  • Not fetched / not researched: crates.io (no new crate is needed for this transport: JSON, base64 and tungstenite are already in the tree; Ed25519 webhook verification crate selection is out of scope since brisvox core does not serve the webhook), Telnyx pricing, per-account stream limits, Telnyx sample repos (team-telnyx on GitHub), the sip.telnyx.com voice.json file.

Fact-checked 2026-10-08: 62 claims checked, 9 corrected. Corrections: (1) stream_auth_token also exists on dial (POST /v2/calls), not only streaming_start; it is absent from answer (whole page read) and TeXML, so the old "unverified whether settable via dial" is resolved for dial/answer/TeXML; (2) dial also lists stream_bidirectional_sampling_rate and stream_establish_before_call_originate, while answer does not list the sampling rate (was UNVERIFIED); (3) the streaming_start description quote for stream_auth_token corrected to "An authentication token to be sent as part of the WebSocket connection"; (4) "once per second" is under the guide's "Sending media files" (MP3) subsection, not RTP; scope for RTP downgraded from "unclear" to "inference, UNVERIFIED"; (5) the claimed TeXML Connect page example (<Connect><Stream .../></Connect><Say>) does not exist on the Connect page; removed and the snippet is marked derived; (6) the 422 list under streaming_start is generic spec examples (90015, 90018, 90034, 90045, 10004, 10032, 10033), not stream-specific cases; (7) Ed25519 is inferred from the header name telnyx-signature-ed25519 (page gives no algorithm text), plus a Standard Webhooks compatibility note was added; (8) the guide says stop is sent when the call ends after streaming.stopped; the "or stream stopped via REST" part is UNVERIFIED; the streaming_stop body object is required (fields optional); (9) the Call Control streaming.failed webhook name, the webhook top-level key list and call.answered fields were not found in the re-read pages and are now marked UNVERIFIED. Still UNVERIFIED after re-check: top-level custom_parameters on dial/answer, the Call Control app prerequisite (third-party source, not re-checked), the Twilio comparison column (belongs to twilio.md), and all behavioral items in "Open items".

SIP / RTP — telephony transport (Rust stack selection)

Scope: research Rust SIP stacks; inbound/outbound call with RTP audio. Related: docs/providers/g711.md (codec), docs/providers/twilio.md (the Media Streams transport; depth and style example), docs/providers/webrtc.md (str0m based, the other RTP-carrying transport), docs/providers/call-control.md (DTMF, transfer), docs/ARCHITECTURE.md "Transport". Researched: 2026-10-08. Every fact below was read from the pages listed under Sources on that date (crates.io API, GitHub API/pages, docs.rs, vendor docs, RFCs). Anything not confirmed is marked UNVERIFIED. Caveat on method: the fetch tool summarizes pages with a small model, so code-level claims (method names, feature lists) are second-hand extracts, not source reads. The spike (section 3 below) must re-confirm the ones it depends on against the actual crate source before they are trusted. This is a transport, not an STT/LLM/TTS provider: brisvox is a SIP user agent (UAS for inbound calls, UAC for outbound calls); there is no HTTP/WebSocket leg.

  • Docs: RFC 3261 (SIP), RFC 3264 (offer/answer), RFC 3550/3551 (RTP, G.711 payload types), RFC 4733 (telephone-event), RFC 4028 (session timers), RFC 3581 (rport), RFC 4961 (symmetric RTP); stack docs per candidate in the tables; source list at the bottom (all fetched 2026-10-08).
  • Auth / trust (what the carriers do, not the stacks):
    • Twilio Elastic SIP Trunking termination (brisvox -> Twilio -> PSTN): IP ACL, credential list (SIP digest) or both; if both are configured both are enforced; Twilio warns IP ACL alone does not protect against certain attacks (elastic SIP trunking overview).
    • Telnyx: credentials (SIP username/password, works with dynamic IPs), FQDN (hostname based inbound routing, credentials or IP for outbound), IP + token (X-Telnyx-Token header), IP + tech prefix, IP + P-Charge-Info (Telnyx trunking docs, re-read 2026-10-08 in the fact-check).
    • Inbound trust in brisvox: INVITEs must be authenticated by source-IP allowlist (carrier ranges) and/or digest challenge (401/407). Never log credentials. An open UDP 5060 listener is scanned within minutes on the public internet (general knowledge, not a fetched fact): default-deny unknown sources.
  • Endpoint(s) / regions: see "Interop targets". Brisvox listens on UDP 5060 (default), optional TCP 5060 and TLS 5061; RTP on a configured UDP port range (one socket per call).

1. Candidate stacks (state on 2026-10-08)

crates.io/GitHub data fetched 2026-10-08 (API JSON). "Pushed" = GitHub pushed_at.

StackLatest (crates.io)LicenseLast release / pushPopularityScope
rsipstack (restsend)0.7.3 (2026-10-08)MITreleases 0.7.0..0.7.3 on 2026-10-06/07/08, 0.6.13 on 2026-09-29; push 2026-10-08212 stars, 69 forks, 2 open issues, 61.9k downloads, repo created 2024-11-04 (crate first published 2025-01-10)SIP signaling only (transport, transaction, dialog, registration, digest). No RTP/SDP/DTMF.
rsiprtp (0x4D44)0.4.1 (2026-05-07; only 2 versions ever: 0.0.0 on 2026-04-28, then 0.4.1)MIT OR Apache-2.0push 2026-05-08 (5 months old at research time)3 stars, 3 forks, 1 open issue, 261 downloadsFull audio UA: SIP + SDP + RTP/RTCP + jitter buffer + SRTP + codecs; sans-IO core + tokio transports.
rvoip (eisenzopf)0.3.12 (2026-10-01); rvoip-sip 0.3.12MITreleases 0.3.6 (2026-08-03) .. 0.3.12 (2026-10-01), push 2026-10-08175 stars, 28 forks, 79 open issues; rvoip 8.8k downloads, rvoip-sip 1.4kLarge workspace (46 crates): SIP, RTP, WebRTC, voice-AI helpers.
ezk (kbalt): ezk-sip-core, ezk-sip-ua, ezk-rtcezk-sip-core 0.9.2 (2026-07-16); ezk-sip-ua 0.9.1 (2026-04-16); ezk-rtc 0.0.1 (2026-04-16)MITpush 2026-09-2750 stars, 15 forks, 11 open issues; ezk-sip-core 95k, ezk-sip-ua 100k downloads (ezk-rtc 298)SIP core + UA (REGISTER, calls, REFER) + SDP-based media session.
xphone (x-phone)0.6.0 (2026-05-13)MITpush 2026-08-065 stars, 5 forks, 11 open issues, 1.4k downloadsHigh-level softphone lib: SIP + RTP + codecs; decoded PCM via crossbeam channels.
wavekat-sip0.3.1 (2026-10-06)Apache-2.0push 2026-10-06; many releases (0.2.2..0.3.1 in 2026-09-20..10-06)2 stars, 1 fork, 0 open issues, 922 downloads, repo created 2026-05-12"SIP signaling and RTP transport for voice pipelines"; negotiation only, codecs left to caller.
sip-core (xmppjingle/sipr)0.3.0 (2026-03-19)Apache-2.0push 2026-10-054 stars, 175 downloads; repo description "Rust CLI based SIP softphone""SIP protocol library for parsing, serialization, and dialog management". Not investigated further: UNVERIFIED capability.
rsip (vasilakisfil)0.4.0 (2022-04-01)crates.io: "non-standard"; GitHub: NOASSERTION ("Other")push 2024-06-15108 stars, 161k downloadsParser/generator only; stale; license unclear. Not a candidate (rsiprtp uses it only as a test oracle: parser_diff dev-dependency per rsiprtp tests.md).
PJSIP via FFIpjproject-sys 1.0.0 (2023-07-13, single version)crate MIT; PJSIP itself GPL-2.0-or-later with an alternative commercial license (contact licensing@pjsip.org)crate untouched since 20231.5k downloadsNote only, see below.
RTP-only cratesrtp-types 0.1.2 (2024-11-14, MIT OR Apache-2.0, 463k downloads); msf-rtp 0.4.2 (2026-06-03, MIT, tokio based); rtp-engine 0.1.0 (2026-03-06, MIT OR Apache-2.0, 50 downloads, claims RTP/RTCP, G.711, SRTP, jitter buffer, audio devices); rustrtc 0.4.3 (2026-10-08, MIT, restsend, WebRTC-centric, has TransportMode::Rtp/Srtp, G.711 packetizer, jitter buffer, 112 stars)Building blocks for media if signaling is taken from rsipstack.

1.1 Feature matrix (what the docs claim; "-" = not mentioned in what was fetched = UNVERIFIED unless stated absent)

Featurersipstack 0.7.3rsiprtp 0.4.1rvoip 0.3.12ezk (sip-ua 0.9.1)xphone 0.6.0wavekat-sip 0.3.1
REGISTER (+digest, refresh)yes (Registration, Credential); client example re-registers every max(expires,50) syes, digest incl. SHA-256 (RFC 7616), periodic refreshyes (beta-qualified)yes (Registration, RegistrarConfig)yes, auto-reconnect and keepaliveyes, digest + keepalive
INVITE / ACK / BYEyes (dialog layer: do_invite, server accept/reject/ringing, bye)yes (CallManager)yesyes (OutboundCall, InboundCall, Call)yes, in+out, 302 follow, early media 183yes (Caller, IncomingCall)
CANCELyes (cancel; hangup_with_headers = BYE if confirmed else CANCEL)tested (tests/integration_advanced.rs covers CANCEL per tests.md); not named on docs.rs/READMEyes (beta-qualified)---
re-INVITE / UPDATEyes (reinvite, update on dialogs)yes (hold/resume via re-INVITE; UPDATE RFC 3311)--hold/resume via re-INVITEhold/resume
PRACK (100rel)yesyes (RFC 3262)----
Session timers RFC 4028- (no session-timer API in the README or dialog pages: UNVERIFIED)yes (refresher + non-refresher; tick/next_deadline)--yesyes
Digest auth (UAC)yes ("built-in")yesyes (beta-qualified)yes (ezk-sip-auth dependency)yesyes
UDP / TCP / TLSUDP, TCP, TLS (rustls), WebSocket (TLS and WebSocket via default-on features rustls/websocket)UDP, TCP, TLS (rustls); no SIP-over-WebSocketUDP, TCP, TLS (beta-qualified); plain SIP-over-WebSocket "bounded evidence", WSS developer preview- (TLS via features tls-rustls/tls-native-tls on ezk-sip-core, neither default; UDP/TCP: UNVERIFIED)UDP, TCP, TLSUDP; TLS behind tls feature (off by default)
SDP offer/answerhelper only: InviteOption.offer body; example parses with sdp-rs; no negotiationyes (RFC 3264)yes (negotiation)yes (SDP-based media backend, ezk-rtc SdpSession)yes (not documented in detail)yes, Opus + G.711 only
RTP / RTCPnone in the crate; client example has its own RTP code in a play_file module (build_rtp_conn, play_audio_file; implementation not read)yes (RTP, RTCP SR/RR/SDES/BYE/APP, rtcp-mux)yes (beta-qualified)ezk-rtc 0.0.1 (uses rtp-types, rtcp-types)yes (own stack)header parser + receive loop + send loop
G.711 PCMU/PCMAnoneyes (also G.722, Opus)yes (beta-qualified)-yes (also G.722; Opus, G.729 optional feature flags)negotiates only; "encode/decode stays with the consumer"
RFC 2833/4733 telephone-eventnone (client example: no DTMF; info method exists for SIP INFO)yes (RFC 4733)yes (RFC 4733; SessionHandle::send_dtmf)-yes (RFC 4733 + SIP INFO RFC 2976)yes (RFC 4733, INFO fallback)
Jitter buffernoneyes, adaptive (reorder, duplicate, late)yes, listed under OS audio devices as developer preview-yes, built in-
SRTPnoneSDES (RFC 4568); no DTLS-SRTPSDES beta-qualified; DTLS developer previewezk-rtc has ICE/SRTP (depends on openssl)SDES (AES_CM_128_HMAC_SHA1_80, UNVERIFIED re-check); no DTLS-
NAT helpersaccept_with_public_contact (server dialog); example --external-ip (or EXTERNAL_IP env)ICE/STUN helper (host + server-reflexive candidates; TURN relay, trickle ICE, ICE restart not supported)STUN; TURN via external config (no hosted TURN)ICE crates existSTUN, TURN, ICE-Lite-
Async runtimetokio (TCP/WS need platform-tokio); also an embassy no_std target (UDP + TLS client only: UNVERIFIED, README says core/codec/transaction/dialog/UDP are no_std, TCP/WS need platform-tokio)tokio transports; core is sans-IO and runtime-agnostictokio ("full" in examples); sans-IO status unstatedtokio ^1 (RtpReceiver wraps tokio mpsc); ezk-rtc SdpSession is sans-IO, tokio feature optionalmixed: listen().await for server mode, one dedicated std::thread per call media pipeline, crossbeam channelsasync/await with tokio_util CancellationToken in examples; runtime unconfirmed
Sans-IOno (tokio tasks, transport layer)yes (transactions, dialogs, CallManager; time passed as now: std::time::Instant)unstatedpartly (SdpSession)not mentionednot mentioned
MSRVUNVERIFIED1.881.91UNVERIFIED1.87UNVERIFIED
Production usersREADME has a "Used by" heading with no entries (docs.rs copy of the README has none); RustPBX (same org, rustpbx 0.5.1 depends on rsipstack 0.7.1 and rustrtc 0.4.0)none listednone listednone listedREADME says it is "used in production alongside xbridge" (self-claim)none listed
Interop evidencenot stated in READMEonly Asterisk named (basic_call.rs against live Asterisk); parser differential-tested vs rsip (parser_diff) and RFC 4475 fixtures; 29-target cargo-fuzz crate (all per tests.md)release-gated against Asterisk, FreeSWITCH, Jambonz OSS 0.9.11, SIPp, baresip, Kamailio, OpenSIPS (self-reported; "not carrier certification"); Twilio/Telnyx not mentionednone statedclaims Telnyx, Twilio SIP, VoIP.ms, Vonage (trunks), Asterisk, FreeSWITCH, 3CX (PBXes) ("not a comprehensive compatibility matrix")none stated
Examplesproxy, client (REGISTER, invite, answer, file playback, echo), bench_uabasic_call (REGISTER+INVITE+BYE vs Asterisk), voicemail, ai_bridge (SIMULATED: no real SIP/RTP/WebSocket code, re-verified 2026-10-08), ice_call, session_timers01-quickstart-p2p..14-vapi-agent (14 examples); endpoint_local_call UNVERIFIEDexamples/ (need a remote endpoint; not runnable as is)examples/sipcliQuick Start snippets only
Stated maturitynonepre-1.0, "pin an exact version for production"; CONTRIBUTING: personal project, no commitment to triage/mergeSIP "Beta-qualified"; media/WebRTC/voice-AI "Developer preview"; "published crate is evidence of availability, not a blanket production-readiness statement"none found (README fetch was partial: UNVERIFIED)Beta"Early development. API will change between minor versions."

Other facts worth knowing:

  • rsipstack: ClientInviteDialog and ServerInviteDialog are marked deprecated in favour of invite_dialog::InviteDialog (docs.rs struct pages, latest; the dialog module index page itself shows no deprecation note). On InviteDialog, hangup() delegates to hangup_with_headers(), which picks BYE (confirmed) or CANCEL (early) and works for either role (CORRECTED from the 0.7.3 source in section 9: the docs.rs-derived claim that hangup() is BYE-only and a no-op on UAS dialogs was wrong). Four releases in three days (0.7.0-0.7.3) and a recent embassy/no_std refactor mean API churn: pin an exact version.
  • rsiprtp CallManager (docs.rs): create_call(remote_uri) -> CallId does no DNS or socket I/O (resolve with SipResolver, RFC 3263), handle_incoming_invite(dialog, offer_sdp) -> Option<(CallId, SessionDescription, u16)> (the u16 is undocumented), answer_call, reject_call, terminate_call (BYE; only for Established calls), handle_bye, tick(now) / next_deadline() for session timers, drain_events() and drain_outbound_requests() as the output side. The caller opens the client/server transactions itself for the requests the manager builds (PRACK, UPDATE, BYE, re-INVITE). This is a bring-your-own-glue sans-IO API, which fits brisvox style but means the integration glue (UDP socket loop, transaction pump, timers) is ours. now is std::time::Instant (docs.rs signature tick(&mut self, now: Instant); the docs pass next_deadline() to tokio::time::sleep_until), so the injected Clock must convert to/from std::time::Instant (confirmed 2026-10-08; previously UNVERIFIED).
  • rsiprtp dependencies (Cargo.toml): tokio, tokio-rustls/rustls/webpki-roots, hickory-resolver, audio-codec-algorithms, ezk-g722, ropus, aes/ctr/hmac/sha1/sha2/md5, bytes, uuid, serde; no optional features (default = []); the SIP parser is in-tree (rsip is a dev-dependency only). Version, license and MSRV are workspace-inherited (not in the crate Cargo.toml). No unsafe lint info available (workspace lints not read).
  • rsiprtp repo contains CLAUDE.md, wrk_docs/, wrk_journals/ (page does not say whether code is AI-written). 205 commits, no releases or tags on GitHub, CI workflow present. GitHub repo created 2025-12-05.
  • ezk: ezk-rtc says its goal is use cases beyond WebRTC "including SIP, which does not always require ICE or even SRTP"; it depends on openssl (build burden) plus ezk-ice, ezk-srtp, rtp-types, rtcp-types. ezk-rtc is at 0.0.1 with 298 downloads; the fetched part of the ezk README has no maturity statement.
  • rvoip: workspace of 46 crates, MSRV 1.91 (README badge), 79 open issues; audio API is SessionHandle::audio() with AudioFrame/AudioSender/AudioReceiver/AudioStream (api::audio), fields not documented on the page fetched; send_dtmf is RFC 4733. Dependency and compile-time weight would be large (UNVERIFIED, 46 crates).
  • xphone: the dedicated-thread-per-call model conflicts with the brisvox goal of hundreds of calls per container and the "no blocking work on tokio workers" / cancellation invariants (a thread per call is not a tokio worker, but it breaks the CancellationToken/Clock style and costs memory per call).
  • wavekat-sip: GitHub About says "built on rsipstack"; the README says "from-scratch ... no external SIP stack" and credits only rsip for message types. Contradictory, UNVERIFIED which is true.
  • PJSIP via FFI (pjproject-sys 1.0.0, 2023; a higher-level community repo "THE-PJPROJECT" by JadKHaddad exists, status not checked: UNVERIFIED): mature C stack with its own media threads and global state, GPL-2.0-or-later unless a commercial license is bought. Conflicts with the project's license preference (MIT/Apache/BSD), with the no-FFI-surprises ethos and with per-session isolation and deterministic simulation. Note only; not recommended.

2. Scored comparison

Scale 1 (poor) to 5 (strong). The scores are the author's judgement from the evidence above, NOT measured results; they were not validated against running code. Criteria are those named for the stack selection. "+own media" means the cost of the missing pieces is counted (we would write them).

Criterionrsipstack +own mediarsiprtprvoipezkxphonewavekat-sipPJSIP FFI
Maturity / maintenance4 (active, most adopted, API churn)2 (one author, last push 5 months ago, 261 downloads)3 (active, big, 79 issues, self-gated beta)3 (older, steady, small)3 (prod claim, small)2 (4 months old, "API will change")5 (C stack) but crate 1
Inbound + outbound calls54 (APIs present; end-to-end example is simulated)54545
RTP / G.7112 (nothing in crate)543535
DTMF (RFC 4733)2 (INFO send exists; 4733 ours)541 (UNVERIFIED)545
tokio fit5444131
License5 (MIT)5 (MIT OR Apache-2.0)5 (MIT)5 (MIT)5 (MIT)5 (Apache-2.0)1 (GPL-2.0+ / commercial)
Test-ability (deterministic sim, no sockets)3 (UDP loopback works; fake transport UNVERIFIED)5 (sans-IO, time injected as std::time::Instant)34 (sans-IO SdpSession; SIP core UNVERIFIED)331
Unweighted total (max 35)26302824272423

Reading the table: rsiprtp scores highest on features and fit with the brisvox invariants but lowest on the maintenance evidence that matters most for a dependency we would carry for years (single maintainer who states no commitment to contributions, 5 months since the last push, 261 downloads, interop shown only against Asterisk, the AI-bridge example is a stub). rsipstack has the strongest maintenance and adoption signal and the permissive license, at the cost of writing the media half. rvoip is the broadest and the only one with a published multi-PBX interop gate, but it is a 46-crate workspace with an API that is still in preview outside SIP and 79 open issues.

3. Recommendation

Recommendation: Use rsipstack (MIT) for SIP signaling and write the media half in brisvox (brisvox-transport feature sip): minimal SDP offer/answer for PCMU/PCMA + telephone-event, an RTP session task per call, RFC 4733 receive/send, a small jitter/reorder buffer, G.711 through the existing brisvox_audio::codec (ulaw_encode/decode, alaw_encode/decode). Reasons: (1) signaling (transactions, dialogs, digest, TLS, retransmission timers) is the hard, interop-sensitive code and rsipstack is the most active and most adopted option (also the base of RustPBX); (2) the media half is small (12-byte RTP header, 160 G.711 bytes per 20 ms, 4-byte telephone-event payload) and is the hot path, where brisvox's own pacer, Clock, buffer reuse and CancellationToken rules matter and a library's media loop would get in the way; (3) tokio-native, MIT; (4) brisvox already owns the G.711 codec and resampler. Rejected: xphone (thread per call), PJSIP (GPL, FFI, global state), wavekat-sip (too young), ezk (no confirmed DTMF, OpenSSL in the media crate), rvoip (size, preview media layer).

Hedge (NOT BUILT): the draft proposed keeping the SIP stack behind a narrow internal trait (SipSignaling: register, accept invite, answer/reject, dial, hangup, in-dialog events) so rsiprtp (sans-IO, complete) or rvoip can replace rsipstack if the spike fails. The implementation did not build that trait: the spike worked, and inbound.rs, outbound.rs and call.rs use rsipstack types directly, so a stack swap would rewrite those three files (the media half, sdp.rs and rtp.rs, is stack-independent). Pin an exact rsipstack version (the 0.7.x line changed in 3 days; it is pinned with =0.7.3). Revisit if: rsipstack cannot be driven with injected time/sockets for deterministic tests, session timers are required by a target carrier and are painful to add, or an interop bug is unfixable.

Do-first spike (historical; superseded by the implementation, see section 9): build examples/sip_echo against rsipstack that answers an INVITE from a local sipp/Asterisk-in-docker/second brisvox UA, negotiates PCMU + telephone-event, echoes audio, sends BYE; read the real source for the UNVERIFIED items (session timers, fake transport, accept_with_public_contact, behaviour of 200-without-ACK, CANCEL race, forked responses).

4. Minimal integration plan

Architecture follows docs/ARCHITECTURE.md Transport: a protocol adapter that bridges SIP/RTP to call_pair() channels. Transport::accept(&mut self, cancel) -> Result<Option<CallLink>> stays the inbound API; outbound needs one extra method (see below). Wire events used: WireEvent::{Started, Audio, Dtmf, MarkAck, Lead, Fault, Hangup} and WireOut::{Audio, Clear, Mark, Hangup} (crates/brisvox-transport/src/call.rs, read 2026-10-08, re-checked in the fact-check).

  1. One SIP UA endpoint per server (SipServer). Owns the UDP socket (and optional TCP/TLS listeners), the rsipstack Endpoint and the DialogLayer, one accept task (tracked by a TaskTracker), and the registration tasks for REGISTER-based accounts (not for Twilio trunks, see interop). Config: bind addr, public_ip/advertised host (SDP c= and Contact/Via), RTP port range, allowed source CIDRs, digest credentials for inbound auth, call limit, accept queue size, timeouts.
  2. Inbound INVITE -> CallLink. (a) Check source allowlist / digest (401/407). (b) Over the call limit or accept queue full -> 503 Service Unavailable (plus Retry-After) like the Twilio server's 503; bad SDP/no common codec -> 488. (c) 100 Trying, bind the per-call RTP socket, 180 Ringing optional, answer 200 OK with SDP (our c=/port, PCMU/PCMA in the offerer's preference order restricted to what we support, telephone-event with the offerer's dynamic payload type, a=ptime:20, sendrecv). (d) On ACK: call_pair(), send WireEvent::Started(CallInfo) (metadata: Call-ID, From/To, remote address, selected codec, x-* headers; CallInfo::SAMPLE_RATE_KEY = 8000 so the engine uses its 8 kHz telephony path), hand the CallLink to the accept queue (the slot is reserved when the INVITE arrives and permit.send fills it) (a closed accept side -> BYE, counted). Call state ends on BYE, CANCEL, ACK timeout (64*T1 = 32 s for a 200 without ACK per RFC 3261, UNVERIFIED here: not re-read), RTP silence timeout, or CallEnds::ended.
  3. One RTP session per call (rtp_session task). One tokio::net::UdpSocket per call from the configured even-port range (RTCP on port+1 or rtcp-mux; RTCP can start as receive-only SR/RR keepalive). Receive: parse (rtp-types MIT OR Apache-2.0, or hand-rolled 12-byte header), drop unknown SSRC/payload types, reorder with a small bounded buffer (suggest 2-3 packets, 40-60 ms; fixed first, adaptive later), conceal a gap with AudioFrame silence or an interpolated frame (policy TBD with the engine's PLC), then G.711 -> PCM16 with ulaw_decode/alaw_decode (selected by the negotiated payload type 0 or 8, RFC 3551) into 160-sample 8 kHz frames -> WireEvent::Audio (the engine resamples). Send: WireOut::Audio PCM16 8 kHz -> ulaw_encode/alaw_encode -> RTP: sequence +1, timestamp +160 per 20 ms, random initial sequence/timestamp/SSRC, marker bit on the first packet after a gap; pacing comes from the existing OutputPacer, so the RTP task sends when WireOut::Audio arrives (no second timer). Preallocate packet buffers (hot-path rule); never block the runtime thread.
  4. DTMF. Receive RFC 4733 telephone-event packets (payload: event, E bit, R bit, volume, duration; events 0-9 = digits, 10 = *, 11 = #, 12-15 = A-D per RFC 4733 Table 3). Emit one WireEvent::Dtmf per event, de-duplicated by RTP timestamp, on the first packet or on the end packet (policy choice); the end packet SHOULD be sent three times by the sender (RFC 4733), so dedupe. Also accept SIP INFO (application/dtmf-relay, UNVERIFIED format) as fallback. Send DTMF (outbound) as telephone-event packets. Telnyx says RFC 2833 is its default DTMF mode and typically payload type 101 (support article, re-read 2026-10-08); Twilio: RFC 2833 support not stated on the Twilio pages fetched, UNVERIFIED; always read the payload type from the SDP, never hard-code 101.
  5. Mapping hang-up. Remote BYE -> reply 200 OK, WireEvent::Hangup, cancel the RTP task, free the port. Engine WireOut::Hangup -> BYE (confirmed dialog) or CANCEL (pending outbound; rsipstack's InviteDialog::hangup() delegates to hangup_with_headers, which picks BYE/CANCEL by state for either role: corrected in section 9) with a transaction timeout, then cancel ended. Remote CANCEL before the 200 -> 487, never create a link.
  6. Barge-in / marks (no SIP equivalent). There is no wire-level clear or mark echo in RTP. WireOut::Clear only drops what the adapter itself still holds (the RTP send queue); audio already sent to the network plays out at the far end (carrier jitter buffer, unknown size: UNVERIFIED, measure on a live call). Synthesize WireEvent::MarkAck when the packets ahead of a WireOut::Mark have been sent (the lead is then the network/jitter-buffer delay, which brisvox cannot observe), and report WireEvent::Lead from an estimate (RTCP RR round trip and jitter fields can refine it later). Keep the lead small by sending close to real time (the pacer already does).
  7. Outbound call API (new). Not part of Transport::accept. Add SipServer::dial(DialRequest) -> Result<CallLink, DialError> where DialRequest { to: SipUri, from: SipUri/caller id, auth: Credential or trunk, extra_headers, offer: codecs, ring_timeout, cancel: CancellationToken }. It resolves the target (RFC 3263 SRV/NAPTR where the stack supports it; else FQDN/IP), sends INVITE with SDP, handles 401/407 digest retries, 18x (optionally early media), 200 + ACK, and returns the link after ACK with WireEvent::Started (direction = outbound). Failures return the SIP status (486, 603, 408...); cancel before answer sends CANCEL. Honour the same call limit. Idempotency: the caller supplies a dial key so a retried request never creates two calls (engine rule for side-effect tools).
  8. Timeouts, cancellation, limits. Every await has a timeout (INVITE ring timeout, ACK wait, BYE transaction, RTP silence e.g. 30-60 s without packets -> Fault then hang up; session-timer expiry if negotiated). Each call = child tasks of the server's TaskTracker, all cancellable via CancellationToken; a panic or error in one call must not affect others (isolate per-call task; signaling state per dialog). Bounded channels as in call_pair().
  9. Session timers and keepalive. rsipstack has reinvite/update; a session-timer layer (RFC 4028 Session-Expires/Min-SE, refresher role) would be ours (rsipstack has none: confirmed in the 0.7.3 source, section 9; brisvox does not offer timer, so it does not implement them). Required only if a target demands it (not found in the Twilio/Telnyx pages fetched). OPTIONS pings for NAT binding keepalive (Twilio: no more than one per 10-15 s).
  10. Testing. (a) Unit: SDP offer/answer, RTP header/telephone-event codec, jitter buffer, with vectors (RFC 3551 payload types, RFC 4733 events). (b) Sim: scripted SIP peer over UDP loopback (second endpoint in the test) driving INVITE/ACK/BYE/CANCEL, 401 challenge, 503, 488, glare, and an RTP peer sending G.711 + telephone-event with loss/reorder. (c) Live (needs infrastructure): Asterisk (docker) and a Twilio or Telnyx trunk, BRISVOX_LIVE_TESTS=1. SIPp as test peer: UNVERIFIED (not researched), rvoip claims SIPp in its own gate.

5. Interop targets

5.1 Twilio Elastic SIP Trunking (docs fetched 2026-10-08)

  • Twilio is a trunk provider; brisvox plays the customer SBC/PBX. Do not confuse with Media Streams (twilio.md): SIP trunking carries RTP, not a WebSocket.
  • Origination (PSTN -> brisvox): configure an origination SIP URI (IP or FQDN; the user part is the dialed number; Twilio adds a Diversion header carrying the dialed Twilio number). UDP default; ;transport=tcp or ;transport=tls (TLS default port 5061, other port allowed); the sips scheme is NOT supported; ;edge=<location> selects the egress edge (e.g. edge=dublin; missing/wrong value -> the edge where the PSTN call arrives); up to ten origination URIs with priority (0-65535, lowest first) and weight (1-65535).
  • Termination (brisvox -> PSTN): termination URI like {name}.pstn.twilio.com (localized: {name}.pstn.<location>.twilio.com; locations: Ashburn, Umatilla, Dublin, Frankfurt, Singapore, Tokyo, Sao Paulo, Sydney); IP ACL and/or credential list (at least one required); numbers in E.164 with a leading + or Twilio returns 400 Bad Request; caller ID must be a Twilio number or verified caller ID. If an INVITE gets no response, Twilio recommends moving to the next available IP after 4 seconds (applies to us as UAC).
  • Behaviour requirements: do not register the trunk; send Max-Forwards: 70; OPTIONS pings optional, at most one per 10-15 s; NAT bindings kept open with OPTIONS over UDP or OPTIONS/CRLF keepalives over TCP/TLS (the OPTIONS/CRLF keepalive sentence was not re-confirmed in the fact-check: UNVERIFIED); symmetric RTP is considered less secure, Twilio recommends leaving it disabled (enable only to traverse a non-SIP-aware NAT) so Twilio sends media only to the address in the SDP (so our advertised SDP address must be correct, see NAT).
  • Signaling: ports 5060 (UDP/TCP) and 5061 (TLS); gateway ranges (IP page): Virginia 54.172.60.0/30, Oregon 54.244.51.0/30, Ireland 54.171.127.192/30, Frankfurt 35.156.191.128/30, Tokyo 54.65.63.192/30, Singapore 54.169.127.128/30, Sydney 54.252.254.64/30, Sao Paulo 177.71.206.192/30. Twilio says not all host active gateways at a time (accept signaling from all listed IPs) and recommends using the configured FQDN, not the IPs.
  • Media: UDP, to 168.86.128.0/18, destination ports 10,000-60,000; source IP and source port ANY (confirmed on the IP page 2026-10-08). Bandwidth planning: peak = simultaneous calls x 100 kbps (G.711 plus overhead).
  • Codecs (codecs page): PCMU and PCMA by default (GA); G.729, Opus, AMR-NB are Limited Availability and "not supported end to end". Origination offer order: PCMU then PCMA (without LA codecs; with LA codecs enabled: Opus, PCMU, PCMA, G.729, AMR-NB). Termination: Twilio matches the first supported codec in our SDP offer, so offer PCMU first.
  • Secure trunking: TLS 1.2+ (1.0/1.1 dropped); SRTP origination accepts only AES_CM_128_HMAC_SHA1_80, termination accepts _80 and _32; MKI not supported; with secure trunking enabled unencrypted calls are rejected; trust DigiCert Global Root CA, G2, G3. Source note: these come from Twilio search-result summaries (the Twilio secure-trunking URL tried in the fact-check returned 404; the Secure Media page covers Programmable Voice SIP Domains and confirms _80/_32, MKI unsupported, unencrypted rejected, but not trunking, TLS versions or DigiCert): treat the trunking-specific parts as UNVERIFIED against a trunking page. => SRTP (SDES) would be needed for secure trunks; not in the first scope, rsiprtp and xphone have SDES, rsipstack/rustrtc side to be evaluated.
  • Telephone-event on Twilio: not stated on the pages fetched. UNVERIFIED.

5.2 Telnyx SIP (docs fetched 2026-10-08)

  • Signaling (sip.telnyx.com page): US sip.telnyx.com 192.76.120.10 and 64.16.250.10; Europe sip.telnyx.eu 185.246.41.140/.141; Australia sip.telnyx.com.au 103.115.244.145/.146; Canada sip.telnyx.ca 192.76.120.31 and 64.16.250.13; Middle East sip.telnyx.me 185.246.42.128/.129; Asia (beta) sip.telnyx.asia 103.115.244.158/.159. Ports UDP/TCP 5060, TLS 5061, WSS 7443. (A search result showed 192.76.120.106 for US and a different /27 media subnet; the live sip.telnyx.com page is authoritative: re-check before writing firewall rules.)
  • Media: UDP 16384-32768; subnets 36.255.198.128/25, 50.114.136.128/25, 50.114.144.0/21, 64.16.226.0/24 to 64.16.230.0/24, 64.16.248.0/24, 64.16.249.0/24, 103.115.244.128/25, 103.115.247.0/24, 185.246.41.128/25, 185.246.42.128/28. ptime 20 ms. Codecs: G.711U/A, G.729, G.722, Opus, AMR-WB (plus video H.264/VP8). Whether PCMU/PCMA is the default offer order: UNVERIFIED (the Telnyx audio-and-codecs support article shows an example inbound offer listing G.722, PCMU, PCMA, G.729, telephone-event but does not say what is enabled by default; the claim that G.722 is enabled by default for LiveKit connections is UNVERIFIED; restrict our SDP to PCMU/PCMA and check the connection's codec list).
  • Auth: credentials (REGISTER-capable, dynamic IPs), IP, FQDN, IP+X-Telnyx-Token, IP+tech prefix, IP+ P-Charge-Info; error text examples: RG1 (480) not registered on a credential connection, R47 (403) multiple Contact addresses in one REGISTER, B3 (488) SRTP mismatch (all confirmed 2026-10-08).
  • TLS 1.2 and 1.3 (1.0/1.1 not accepted, sip.telnyx.com); SRTP preferred cipher on the B2BUA AEAD_AES_256_GCM_8 (first in the default offer), also AES_CM_128_HMAC_SHA1_80 (sip.telnyx.com); the claim that a second Telnyx page disagrees on the preferred cipher was not reproduced in the fact-check (UNVERIFIED which is current).
  • DTMF: RFC 2833 (default, "recommended"), SIP INFO, inband, selectable per connection; typical RFC 2833 payload type 101.
  • Media IP: the earlier claim that Telnyx "auto-detects your media IP address by monitoring early RTP packets" and adjusts if the source differs from the SDP ("Disable RTP Auto Adjust" / "Accept any RTP packets" expert options) could NOT be confirmed on any Telnyx page in the fact-check (only a community thread mentions the portal toggles "RTP Auto Adjust" and "accept any RTP packets"): UNVERIFIED. STUN stun.telnyx.com:3478 (UDP), TURN turn.telnyx.com:3478 (UDP/TCP; credentials from Telnyx support).

5.3 Asterisk and FreeSWITCH

  • Asterisk (PJSIP NAT doc, fetched 2026-10-08): local_net, external_media_address (public IP written into the SDP media address for peers outside local_net), external_signaling_address; endpoint options direct_media=no, rtp_symmetric, force_rport, rewrite_contact for phones behind NAT. Expect Asterisk to latch symmetric RTP: brisvox should send RTP from the same port it advertises and listens on (RFC 4961). rsiprtp's basic_call example runs against Asterisk; rvoip's release gate lists it.
  • FreeSWITCH: NAT parameters (ext-rtp-ip, ext-sip-ip, etc.) NOT confirmed in the docs fetched (the SignalWire page fetched was only an index): UNVERIFIED. rvoip lists FreeSWITCH in its gate; xphone claims it. Treat as a second live-test target.

5.4 NAT and deployment considerations

  • Brisvox is the server: give it a public IP (or a static NAT) and advertise it in SDP c=, o=, Contact and Via (rsipstack accept_with_public_contact; example --external-ip). Behind Kubernetes/Docker bridge NAT the advertised address must be configured, not auto-detected.
  • RTP at scale: one UDP socket per call, so hundreds of calls = hundreds of open ports in a range that must be forwarded/published; prefer host networking or a wide published port range. Per-call sockets also mean ulimit -n sizing. (Design consideration; scale numbers untested.)
  • Symmetric RTP (RFC 4961, BCP 131: use one UDP port pair for sending and receiving; required for NATs without ALG and for SBCs): send from the same socket we receive on. Latching onto the first source address (rustrtc enable_latching is such a feature) helps with NATed peers but weakens security; default: accept RTP only from the SDP address, optionally latch within a short window for trusted peers (implemented as SipConfig::rtp_latch + rtp_latch_window: only a packet that passed the payload-type, SSRC and sequence checks moves the destination, only within the window after the call is up or a re-INVITE, and only once).
  • SIP over UDP behind NAT: rport (RFC 3581) so responses return to the sender's address and port; TCP/TLS avoid that class of problem (connection reuse) at the price of head-of-line blocking.
  • Firewall: allow carrier signaling ranges on 5060/5061 and media ranges (Twilio 168.86.128.0/18 UDP 10000-60000; Telnyx list above) to our RTP range. Prefer FQDN/SRV over hard-coded IPs.
  • SIP ALGs on routers/firewalls corrupt SDP: disable (general knowledge: UNVERIFIED against a doc).
  • Latency note for docs/LATENCY.md: SIP/RTP paths carry the carrier's RTP packetization (20 ms) and jitter buffer; the transport adds our reorder buffer depth (40-60 ms suggested) plus 20 ms packetization on both legs; measure on a live trunk.

6. Protocol facts used (RFCs, fetched 2026-10-08)

  • RFC 3551: PCMU = static payload type 0, PCMA = 8, clock 8000 Hz, 1 channel; default packetization 20 ms (160 octets); RTP timestamp advances 160 per 20 ms (derived from the RFC's rules, not a verbatim sentence).
  • RFC 4733 (obsoletes RFC 2833): media type audio/telephone-event, no static payload type (assigned dynamically in SDP, e.g. a=rtpmap:100 telephone-event/8000); default clock 8000 Hz; event codes: 0-9 digits, 10 *, 11 #, 12-15 A-D; payload fields event (8 bit), E (end) bit, R bit, volume (0 to -63 dBm0), duration (16 bit, timestamp units); the final packet of an event SHOULD be sent three times (a SHOULD, not a MUST).
  • RFC 3581: rport Via parameter asks the server to answer to the actual source IP and port.
  • RFC 4961 / BCP 131: symmetric RTP and RTCP (see above).
  • RFC 3261, 3264, 4028, 3262, 3515 etc.: not re-read; behaviour described from the stacks' own docs only.

7. Turkish support

Not applicable to the transport (language-agnostic G.711 audio). Telephony quality note as in twilio.md: 8 kHz narrowband G.711 is typically harder for STT; wideband codecs (G.722 at Telnyx, Opus) could help but both carriers' default/guaranteed codecs are PCMU/PCMA, and rsipstack-based media would need G.722/Opus added by us. Turkish PSTN numbers/trunks (e.g. a local carrier) were not researched (UNVERIFIED).

8. Gotchas (research-time; implementation gotchas get appended later)

  • rsiprtp ai_bridge.rs looks like the AI-agent integration but is a simulation with no SIP/RTP; do not treat it as proof the stack runs a real call to an AI.
  • GitHub license fields can differ from crates.io (rsiprtp: GitHub shows Apache-2.0 only, crates.io says MIT OR Apache-2.0; rsip: NOASSERTION). Run cargo deny on the chosen set before merging.
  • rsipstack versions move daily at the moment: Cargo.lock plus exact pin, and re-read the changelog on every bump.
  • Twilio does not accept sips:; use sip: with ;transport=tls.
  • Telnyx and Twilio media IPs/ports differ from our own advertised RTP range; do not confuse the two lists in firewall rules.
  • Dialog deprecations in rsipstack (ClientInviteDialog, ServerInviteDialog -> InviteDialog): code against InviteDialog.

9. Implementation notes (read from rsipstack 0.7.3 source and measured with local tests)

Module brisvox_transport::sip (cargo feature sip, off by default). Stack: rsipstack 0.7.3 pinned with default-features = false, features = ["platform-tokio", "rustls"] (rustls with the ring provider is enabled for the TLS connection types; still no WebSocket or SRV lookup; MIT, LICENSE file read in the crate; cargo deny check passes). Media half (SDP, RTP, reorder buffer, RFC 4733) is ours.

Resolved from the source (previously UNVERIFIED above):

  • rsipstack has its own SIP parser now (no rsip dependency); the only extra dependencies with platform-tokio are tokio, tokio-util, parking_lot, socket2, if-addrs and rand 0.10.
  • Server side: the endpoint hands every new server transaction to incoming_transactions(); an INVITE without To tag becomes a dialog with DialogLayer::get_or_create_server_invite, and InviteDialog::handle(&mut tx) must run on its own task for the INVITE transaction (it takes the ACK or the CANCEL). CANCEL does not appear as a separate transaction: the dialog turns it into Terminated(UacCancel) and answers 487. In-dialog requests need DialogLayer::match_dialog + handle.
  • BYE, INFO, UPDATE, re-INVITE, OPTIONS, REFER, NOTIFY and MESSAGE inside a dialog reach the application as DialogState events carrying a TransactionHandle; the application must answer within 64*T1 (32 s) or the stack answers 501. Brisvox answers INFO (DTMF) and re-INVITE/UPDATE (SDP) and refuses the rest with 501.
  • Client side: DialogLayer::do_invite sends the INVITE, answers a 401/407 once when a Credential is set (otherwise the challenge is returned as the final response), ACKs a 2xx itself and returns the dialog. Dropping the future abandons the call: CANCEL (also from the Calling state, once a provisional arrives), and a 2xx that crosses the CANCEL is ACKed and ended with BYE. The dialog is never removed from the layer automatically: the call task calls remove_dialog.
  • Session timers (RFC 4028) are not implemented by rsipstack; Brisvox does not offer timer, so peers do not require it. Deterministic simulation of SIP signaling is not possible: transaction timers run on tokio time.
  • TransportLayer::set_whitelist drops datagrams and TCP connections from other source IPs before parsing.
  • rsipstack logs whole SIP messages at info level (udp received / udp sent). They contain digest responses and caller data, never a password; run production with rsipstack=warn.
  • A CRLF CRLF datagram (RFC 5626 keepalive) is answered with CRLF by the stack.
  • On Windows the next receive after a send to a closed RTP port fails with a connection reset (ICMP). The call task ignores ConnectionReset/ConnectionRefused on its RTP socket.

Behaviour of the stack that the review of the SIP transport relied on (0.7.3 source):

  • Dropping a server Transaction after its final answer hands it to the endpoint cache of finished transactions; the cache absorbs the ACK and answers a retransmitted INVITE with the same answer until its cleanup timer fires. A refusal therefore does not have to wait for the ACK (it used to, and held a handler slot for the Timer K wait).
  • A request that cannot make a transaction key (no From tag, no Via) is dropped by the endpoint with a warning and no answer. A CANCEL without a matching transaction is answered 481 by the endpoint itself.
  • DialogStateReceiver is an unbounded channel (UnboundedReceiver). Brisvox bounds it indirectly: each state is produced by a request that holds one of 512 handler slots until the call task answers it.
  • The stack reads Require only for 100rel; Brisvox answers any INVITE with another or unknown required tag with 420 and an Unsupported header (RFC 3261 section 8.2.2.3).
  • TransportLayer::set_whitelist applies to everything received, so it also drops the answers of outbound calls from hosts outside allowed_sources.
  • Contact: a call set up over TCP gets ;transport=TCP in our Contact (inbound and outbound), because the peer sends its BYE and re-INVITE to the Contact (RFC 3261 sections 12.1.1 and 8.1.1.8).
  • Secrets: DialRequest::headers can carry an authentication token (X-Telnyx-Token). Its Debug shows header names only, but rsipstack logs whole INVITEs at info: the token appears in logs unless rsipstack=warn.
  • A held call (the peer re-INVITEs with a=sendonly, or with c=0.0.0.0) may send no RTP at all; the RTP silence timeout is suspended while the call has no media address or the peer does not listen, and restarts on resume. A re-INVITE that moves, holds or resumes the media starts the receive stream over (new SSRC, new sequence numbers are heard at once).
  • A re-INVITE without a body (delayed offer; session refresh, 3PCC, hold done by a controller) is answered 200 with our current SDP (o= version bumped); the peer's answer comes in the ACK. The call task remembers the CSeq of that INVITE and, when the dialog confirms with an ACK of that CSeq (InviteDialog::last_remote_ack), parses the body like an offer and applies it (new media address, hold/resume, receive restart, reopened latch window). An ACK without a body leaves the stream as it was; an unusable one is logged and ignored. An initial INVITE without an offer is still refused with 488.
  • Bind address and public_ip (found by the review tests): rsipstack reports the address of a socket bound to 0.0.0.0 as the address of the first non-loopback interface (SipConnection::resolve_bind_address). Brisvox therefore always (re)creates its UDP and TCP sockets on the configured bind_addr with the chosen port and uses the reported address only for the port and the comparison with public_ip. Before this a server bound to 0.0.0.0 with a public_ip listened only on the first interface (not on loopback, not on a second NIC). tests/sip.rs runs a 0.0.0.0 bind advertised as 127.0.0.2 and checks c=, o=, Contact and a BYE sent to the Contact.
  • Digest qop: for a challenge with qop="auth" rsipstack answers qop="auth" (quoted), nc=00000001 and a cnonce; RFC 2617 writes the qop value as a bare token. The hash is verified in tests/sip.rs; whether a carrier accepts the quoted form is UNVERIFIED (only a real carrier shows it). The hash function itself is pinned by known answers computed with an independent MD5 implementation (src/sip/auth.rs unit test).
  • Marks: the session cuts a mark on a clear (MarkCleared) and ignores its later echo, so the engine never sees MarkPlayed for cut audio whether the adapter drops or echoes a mark on Clear. The SIP adapter drops them. The core pacer releases audio in real time; a long single frame queues all its packets in the adapter at once, which is why the mark tests use one 1 s frame.
  • RTP ports: only even ports are used (odd ones stay free for RTCP). rtp_ports must contain at least one even port (validate rejects (20001, 20001)); the rotation cursor wraps inside the even ports first..=last where last is high rounded down to even, so an odd high is never bound.
  • Accept queue: an inbound INVITE reserves its accept-queue slot (Sender::try_reserve_owned) before anything is sent, and holds it until the call is handed over, so a burst larger than accept_queue gets 503 instead of 200 followed by a BYE.
  • Bot audio that is not 8 kHz is dropped and counted in dropped_audio_frames; a warning is logged once per call. No Fault event is sent to the engine (the earlier text claiming one was wrong).
  • Barge-in: the call task always reads the engine channel (a clear must not wait behind a backlog); a frame that does not fit the 500-packet send queue is dropped and counted, which the core pacer (real-time release) makes a rare case. (Observed in the tests, not read from the pacer source:) the core pacer passes a long frame on whole but did not release the next frame until the first one had played out, so a long backlog in the send queue comes from one long frame.

Design decisions: one task per call owning RTP socket, reorder buffer, send queue and dialog events; one re-armed timer for send slot / reorder deadline / RTP silence; packets sent one per 20 ms on an absolute schedule (never a burst); Clear drops queued packets and marks; marks resolve when the packets before them were sent; no Lead is reported. Outbound DTMF is SipHandle::send_dtmf(call_id, ..) because TransportCommand has no DTMF command yet. Not done: RTCP, REFER, delayed-offer INVITE, early media, SRV. (SIPS and SDES-SRTP: docs/TELEPHONY.md, "SIP over TLS and SRTP"; the Twilio secure-trunking facts were fetched again on 2026-10-13 from https://www.twilio.com/docs/sip-trunking and match the notes above: TLS 1.2+, port 5061, _80 only on origination, _80/_32 on termination, no MKI, sips: treated as sip: over TLS.) UNVERIFIED still: INFO DTMF body formats against a real carrier, Twilio/Telnyx telephone-event behaviour, carrier jitter-buffer size (barge-in tail), interop beyond the scripted peers of tests/sip.rs.

UNVERIFIED (summary list)

  1. rsipstack: MSRV; embassy target limits; ability to inject a fake transport or time for deterministic tests (resolved the other way in section 9: none, tests use UDP loopback and the real clock). Resolved by the implementation: session timers, RFC 4733, jitter buffer and SDP negotiation are not in rsipstack (Brisvox has its own media half); CANCEL before the answer and a 200 that is never acknowledged are covered by tests/sip.rs.
  2. rsiprtp: exact RTP/transport API for driving real sockets; real-world interop beyond Asterisk; any unsafe. (now type resolved: std::time::Instant; CANCEL is covered by its integration tests.)
  3. rvoip: sans-IO status, audio frame fields, compile weight, session timers, MSRV claims beyond README badge, endpoint_local_call example.
  4. ezk: DTMF, G.711, jitter buffer, session timers, UDP/TCP transport list, MSRV, maturity statement (README fetch partial); ezk-rtc maturity beyond 0.0.1.
  5. wavekat-sip: tokio dependency, whether it uses rsipstack, interop tested, NAT support.
  6. xphone: runtime used by server mode, SDES cipher suite detail; "used in production alongside xbridge" is a self-claim.
  7. sip-core (xmppjingle/sipr) capabilities.
  8. Twilio: telephone-event (RFC 2833/4733) in SDP; OPTIONS/CRLF keepalive wording; trunking-specific secure-trunking facts (TLS versions, DigiCert roots, SRTP suites per direction: search-summary sourced); a numeric carrier jitter-buffer size; barge-in tail size.
  9. Telnyx: default codec order and G.722 default; the "auto-detects media IP"/RTP Auto Adjust behaviour; whether a second page names a different preferred SRTP cipher; media subnet discrepancy between cached copies; exact signaling IP for US (.10 on the fetched page vs .106 in a search snippet).
  10. FreeSWITCH NAT parameters; SIPp usage; SIP INFO DTMF content type; 64*T1 ACK timeout (RFC 3261 not re-read); SIP ALG behaviour.
  11. Production readiness of RustPBX (rsipstack's flagship user); THE-PJPROJECT status.
  12. Third-party scale numbers (calls per core, memory per call) for any stack: none fetched.

Sources (all fetched 2026-10-08)

  • https://crates.io/api/v1/crates/rsipstack , /ezk-sip-ua , /ezk-sip-core , /ezk-rtc , /rvoip , /rvoip-sip , /rsip , /rsiprtp , /xphone , /wavekat-sip , /rtp-engine , /rtp-types , /rustrtc , /msf-rtp , /sip-core , /pjproject-sys
  • https://api.github.com/repos/restsend/rsipstack , /kbalt/ezk , /eisenzopf/rvoip , /0x4D44/rsiprtp , /x-phone/xphone-rust , /wavekat/wavekat-sip , /vasilakisfil/rsip , /xmppjingle/sipr , /restsend/rustrtc
  • https://github.com/restsend/rsipstack , https://docs.rs/crate/rsipstack/0.7.3/source/README.md , https://docs.rs/rsipstack/latest/rsipstack/dialog/ (+ client_dialog::ClientInviteDialog, server_dialog::ServerInviteDialog, invite_dialog::InviteDialog) , https://github.com/restsend/rsipstack/tree/main/examples/client , https://raw.githubusercontent.com/restsend/rsipstack/main/examples/client/main.rs , https://raw.githubusercontent.com/restsend/rustpbx/main/Cargo.toml , https://github.com/restsend/rustrtc
  • https://github.com/0x4D44/rsiprtp , https://docs.rs/crate/rsiprtp/latest , https://docs.rs/rsiprtp/latest/rsiprtp/ , https://docs.rs/rsiprtp/latest/rsiprtp/session/struct.CallManager.html , https://raw.githubusercontent.com/0x4D44/rsiprtp/main/crates/rsiprtp/Cargo.toml , .../examples/ai_bridge.rs , https://raw.githubusercontent.com/0x4D44/rsiprtp/main/tests.md , .../ARCHITECTURE.md
  • https://github.com/eisenzopf/rvoip , https://raw.githubusercontent.com/eisenzopf/rvoip/main/README.md , https://docs.rs/rvoip-sip/latest/rvoip_sip/
  • https://github.com/kbalt/ezk , https://docs.rs/ezk-sip-ua/latest/ezk_sip_ua/ , https://docs.rs/ezk-sip-core/latest/ezk_sip_core/ , https://docs.rs/crate/ezk-sip-core/latest/features , https://docs.rs/ezk-rtc/latest/ezk_rtc/
  • https://github.com/x-phone/xphone-rust , https://github.com/wavekat/wavekat-sip , https://raw.githubusercontent.com/wavekat/wavekat-sip/main/README.md
  • https://docs.pjsip.org/en/latest/overview/license_pjsip.html
  • Twilio: https://www.twilio.com/docs/sip-trunking , https://www.twilio.com/docs/sip-trunking/ip-addresses , https://www.twilio.com/docs/sip-trunking/codecs , https://www.twilio.com/docs/voice/api/secure-media (+ web-search summaries of the Twilio secure-trunking docs)
  • Telnyx: https://sip.telnyx.com/ , https://developers.telnyx.com/public/llms/calling/sip-trunking-full.txt , https://support.telnyx.com/en/articles/1130710-what-is-dtmf-and-how-to-configure-it-on-telnyx , https://support.telnyx.com/en/articles/3192298-audio-and-codecs (plus web-search summaries of the Telnyx docs index)
  • Asterisk: https://docs.asterisk.org/Configuration/Channel-Drivers/SIP/Configuring-res_pjsip/Configuring-res_pjsip-to-work-through-NAT/
  • RFCs: https://www.rfc-editor.org/rfc/rfc3551.html , /rfc4733.html , /rfc4961.html , /rfc3581.html
  • Repo files read: crates/brisvox-transport/src/call.rs, transport.rs, crates/brisvox-audio/src/codec.rs (function names ulaw_encode/decode, alaw_encode/decode), docs/ARCHITECTURE.md (Transport).

Fact-checked 2026-10-08: about 130 claims checked, 13 corrected (plus 6 downgraded to UNVERIFIED):

  • rsiprtp now is std::time::Instant (was UNVERIFIED); rsiprtp CANCEL is covered by its integration tests (was "not named"); the "differential-tested vs rsip / RFC 4475 / fuzz" claim is supported by tests.md, not by the README; rsiprtp terminate_call only acts on Established calls; rsiprtp ICE: TURN relay, trickle and ICE restart unsupported.
  • rvoip WebSocket: plain SIP-over-WebSocket has "bounded evidence", only WSS is developer preview (was "WS developer preview").
  • xphone: wording is "used in production alongside xbridge", not "powers xbridge"; Opus/G.729 are optional feature flags; interop list split into trunks vs PBXes.
  • rsipstack: hangup() is BYE-only and a no-op on UAS dialogs; BYE/CANCEL selection is in hangup_with_headers; the dialog module index page shows no deprecation (struct pages do); crate first published 2025-01-10; embassy "UDP + TLS client only" is not what the README says (downgraded to UNVERIFIED).
  • RFC 4733: the triple send of the end packet is a SHOULD.
  • Twilio: media source IP/port ANY is confirmed on the IP page (was UNVERIFIED); the "3-4 IPs" phrasing is not on the page (it says next available IP after 4 s); edge= fallback behaviour added; secure-trunking details (TLS 1.2+, DigiCert, per-direction SRTP suites) rest on search summaries, since the fetched Secure Media page covers Programmable Voice, not trunking (downgraded); OPTIONS/CRLF keepalive over TCP/TLS not re-confirmed (downgraded).
  • Telnyx: the "auto-detects media IP / Disable RTP Auto Adjust" quote could not be found on any Telnyx page (downgraded to UNVERIFIED); TLS is 1.2 and 1.3; G.722-default claim unconfirmed (support article only shows G.722 first in an example offer); the "two pages disagree on cipher" claim not reproduced.
  • ezk README maturity statement: fetch was partial, so "none" is UNVERIFIED.
  • Confirmed unchanged: all crates.io versions, dates, licenses and download counts; GitHub stars/forks/issues/push dates; Twilio and Telnyx IP ranges, ports, codecs; Asterisk NAT options; RFC 3551/3581/4961 facts; PJSIP license; brisvox WireEvent/WireOut/codec function names.

Telephony call control (DTMF, transfer, voicemail/AMD) — Twilio and Telnyx

Scope: DTMF in/out, warm/cold transfer (tool-callable), voicemail / answering-machine detection strategy and leave-message flow. Related: docs/providers/twilio.md (Media Streams wire protocol, request validation), docs/providers/telnyx.md (Telnyx media streaming wire protocol), docs/providers/g711.md (codec). Researched: 2026-10-08. Every fact below was read from the pages listed under Sources on that date. Anything not confirmed on a fetched page is marked UNVERIFIED. Statements labelled "Proposal" or "Brisvox design" are engineering choices, not documentation claims. This is call control (REST/webhooks next to the media WebSocket), not an STT/LLM/TTS provider.

Scope boundary (from twilio.md): brisvox core is the WebSocket SERVER of the media stream and does not serve TwiML or webhooks. Everything on this page that is REST (update call, participants, send_dtmf, transfer) is an OUTBOUND HTTPS call that a brisvox tool/adapter makes (reqwest 0.13.5, already in the workspace Cargo.toml); everything that is a callback (status callbacks, AMD results, conference events, Telnyx webhooks) is served by the user's HTTP app or by an optional brisvox webhook module (not decided here).

0. Capability matrix (what the docs say works)

CapabilityTwilio (Media Streams, <Connect><Stream>)Telnyx (Call Control + media streaming)
DTMF from caller to botYes: dtmf WebSocket event, bidirectional streams only, inbound direction onlyYes: dtmf WebSocket frame (dtmf.digit, occurred_at); webhook call.dtmf.received is documented as a Gather result
DTMF from bot to far end, over the streamNO (documented: not supported outbound)NO (only media/mark/clear are accepted; no DTMF frame documented)
DTMF from bot to far end, other routeREST update call with TwiML <Play digits="..."> (replaces the TwiML, see 2.2)REST POST .../actions/send_dtmf (no webhooks; interplay with an active stream not documented)
Cold transferREST update call with <Dial> TwiML (or <Connect action> fallthrough); <Refer> only for SIP legsPOST .../actions/transfer (documented description: original leg stays active if the transfer fails)
Warm transferConference + Participants API, or <Dial><Number url=...> whisper screeningConference commands, or Dial with link_to + Bridge; refer for SIP REFER
AMDCalls API / Participants API / <Dial><Number> / <Dial><Sip>; AnsweredBy incl. machine_end_beepanswering_machine_detection on Dial/Transfer: detect, detect_beep, detect_words, greeting_end, premium
AMD price$0.0075 per call$0.002 per call (standard), $0.0065 per call (premium)
Idempotency key on commandsnone documented (UNVERIFIED)command_id on every command

1. Twilio

1.1 REST basics (auth, encoding)

  • Docs: https://www.twilio.com/docs/usage/requests-to-twilio (fetched 2026-10-08).
  • Auth: HTTP Basic. "use the Account SID as the HTTP Basic authentication username and the Auth Token as the password" (page wording). Alternative: API key SID as username and API key secret as password; the page says to prefer API keys for applications unless a reference says to use Account SID + Auth Token. Docs' own curl examples use -u $TWILIO_API_KEY:$TWILIO_API_SECRET. Brisvox: support both; never log the token/secret (same rule as twilio.md).
  • Base URL: https://api.twilio.com/2010-04-01/ (all examples; the page does not state it as a standalone rule); resource URLs end in .json.
  • POST bodies: application/x-www-form-urlencoded or multipart/form-data ("Twilio APIs expect ..."). Repeated parameters (e.g. several StatusCallbackEvent): the Call create page says "Send multiple values as separate parameters" (page wording: "use a separate parameter for each").
  • Rate limits / concurrency limits / error body format: NOT covered on the fetched pages (UNVERIFIED). Error bodies in samples have error_code and error_message fields only.
  • Idempotency: no idempotency header or key is documented for Calls or Participants (UNVERIFIED). Brisvox must provide at-most-once itself (engine rule "SideEffect tools never run twice"); after a timeout, GET the call before any retry.

1.2 Call resource: create, update, fetch

Docs: https://www.twilio.com/docs/voice/api/call-resource (and .md), https://www.twilio.com/docs/voice/tutorials/how-to-modify-calls-in-progress (fetched 2026-10-08).

  • Create: POST https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Calls.json. Required To, From. Exactly the documented example:
    curl -X POST "https://api.twilio.com/2010-04-01/Accounts/$TWILIO_ACCOUNT_SID/Calls.json" \
    --data-urlencode "Url=http://demo.twilio.com/docs/voice.xml" \
    --data-urlencode "To=+15558675310" \
    --data-urlencode "From=+15017122661" \
    -u $TWILIO_API_KEY:$TWILIO_API_SECRET
    
    Create parameters relevant here (page table): Url (absolute URL returning TwiML), Twiml ("Max 4000 characters."; page wording: "If both twiml and url are provided then twiml parameter will be ignored", i.e. Url wins on Create), ApplicationSid, Method (GET|POST, default POST), FallbackUrl/FallbackMethod, StatusCallback/StatusCallbackMethod (default POST; "Without events, only completed is sent"), StatusCallbackEvent (initiated, ringing, answered, completed; default completed), Timeout (default 60, max 600; a 5-second buffer may be added), TimeLimit, SendDigits (up to 32 characters; page wording: "If both SendDigits and MachineDetection parameters are provided, then MachineDetection will be ignored"), Record etc., and the AMD set in 1.7. Dial-out result fields: sid (CA...), status, direction, answered_by, parent_call_sid, ...
  • Update (redirect / hang up a LIVE call): POST https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Calls/{Sid}.json (Sid = 34 chars, starts with CA). Page description (paraphrase; the exact earlier-quoted sentence "Modifies an active call. You can redirect an in-progress call to new TwiML or terminate it." was NOT found verbatim, the page says "Updating a Call allows you to modify an active call" and that a POST to a Call instance lets you redirect an in-progress call or terminate it). Parameters (page table):
    ParamMeaning
    UrlAbsolute URL returning TwiML; called with Method when the call connects
    MethodGET or POST, default POST (ignored if application_sid present)
    TwimlTwiML used without fetching Url; mutually exclusive with Url (page wording: "Twiml and url parameters are mutually exclusive")
    Statuscanceled or completed
    FallbackUrl, FallbackMethodused on error fetching/executing the TwiML at Url
    StatusCallback, StatusCallbackMethodstatus updates; to change StatusCallback of a call "it is required to set the Url in the same statement"
    TimeLimitmax call duration in seconds
    The maximum length of Twiml on UPDATE is not stated (only the Create page says 4000 characters): UNVERIFIED, keep
    update TwiML under 4000 characters or use Url.
    Example of the documented shape (inline TwiML update; the docs show this as SDK code, the curl form below is derived
    from the Create example and the parameter table, not a verbatim sample):
    curl -X POST "https://api.twilio.com/2010-04-01/Accounts/$TWILIO_ACCOUNT_SID/Calls/$CALL_SID.json" \
    --data-urlencode "Twiml=<Response><Dial>+905551112233</Dial></Response>" \
    -u $TWILIO_ACCOUNT_SID:$TWILIO_AUTH_TOKEN
    
    Response: the updated Call resource (fields: account_sid, answered_by, api_version, caller_name, date_created, date_updated, direction, duration, end_time, forwarded_from, from, from_formatted, group_sid, parent_call_sid, phone_number_sid, price, price_unit, queue_time, sid, start_time, status, subresource_uris, to, to_formatted, trunk_sid, uri). status values: queued, ringing, in-progress, completed, busy, failed, no-answer, canceled. Error codes for updating a completed/unknown call: not listed on the page (UNVERIFIED). The adapter therefore does not rely on one status code when it ends a warm transfer's agent leg: after a 4xx other than 404 it reads the leg and treats a final status as "already over", and ends a queued or ringing leg with Status=canceled (the update table lists canceled or completed).
  • Semantics (tutorial page): the request "instructs Twilio to make an immediate change [to] the TwiML document that Twilio is using in a phone call". Not stated: what happens to the verb currently running (the page says nothing). Hang up: Status=completed ("To end a phone call by your choice, pass a completed status to a CallSid in progress"). The page also says Twilio ends a call on <Hangup> or when it runs out of TwiML documents to process.
  • Parent/child (call-resource page, paraphrase of the "modifying active calls" notes): redirecting an active call to another phone number makes Twilio create "an entirely new Call instance for that new phone number"; the original call is the parent call, and each additional dialed number is a child call, each with its own Call SID. A parent call executing a <Dial> "is considered in-progress by Twilio" even after redirection, so "you must use Status=completed to end it."
  • Fetch: GET https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Calls/{Sid}.json.

1.3 What a REST update does to a bidirectional Media Stream

This is the central unknown for DTMF and transfer on Twilio.

  • Documented: <Connect><Stream> creates the bidirectional stream; "Twilio doesn't execute subsequent TwiML instructions" and "Twilio executes the remaining TwiML instructions only after your server closes the WebSocket connection" (stream TwiML page). The overview page: "You can stop a bidirectional Media Stream by closing the WebSocket connection from your server or by ending the Call." The messages page says "the only way to stop a Stream is to end the call" (the contradiction is recorded in twilio.md). The stream TwiML page says "You can also update the call with TwiML instructions using the Update a Call resource" (page wording); it does not say what happens to the Stream in that case (our reading; no such sentence is quoted from the page). The general <Connect> page: "If there is no further verb, Twilio will end the phone call", and "If you do not provide an action URL, <Connect> will finish and Twilio will move on to the next TwiML verb in the document". <Connect action> text: "When the <Connect> verb ends, Twilio sends an HTTP request to this URL"; it does not list Stream-specific parameters.
  • NOT documented (UNVERIFIED, live-test item 1): whether a REST update with new TwiML closes the stream, sends a stop frame, closes the socket from Twilio's side, or in what order; whether a new <Connect><Stream> inside the update TwiML is allowed while the old stream is still ending (media-streams overview page: "For bidirectional Streams, you can have only one Stream per Call"; the <Stream> TwiML page only says a Stream name must be unique per Call).
  • Safe sequencing rules derived from the documented facts (Brisvox design):
    1. Never close the WebSocket first when a hand-off TwiML must follow, unless the original TwiML has a <Connect action>/next verb that does the hand-off: with no further verb the call ends.
    2. So: finish playing the farewell/hand-off audio (utterance-end mark played), call REST update, wait for HTTP 200, then keep the socket open and treat the next stop/close as expected (not a user hang-up) for a bounded time (e.g. 5 s), then close.
    3. Pre-planned documented alternative: original TwiML <Connect action="https://app/after-stream"><Stream .../></Connect>; brisvox closes the socket, Twilio requests the action URL (UNVERIFIED that closing a Stream triggers action), the user's app returns <Dial> TwiML chosen from state brisvox has stored (needs shared state keyed by CallSid). Rejected as the default: requires the user's HTTP app and extra state; REST update is self-contained.

1.4 DTMF

Inbound (caller presses keys): see twilio.md ("Protocol"): {"event":"dtmf","streamSid":..,"sequenceNumber":"5","dtmf":{"track":"inbound_track","digit":"1"}}, bidirectional streams only. Overview page (fetched 2026-10-08): "DTMF isn't supported with unidirectional streams." "DTMF is supported with bidirectional Media Streams only in the inbound direction, from Twilio toward your media server." Digit alphabet beyond one key and whether the tone audio is also present in the media payload: UNVERIFIED (the messages page does not say). No duration or timestamp is sent with a dtmf event.

Outbound (bot presses keys on the far end, e.g. IVR navigation):

  • Over the stream: NOT supported (same overview page: "Sending DTMF outbound from your media server toward Twilio is not supported"; the server may only send media, mark, clear).
  • TwiML <Play digits="..."> (https://www.twilio.com/docs/voice/twiml/play, fetched 2026-10-08):
    • digits allowed characters: 0-9, A, B, C, D, W, w, #, *. Lowercase w = 0.5 s pause, uppercase W = 1 s pause (page prose: www3 = 1.5 s before tone 3; the page's own samples are inconsistent between www3 and wwww3). No maximum length or tone duration is stated.
    • The page describes <Play digits> as playing DTMF tones "during a call" and suggests testing an IVR menu. It does NOT say that the tones go to a particular leg; for dialing a number with an extension the page says use <Number sendDigits>. <Play digits> cannot be used inside <Gather> (a nested <Play> with a file is allowed, digits are not).
    • Example (verbatim): <Response><Play digits="www3"></Play></Response>.
  • Other digit-sending parameters: Create Call SendDigits (up to 32 chars) and <Dial><Number sendDigits="wwww1928"> (page 'number': plays tones "once the call is answered"; same alphabet 0-9 A B C D W w # *). These apply to calls Twilio places, not to an already-running stream.
  • Consequence for a call that is in a Media Stream: the only documented way to emit DTMF mid-call is to REPLACE the TwiML via Update Call (1.2). The update ends the <Connect><Stream> flow in some way (1.3) so after the digits the TwiML must start a NEW stream:
    <Response>
      <Play digits="ww1234#"/>
      <Connect>
        <Stream url="wss://agent.example.com/twilio/stream" name="brisvox">
          <Parameter name="resume" value="SESSION_ID"/>
        </Stream>
      </Connect>
    </Response>
    
    Consequences (design, UNVERIFIED in practice): a new WebSocket upgrade, a new streamSid, a gap in both audio directions, the new start.customParameters.resume must be mapped back to the live session by an adapter (CallInfo.metadata["custom.resume"]), and the Twilio signature check applies to the new upgrade as usual. This is heavy; do it only for deliberate IVR navigation.
  • In-band alternative (Proposal, reliability UNVERIFIED): synthesize the two-tone DTMF waveform as PCM16 8 kHz in the engine, mu-law encode, and send it as normal media frames with the audio pacer. No Twilio doc says in-band tones sent on the stream are passed to the far end as DTMF or detected by an IVR; the far-end behaviour of G.711 tones is carrier- and IVR-dependent. Parameters to expose as config, tested live: tone ms, gap ms, level. For orientation only: Telnyx's own send_dtmf documents duration_millis 100-500, default 250 (section 2.2). Frequency table is the ITU keypad standard; this research confirmed only keys 5 (770+1336 Hz) and 8 (852+1336 Hz) via the dtmf_tones repo README, the rest of the table (rows 697/770/852/941, columns 1209/1336/1477/1633) is UNVERIFIED by a fetched source: cross-check with a decoder in a unit test (Goertzel).
  • Event wiring (brisvox): WireEvent::Dtmf already exists (see twilio.md table, "dtmf -> WireEvent::Dtmf"); brisvox delivers it as Data::Dtmf to the turn state machine/flows and exposes it to tools (e.g. a dtmf message in the LLM context: "[caller pressed 5]"). Outbound: a new transport command SendDtmf { digits } per transport: Telnyx -> send_dtmf; Twilio -> either the REST-update route above or in-band synthesis, selected by config; others -> "unsupported" error.

1.5 Transfer on Twilio

Primitives (all fetched 2026-10-08): TwiML <Dial>, <Number>, <Conference>, <Refer>, <Redirect>; REST Calls update; Conference and Participant resources.

1.5.1 Cold transfer (bot says goodbye, caller is connected to a number)

  • REST: Update Call (1.2) with Twiml=<Response><Dial callerId="+90..." answerOnBridge="true" timeout="30" action="https://app/dial-result">+90555...</Dial></Response> (attributes from the <Dial> page: action, answerOnBridge (default false), callerId, timeout default 30 (5-600, +5 s buffer), timeLimit default 14400, hangupOnStar, record). The caller's call becomes the parent; <Dial> creates a child call with its own CallSid; both legs bill (billing statement not fetched: UNVERIFIED). A plain-text number is the same as nested <Number>; E.164 recommended.
  • callerId rules (<Dial> page): the dialed party sees the inbound caller's number by default; with <Number> callerId may be the To or From of Twilio's request, a purchased Twilio number or a verified number; for inbound SIP calls it cannot be the To/From.
  • Outcome reporting: with action, Twilio sends DialCallStatus (completed, answered (conference), busy, no-answer, failed, canceled), DialCallSid, DialCallDuration, DialBridged. Without action, Twilio moves to the next verb; with none the call ends. After the update brisvox is no longer in the media path, so the outcome reaches only the user's HTTP app (or brisvox via a status callback/poll: GET the child call, finding it needs the call list filter by parent: UNVERIFIED, not fetched). If the dial fails (busy/no-answer/failed), the caller returns to whatever the action URL says; to put the bot back, the action handler must return <Connect><Stream> again (new session with resume parameter, see 1.4). This is the documented way to support "transfer failed, bot continues".
  • SIP REFER: <Refer><Sip>sip:alice@example.com</Sip></Refer> (https://www.twilio.com/docs/voice/twiml/refer, fetched 2026-10-08). "<Refer> is only supported on SIP call legs"; PSTN and Voice SDK legs are not supported and return an error. So it applies only if the call reached Twilio via SIP (the SIP transport), not for PSTN numbers. Action parameters ReferCallStatus, ReferSipResponseCode, NotifySipResponseCode. Billing: not stated on that page (a Twilio help text seen in search says SIP REFER is a separately charged feature: UNVERIFIED).
  • <Redirect> only changes the TwiML document ("All verbs after <Redirect> are unreachable and ignored"); not a transfer by itself.

1.5.2 Announced / screened transfer (no conference): whisper on the agent leg

<Dial><Number url="https://app/whisper"> runs TwiML "on the called party's leg after answer, before the parties are connected" (page 'number', fetched 2026-10-08; paraphrase of the page). Only <Play>, <Say>, <Gather>, <Hangup> are supported there ("Other TwiML verbs aren't supported"); with answerOnBridge on <Dial> the caller keeps hearing ringing while it runs, and that TwiML cannot contain <Dial>. Use: <Say language="tr-TR" ...> to read a short summary and a <Gather numDigits="1"> "press 1 to accept"; <Hangup/> otherwise (the page: offer them a chance to decline using <Gather> and <Hangup>). The url is a URL, so the user's HTTP app serves it (summary passed as a query string or looked up by ParentCallSid: design). Turkish <Say> voices (https://www.twilio.com/docs/voice/twiml/say/text-speech, fetched 2026-10-08): Google Standard-A..E, Wavenet-A..E, Chirp3-HD (Aoede, Charon, Fenrir, Kore, Leda, Orus, Puck, Zephyr), Amazon Polly Neural Burcu-Neural, Polly Standard Filiz. <Say> text limits (same page): Basic 4000 characters, Polly 3000 (excluding SSML tags), Google 5000 (including SSML tags, newlines and spaces).

1.5.3 Warm transfer (bot or first agent briefs the target, then hands off)

Documented conference primitives:

  • TwiML <Conference> (https://www.twilio.com/docs/voice/twiml/conference, fetched 2026-10-08): attributes muted (default false), beep (true|false|onEnter|onExit, default true), startConferenceOnEnter (default true; false = muted and hears hold music until a moderator with true joins), endConferenceOnExit (default false), participantLabel (unique in the conference, max 128 chars; duplicate -> error 16025, call not added), waitUrl/waitMethod, maxParticipants (<= 250), record, region (us1,ie1,de1,sg1,br1,au1,jp1), jitterBufferSize (small|medium|large|off, default large), statusCallback, statusCallbackMethod (the page is internally inconsistent about its default: text says POST, table says GET when no waitUrl: pass it explicitly), statusCallbackEvent (space-separated: start end join leave mute hold modify speaker announcement; the first participant's values win). The conference does not start until two participants join (and at least one has startConferenceOnEnter true); names are namespaced to the account; do not put personal data in the name. Callback parameters: ConferenceSid, FriendlyName, AccountSid, SequenceNumber, Timestamp, StatusCallbackEvent (conference-start|end, participant-join|leave|mute|unmute|hold|unhold|modify|speech-start|speech-stop, announcement-end|fail); participant events add CallSid, Muted, Hold, Coaching, EndConferenceOnExit, StartConferenceOnEnter, ParticipantLabel; participant-leave adds ParticipantCallStatus, ReasonParticipantLeft.
  • Conference resource (https://www.twilio.com/docs/voice/api/conference-resource): GET .../Conferences.json?FriendlyName=NAME&Status=in-progress finds the ConferenceSid (CF...); status is init|in-progress|completed; conference rooms "are not directly created from the Programmable Voice API" (create with TwiML <Dial><Conference> or the Participants API); POST .../Conferences/{Sid}.json with Status=completed ends it or AnnounceUrl= announces to everyone. The page says "Starting from September 30th, 2026, the default [of the Status list filter] is in-progress" (already past on 2026-10-08): always pass Status explicitly. Docs recommend the statusCallback over polling.
  • Participants (https://www.twilio.com/docs/voice/api/conference-participant-resource, fetched 2026-10-08; base https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Conferences/{ConferenceSid}/Participants):
    • Create: POST .../Participants.json; required From, To. Parameters (selection): Label, CallerId, Timeout (5-600, default 60, +5 s), Muted, Beep, EarlyMedia (default true), StartConferenceOnEnter (default true), EndConferenceOnExit (default false), WaitUrl, Record, ConferenceStatusCallback, ConferenceStatusCallbackEvent (default start end), StatusCallback/StatusCallbackEvent (call status), MachineDetection, MachineDetectionTimeout (default 30), MachineDetectionSpeechThreshold, MachineDetectionSpeechEndThreshold, MachineDetectionSilenceTimeout, AmdStatusCallback, AmdStatusCallbackMethod (default POST), CallToken, Region (us1, us2, ie1, de1, sg1, br1, au1, jp1). Response fields: account_sid, call_sid, label, conference_sid, date_created, date_updated, end_conference_on_exit, muted, hold, status, start_conference_on_enter, coaching, call_sid_to_coach, queue_time (create only), uri; participant status: queued|connecting|ringing|connected|complete|failed. The sample create response has "status": "queued". Reconstructed request (the page's own curl example was too long to quote; this follows its fields):
      curl -X POST "https://api.twilio.com/2010-04-01/Accounts/$TWILIO_ACCOUNT_SID/Conferences/$CF_SID/Participants.json" \
      --data-urlencode "From=+15017122661" --data-urlencode "To=+905551112233" --data-urlencode "Label=agent" \
      --data-urlencode "EndConferenceOnExit=false" -u $TWILIO_API_KEY:$TWILIO_API_SECRET
      
    • Update: POST .../Participants/{CallSid-or-Label}.json (label must be percent-encoded). Hold=true|false, HoldUrl, HoldMethod (default GET), AnnounceUrl, AnnounceMethod (default POST), Muted, BeepOnExit, EndConferenceOnExit, WaitUrl, Coaching, CallSidToCoach. Documented samples (verbatim): hold: --data-urlencode "Hold=true" --data-urlencode "HoldUrl=http://www.myapp.com/hold_music"; announce: --data-urlencode "AnnounceUrl=http://www.myapp.com/announce"; both against .../Conferences/CF.../Participants/CallSid.json. Announce URL media types: the Conference resource page lists MP3, WAV or TwiML; the list of TwiML verbs allowed (<Play>, <Say>, <Pause>, <Redirect>) was NOT confirmed on the participant page: UNVERIFIED. Whether an announcement interrupts audio already playing is not stated. Long files delay playback; announcements of 30 minutes or longer can trigger a request timeout (conference and calls stay connected, participants hear "An application error has occurred").
    • Delete (remove the leg from the conference): DELETE .../Participants/{CallSid}.json -> HTTP 204, no body (the removed participant's call ends or continues per its TwiML: UNVERIFIED which, the page does not say; the Twilio blog tutorial instead ends a leg with Calls update status: completed).
    • List/fetch: list filters Muted, Hold, Coaching, PageSize (default 50, max 1000); fetch accepts a Call SID or a label.
  • Documented warm-transfer shape (Twilio TaskRouter "Call Control Concepts", https://www.twilio.com/docs/taskrouter/contact-center-blueprint/call-control-concepts, fetched 2026-10-08): (1) put the caller on hold (participant update Hold=true + HoldUrl), (2) create a new TaskRouter task for the transfer target (with the conference SID and original TaskSID) and, once assigned, call agent 2 into the conference with TwiML (the page's route is via TaskRouter, not a bare Participants create), (3) three-way call, (4) remove agent 1's leg via the Participants API, (5) complete the task. Cold-transfer variant on the same page: "it is not important for the addition of the new participant to be completed before the first agent call SID is removed" and "The conference and caller leg will stay up even if no one else is on the conference."
  • Voice-AI example from Twilio (blog, published 2025-09-08, https://www.twilio.com/en-us/blog/developers/tutorials/product/warm-transfer-openai-realtime-programmable-sip): the caller sits in a conference (<Conference startConferenceOnEnter="true" participantLabel="customer" endConferenceOnExit="true">, conference named with the inbound CallSid), the AI is a SECOND conference participant (label "virtual agent") dialed over SIP, the human agent is added with Participants create (label "human agent", earlyMedia: false, callToken), a conference join status callback triggers removal of the AI leg by listing participants and updating that leg with status: completed. The article says it has no unit tests and that tests are needed before production; it does NOT state anything about a missing briefing/summary announcement or fragile error handling (an earlier version of this file claimed so; not supported by the page).
  • Problem for brisvox (design analysis): with <Connect><Stream> the bot is not a conference participant; it is a WebSocket on the caller's own leg. Moving the caller into a conference by TwiML ends that flow (1.3), so the bot cannot brief the agent or stay in a three-way call. Three workable shapes:
    1. Consult-leg pattern (composed from documented primitives only; end-to-end UNVERIFIED): (a) update the caller's call with <Play loop="0"> hold audio (or <Conference> alone with waitUrl); (b) create a second outbound call to the agent via Calls create with Twiml=<Response><Connect><Stream url="wss://.../twilio/stream"><Parameter name="role" value="consult"/><Parameter name="parent" value="CALLER_CALL_SID"/></Stream></Connect></Response> so a second brisvox session talks to the agent (briefing, "accept?" via speech or DTMF); (c) if accepted, update both calls with <Dial><Conference>SAME_NAME</Conference></Dial> (agent endConferenceOnExit, caller endConferenceOnExit), the consult stream ends; (d) if declined, update the caller back to <Connect><Stream> with resume. Needs answered_by handling on the agent leg (voicemail of the agent's phone must not "accept": use AMD from 1.7).
    2. Whisper screening (1.5.2): no conference, simplest, Twilio TTS reads the summary, agent accepts by DTMF. Good default for "warm".
    3. Bot as a conference participant from the start (Twilio blog shape): needs the bot reachable as a SIP/PSTN endpoint; not researched further.
  • Tool contract (Proposal): transfer_call { destination, mode: "cold" | "announced" | "warm", summary?, reason? } as a SideEffect tool: never speculative, idempotency key per call+destination, one transfer per call. Result returned to the LLM: {status: "initiated" | "failed", detail}; after initiated the session ends (cold) or waits for the consult result (warm). The tool must (1) validate E.164 against an allow-list from config (prevents prompt-injection toll fraud), (2) have the bot finish its hand-off sentence and wait for the played-mark (the Twilio adapter notes in twilio.md "Ending the call"), (3) only then call the REST update.

1.6 Status callbacks (Twilio)

  • Calls API: StatusCallback + StatusCallbackEvent (initiated|ringing|answered|completed, default completed); form-encoded request carrying the standard TwiML request parameters (https://www.twilio.com/docs/voice/twiml, fetched 2026-10-08): CallSid, AccountSid, From, To, CallStatus (page lists queued, ringing, in-progress, completed, busy, failed, no-answer, canceled; initiated is NOT in that list although it is a valid StatusCallbackEvent name), ApiVersion, Direction (inbound|outbound-api|outbound-dial), ForwardedFrom, CallerName, ParentCallSid, CallToken; status callbacks add CallDuration, RecordingUrl/Sid/Duration.
  • <Dial><Number statusCallback ...> (page 'number'): adds ParentCallSid, CallStatus, CallDuration (completed only), Timestamp (RFC 2822, UTC), CallbackSource (call-progress-events), SequenceNumber (starts at 0; events may arrive out of order). Child calls are never queued.
  • Conference callbacks: see 1.5.3. Stream callbacks (statusCallback on <Stream>): in twilio.md.
  • Authenticity: same X-Twilio-Signature HMAC-SHA1 scheme as twilio.md ("Request validation"); the callback is HTTP(S), so the URL form is the normal one.

1.7 Answering Machine Detection (Twilio)

Docs: https://www.twilio.com/docs/voice/answering-machine-detection and -faq-best-practices (fetched 2026-10-08).

  • Where AMD works (FAQ page): Calls API create, Participants API create, <Dial><Number>, <Dial><Sip>. It does NOT work on Elastic SIP trunking calls, <Dial><Client>, <Dial><Conference>, <Dial><Queue>. Changelog 2022-10-07: Dial and Participants AMD need an account with "Enhanced Programmable SIP Features". Inbound-call AMD: not offered (the pages show only outbound use; not stated explicitly).
  • MachineDetection: Enable (returns as soon as the party is identified; use to connect humans / hang up on machines) or DetectMessageEnd (returns human immediately; for a machine only when the greeting ends, "usually indicated by a beep"; the mode for leaving voicemail).
  • AnsweredBy values: with Enable: machine_start, human, fax, unknown; with DetectMessageEnd: human, machine_end_beep, machine_end_silence, machine_end_other (also a fallback when a machine was detected but MachineDetectionTimeout expired before the greeting ended), fax, unknown (timeout or initial-silence timeout). machine_start is not defined further on the page. FAQ: treat any machine_end_* as "machine, greeting finished" unless you must tell them apart.
  • Timing parameters (Create Call / AMD pages): MachineDetectionTimeout default 30 s (AMD page range 3-59; the <Dial><Number> page says 3-60; the Create Call page lists no range: inconsistent, UNVERIFIED which is right; stay within 3-59), only relevant for DetectMessageEnd per the FAQ; MachineDetectionSpeechThreshold 1000-6000 ms default 2400 (higher = fewer false machine, slower machine detection); MachineDetectionSpeechEndThreshold 500-5000 ms default 1200 (higher helps short voicemails, delays human detection; lower risks false human); MachineDetectionSilenceTimeout 2000-10000 ms default 5000 (initial silence before unknown). FAQ tuning hints: greetings of individual humans (residential/mobile) typically under 1800 ms; business 1800-3000 ms; machines over 3000 ms; recommended values for residential/mobile callees SpeechThreshold 1800-2000 and SpeechEndThreshold 1400-1500; for business callees SpeechThreshold 1800-3000 and SpeechEndThreshold 1400-1500. "30 seconds is frequently not enough time" for business voicemail (FAQ); low MachineDetectionTimeout increases unknown.
  • Sync vs async: default is synchronous: Twilio blocks the call until the verdict (the callee hearing silence for "several seconds" is our description, UNVERIFIED wording), then runs the TwiML; with Url it sends a webhook containing the decision (exact form-field name in that webhook: described as AnsweredBy in the AMD docs; the TwiML request-parameter page does not list it: UNVERIFIED). Async (AsyncAmd=true, Calls API): callee connected immediately, TwiML runs, result posted to AsyncAmdStatusCallback (+ AsyncAmdStatusCallbackMethod; AMD page says only POST, the Create page says GET|POST: UNVERIFIED which is right). Whether Participants API, <Dial><Number>, <Dial><Sip> are async only: UNVERIFIED (not stated on the AMD page; the 2022 changelog says AMD "will run in the background while the call connects" for those). Async callback fields: CallSid, AccountSid, AnsweredBy, MachineDetectionDuration (ms). The page shows no example payload (an earlier version of this file said it did); illustrative, constructed payload only: CallSid=CA...&AccountSid=AC...&AnsweredBy=machine_end_beep&MachineDetectionDuration=4200.
  • Typical latency (FAQ): results "within ~4 seconds after the call was answered" with default settings (an average); webhook round trip is the most common cause of slowness. Accuracy: FAQ "close to 100% accurate in US destinations with default settings" for DetectMessageEnd; international accuracy "may be slightly reduced".
  • Forked-stream interaction (AMD page, key for brisvox): async AMD uses one of the four forked audio streams per call; sync AMD uses none. The AMD page says Media Streams, SIPREC and Real-Time Transcription should be started "only after" Async AMD finishes, as indicated by the AsyncAmdStatusCallback. The stream overview page lists AMD among features sharing the four-track limit (that limit is stated for unidirectional streams); bidirectional streams are limited to one per call. Whether async AMD can run concurrently with a bidirectional <Connect><Stream> is therefore at best discouraged by the docs (UNVERIFIED if it errors).
  • Recommended Twilio voicemail pattern for brisvox (Proposal built on the above): outbound Calls create with MachineDetection=DetectMessageEnd (sync), MachineDetectionTimeout=45 (below 59), MachineDetectionSilenceTimeout default, Url=https://app/voice whose handler reads AnsweredBy and returns <Connect><Stream url=...><Parameter name="answered_by" value="{AnsweredBy}"/></Stream></Connect>; brisvox gets CallInfo.metadata["custom.answered_by"] at start, so no AMD plumbing is needed inside brisvox core. Humans: conversation starts about 0-4 s after pickup (sync-silence cost). Machines: wait for machine_end_*, then speak the message. Async variant to cut the silence: AsyncAmd=true, TwiML <Pause length="N"/> then <Connect><Stream> is NOT recommended (the docs say not to start a stream before the callback; a REST update on callback could start it, behaviour UNVERIFIED).
  • Cost: $0.0075 per call (https://www.twilio.com/en-us/voice/pricing/us line "Answering machine detection ... $0.0075 per call", fetched 2026-10-08; the 2019 GA blog says charged when enabled and the called party answers, busy/failed not charged; 2022 changelog: "$0.0075 per completed call"). Media Streams are listed at $0.0044 per minute and conference participants "Starting at $0.0018 / participant per min" on the same page.
  • Language/countries: the AMD docs do not mention languages or Turkish (UNVERIFIED for Turkish voicemail greetings). A supported-country list was not found (the 2019 GA blog lists no countries; its example number is +1); the 2017 beta post said US and Canada only (old, from a search summary, UNVERIFIED); pricing pages for other countries list the AMD fee, which suggests availability but is not proof. Test with Turkish carriers (Turkcell, Vodafone, Turk Telekom voicemail) live before trusting it.

2. Telnyx Call Control

2.1 REST basics

  • Base URL: https://api.telnyx.com/v2 (OpenAPI server; docs examples https://api.telnyx.com/v2/calls/{call_control_id}/actions/answer). Docs: https://developers.telnyx.com/docs/voice/programmable-voice/sending-commands (fetched 2026-10-08). That page itself only covers base URL, auth header, and HTTP status meanings (200, 403, 404, 422); the facts below on client_state, command_id, response bodies and error codes come from the individual command pages (send-dtmf, transfer-call, bridge-calls, etc.) and the webhooks page.
  • Auth: Authorization: Bearer YOUR_API_KEY (API key v2); JSON bodies (Content-Type: application/json in the docs' curl).
  • call_control_id identifies the call leg to control (command pages: "Identifier and token for controlling the call"; the claim that it also routes to the node where the call lives, for lower latency, was NOT found on the fetched pages: UNVERIFIED). Every command takes optional client_state (Base64, echoed in all later webhooks) and command_id (send-dtmf page: Telnyx ignores any command with the same command_id for the same call_control_id; the webhooks page adds "within 60 seconds"; this is the idempotency key to use for transfer and DTMF).
  • Success: 200 {"data":{"result":"ok"}}. Errors: 422 with an error list (transfer page examples show code, title, detail; the source/meta members are UNVERIFIED); samples: 90015 invalid Call Control ID, 90018 call already ended, 90034 call not answered yet, 10004 missing parameter, 90045 "command can't be issued when media streaming is used"; generic failure 10007. Dial lists 400, 422, 429 (rate limited), 500, 503. Rate limit values: not found (UNVERIFIED).
  • Commands exist (index page): send_dtmf, send_sip_info, transfer, bridge, refer, hangup, streaming_start, streaming_stop, playback_*, speak, gather_*, record_*, answer, reject, fork_*, transcription_*, client_state_update; conference commands hold, unhold, join, leave, mute, unmute, play, speak, update. No call-level hold command is listed (hold exists on conferences).

2.2 DTMF

Docs: https://developers.telnyx.com/api-reference/call-commands/send-dtmf (fetched 2026-10-08).

  • Send: POST https://api.telnyx.com/v2/calls/{call_control_id}/actions/send_dtmf. Description: "Sends DTMF tones from this leg. DTMF tones will be heard by the other end of the call." Body: digits (required; "Valid digits are 0-9, A-D, *, and #. Pauses can be added using w (0.5s) and W (1s)", example 1www2WABCDw9), duration_millis (100-500, default 250, "for how many milliseconds each digit will be played in the audio stream"), client_state, command_id. Response {"data":{"result":"ok"}}. No webhooks. No maximum number of digits stated (UNVERIFIED).
    curl -X POST https://api.telnyx.com/v2/calls/$CALL_CONTROL_ID/actions/send_dtmf \
      -H "Authorization: Bearer $TELNYX_API_KEY" -H "Content-Type: application/json" \
      -d '{"digits":"1www2","duration_millis":250,"command_id":"UUID"}'
    
    Whether this command is rejected (90045) or works while a media stream is active on the same leg: not documented (UNVERIFIED, live-test item 4). Separate commands exist for SIP INFO (send_sip_info, not fetched) and conference participants (/v2/conferences/{id}/actions/send_dtmf, not fetched). send_digits_on_answer (max 64 chars, 0-9 A-D w W * #) on Dial/Transfer; a to may end with ,digits (+18004247767,200); an explicit send_digits_on_answer overrides digits appended to to.
  • Receive over the stream: {"event":"dtmf","stream_id":..,"occurred_at":..,"sequence_number":"5","dtmf":{"digit":"1"}} (digits 0-9 * # A B C D; may arrive out of order; order by occurred_at; occurred_at is optional in the schema; full schema in telnyx.md).
  • Receive as webhook: call.dtmf.received exists with data.event_type = "call.dtmf.received", data.payload.digit (string), plus call_control_id, call_leg_id, call_session_id, client_state, connection_id, from, to (source: official SDK model telnyx-python types/call_dtmf_received.py, read 2026-10-08). The Voice API webhooks page describes it as "A DTMF digit was received", fired when the caller presses keys during Gather using audio or Gather using speak (paraphrase; the exact earlier-quoted sentence was not verified verbatim), and the Gather command docs list it as an expected webhook; it is NOT documented as a general always-on DTMF event. For a streamed bot, use the dtmf stream frame.
  • Mute: mute_dtmf (none|both|self|opposite) on Transfer/Bridge: DTMF is not passed through but DTMF webhooks still fire (Bridge page wording).

2.3 Transfer on Telnyx

  • Transfer: POST https://api.telnyx.com/v2/calls/{call_control_id}/actions/transfer (https://developers.telnyx.com/api-reference/call-commands/transfer-call, fetched 2026-10-08). Only to required (DID or SIP URI; ;secure=true|srtp|dtls; ,digits suffix allowed). Selected body fields: from (default the original call's to; E.164), from_display_name (<=128), audio_url/media_name (WAV/MP3 played to the destination on answer, before bridging; mutually exclusive), send_digits_on_answer, early_media (default true), timeout_secs (default 30, 5-600), time_limit_secs (default 14400, 30-14400), park_after_unbridge (self), answering_machine_detection (default disabled; enum below) + answering_machine_detection_config, custom_headers, sip_headers (only User-to-User), sip_transport_protocol, sip_region, preferred_codecs, media_encryption, target_leg_client_state, client_state, command_id, webhook_url(+method), mute_dtmf, record*. Behaviour: bridges the caller (Leg A) to a NEW leg (Leg B) to to. Webhooks: call.initiated, call.bridged (to Leg B), call.answered or call.hangup, and AMD events when requested. On a failed transfer the docs say a call.hangup for Leg B is sent; the original call remains active and can receive further commands (incl. a transfer to an alternate destination). Timeouts/limits produce call.hangup with hangup_cause timeout or time_limit. After the bridge ends, the leg hangs up unless park_after_unbridge: "self". Response {"data":{"result":"ok"}}.
    curl -X POST https://api.telnyx.com/v2/calls/$CALL_CONTROL_ID/actions/transfer \
      -H "Authorization: Bearer $TELNYX_API_KEY" -H "Content-Type: application/json" \
      -d '{"to":"+905551112233","from":"+908501234567","timeout_secs":30,"command_id":"UUID"}'
    
  • Media stream interplay: the transfer page lists 90045 (media streaming in use) among its 422 examples and says nothing else about streaming. The same 90045 example ("This command can't be issued when media streaming is used.") appears on the streaming_start page. Which commands are blocked by an active stream is NOT stated (UNVERIFIED, live-test item 4). If transfer/bridge is rejected with 90045, issue POST .../actions/streaming_stop first (stream_id optional; omitted = all streams of the call; webhook streaming.stopped), wait for it, then transfer; on failure, streaming_start again. Whether the WebSocket gets a stop frame after streaming_stop: the streaming_stop page does not describe it; the WebSocket reference says Telnyx sends stop "when it stops media streaming for the call" (the media-streaming guide: when the call ends, after the streaming.stopped webhook): treat the exact ordering as UNVERIFIED. Streams: "Currently only one streaming/fork operation is supported per call" (media-streaming page).
  • Bridge two legs: POST .../calls/{call_control_id}/actions/bridge with body call_control_id (the other leg; mutually exclusive with queue/video_room_id), park_after_unbridge, hold_after_unbridge, play_ringtone, ringtone, mute_dtmf, prevent_double_bridge, record*, client_state, command_id. Webhooks call.bridged for both legs. Errors incl. 90034 not answered.
  • Dial a second leg linked to the first: POST https://api.telnyx.com/v2/calls with required connection_id, to, from; link_to (call control id to share a session with), bridge_intent (requires link_to; overwrites from with the linked call's from), bridge_on_answer (requires link_to), prevent_double_bridge, park_after_unbridge (requires link_to); response data: call_control_id, call_leg_id, call_session_id, is_alive (always false for dial), client_state, record_type:"call", .... Dial also takes the streaming fields (stream_url, stream_track, stream_bidirectional_mode, stream_auth_token, ...) so a CONSULT leg can be streamed to a second brisvox session (same consult pattern as Twilio 1.5.3 shape 1). Fully documented parameters are in the Dial page; shape not live-tested.
  • Conference: POST /v2/conferences (call_control_id, name required; start_conference_on_create default true; hold_audio_url/hold_media_name; max_participants 2-800 default 250 (the page's own error examples conflict on this range, UNVERIFIED); duration_minutes; conferences expire after all leave or 4 hours; status init|in_progress|completed; end_reason all_left|ended_via_api|host_left|time_exceeded), POST /v2/conferences/{id}/actions/join (call_control_id required; hold, hold_audio_url, mute, start_conference_on_enter (default false), end_conference_on_exit, soft_end_conference_on_exit, supervisor_role barge|monitor|none|whisper, whisper_call_control_ids, beep_enabled), POST /v2/conferences/{id}/actions/hold (call_control_ids empty = all; audio_url/media_name). Webhooks (create-conference page) conference.created, conference.participant.joined|left, conference.ended, conference.recording.saved, conference.floor.changed. A warm transfer on Telnyx is therefore: conference the caller, hold the caller, dial and join the agent, brief, unhold, hang up the bot leg. As with Twilio, the bot-as-stream sits on the caller's leg, whether it survives conference join is UNVERIFIED.
  • SIP REFER: POST .../actions/refer with sip_address (required SIP URI), client_state, command_id, custom_headers, sip_auth_username/password, sip_headers (User-to-User only). Webhooks call.refer.started, call.refer.completed, call.refer.failed (the earlier-noted payload field sip_notify_response was NOT found on the refer page: UNVERIFIED). Whether the call must be answered: not stated (90034 is a listed generic error). The SIP trunking "External transfers" page describes blind (SIP REFER, "Immediate transfer without announcement") and attended transfer (hold, dial, announce, then REFER) for SIP endpoints, with a required Diversion header: that page concerns SIP-trunk endpoints and is not directly the Call Control stream bot case.
  • Telnyx's own AI Assistant product has a warm-transfer-acceptance feature (consult leg, built-in complete_transfer tool, 2-minute decision timeout, private|shared context): it is a different product; recorded only as design reference (https://developers.telnyx.com/docs/inference/ai-assistants/warm-transfer-acceptance).
  • Price line seen: "Call transfer | $0.10 per invocation" (https://telnyx.com/pricing/voice-api, fetched 2026-10-08); which commands count as a "transfer invocation" is UNVERIFIED.

2.4 AMD on Telnyx

Docs: https://developers.telnyx.com/docs/voice/programmable-voice/answering-machine-detection, Dial and Transfer pages (fetched 2026-10-08).

  • Enable with answering_machine_detection on Dial (outbound) or Transfer. (An earlier version of this file quoted the page as saying "outbound call creation or inbound call transfer"; that wording was NOT found on the AMD guide, Dial or Transfer pages: UNVERIFIED.) Modes: disabled (default), detect, detect_beep, detect_words, greeting_end, premium ("recommended" on the guide), premium_ios_call_screening_detection.
    ModeWebhooksresult values
    detectcall.machine.detection.endedhuman, machine, not_sure ("Recommended to treat as if human answered")
    detect_beepdetection.ended; call.machine.greeting.ended only if a beep is detectedgreeting: beep_detected
    detect_wordsdetection.ended; greeting.ended on beep or after a 30 s beep-detection windowgreeting: beep_detected or not_sure (timeout)
    greeting_endboth; Transfer page: greeting.ended when the greeting ends "with a beep or silence"greeting: ended
    premiumcall.machine.premium.detection.ended, call.machine.premium.greeting.endeddetection: human_residence, human_business, machine, silence, fax_detected, not_sure; greeting: beep_detected, no_beep_detected
    premium_ios_call_screening_detectionadds call.machine.premium.call_screening.detectedscreening; greeting prompt_ended
    Payload (SDK models call_machine_detection_ended.py and call_machine_greeting_ended.py, read 2026-10-08): data.event_type, data.id, data.occurred_at, data.record_type:"event", data.payload.{call_control_id, call_leg_id, call_session_id, client_state, connection_id, from, to, result} (all optional in the SDK);
    result Literal for detection.ended: `humanmachinenot_sure; for greeting.ended: beep_detected
    Order: call.initiated -> call.answered -> detection result -> greeting result; callee hang-up gives call.hangup at any point. Premium ordering: if a beep is heard before detection.ended, the greeting webhook is sent first (UNVERIFIED on the current guide fetch, which shows the general order and the iOS order only).
  • Config answering_machine_detection_config (Dial page defaults): total_analysis_time_millis 3500, after_greeting_silence_millis 800, between_words_silence_millis 50, greeting_duration_millis 3500, initial_silence_millis 3500, maximum_number_of_words 5, maximum_word_length_millis 3500, silence_threshold 256, greeting_total_analysis_time_millis 5000, greeting_silence_duration_millis 1500, beep tuning (beep_detection_profile both|freq_only, beep_min_frequency_hz 200-4000, beep_max_frequency_hz, beep_min_tone_duration_millis 50-5000, beep_spectral_*), prompt_end_timeout_millis (Dial/Transfer pages: default 5000, 1000-120000; the guide says 30000 default; iOS screening only). For premium only total_analysis_time_millis and greeting_duration_millis apply. The AMD guide quotes premium default analysis timeout 30 s (result not_sure on expiry) while the Dial and Transfer pages list 3500 for the config default: inconsistent (UNVERIFIED which applies to premium). The field descriptions on the Dial page make the heuristics explicit (useful design reference for an in-engine detector): initial silence over initial_silence_millis (3500 ms) = machine; a greeting longer than greeting_duration_millis (3500 ms) = machine; more than maximum_number_of_words (5) words = machine; a single word longer than maximum_word_length_millis (3500 ms) = machine; silence over after_greeting_silence_millis (800 ms) after a greeting = human. (These are descriptions of config fields, not a full algorithm spec.)
  • Cost: standard AMD $0.002 per call, premium $0.0065 per call (https://telnyx.com/pricing/voice-api, fetched 2026-10-08); Media Streaming over WebSockets $0.0035 per minute; Voice API outbound "$0.002 per minute + the SIP Trunking fee".
  • Language/Turkish: not documented in the AMD guide (UNVERIFIED). Telnyx streaming/AMD webhooks need a Call Control App connection and a public webhook (UNVERIFIED on fetched pages; design assumption).

2.5 Telnyx webhook mechanics (needed for transfer and answering-machine detection if brisvox consumes them)

Docs: https://developers.telnyx.com/docs/voice/programmable-voice/voice-api-webhooks (fetched 2026-10-08).

  • Envelope: data.{record_type:"event", event_type, id, occurred_at, payload} and meta.{attempt, delivered_to}.
  • Signature headers: Telnyx-Signature-Ed25519 (ED25519 signature) and Telnyx-Timestamp (Unix timestamp); the earlier claim "compatible with the Standard Webhooks specification" is NOT on the fetched webhooks page: UNVERIFIED. Verification steps are on a separate Webhook Fundamentals page (not fetched: UNVERIFIED).
  • Delivery: 2xx = ack; 3xx followed up to 3 redirects; 408, 429, 5xx retried; other 4xx not retried; "webhooks may be delivered more than once" -> dedupe on data.id; webhook_timeout_secs 0-30; failover URL webhook_event_failover_url; ack "within a few seconds" and process asynchronously (the "few seconds" wording UNVERIFIED on the latest fetch; retry count/interval not specified on the page). Per-call webhook override via webhook_url (+ webhook_url_method) on commands; webhook_urls/webhook_retries_policies (max 5 retries, total <= 60000 ms) are on the Dial page.
  • Correlate with call_leg_id; call_session_id groups related legs (e.g. both sides of a transfer). Ordering guarantees: none documented.

3. Brisvox design for DTMF, transfer and voicemail (Proposal)

3.1 DTMF

  • Inbound: already produced by the Twilio adapter (dtmf -> WireEvent::Dtmf) and by the Telnyx adapter (dtmf.digit, order by occurred_at). The design adds: delivery to the turn manager/flows and to tools as an event with timestamp; a debounce/collector (collect_digits { max, terminator '#', timeout } tool or flow node) with an explicit timeout (engine rule); DTMF while the bot speaks counts as barge-in only if configured; DTMF is NOT run through STT.
  • Outbound: transport capability flag can_send_dtmf: Telnyx true (REST send_dtmf with command_id); Twilio false over the stream, with two opt-in fallbacks (REST update + reconnect, or in-band synthesis); loopback/WebSocket transports: custom message. send_dtmf tool = SideEffect, idempotency key per (call, digits, sequence), digits validated against [0-9A-D*#wW] and a length limit (Twilio SendDigits 32, Telnyx send_digits_on_answer 64; digits max unspecified).
  • Crates: no crate needed. Hand-write the two-tone generator (a few lines, no_std-style, reusable buffer) and a Goertzel check in tests. crates.io check (API, 2026-10-08, re-checked in fact-check): dtmf 0.1.5 (MIT, decoder, last release 2022-11-04, 9820 total downloads: stale), dtmf_tones 1.0.2 and dtmf_table 1.1.4 (MIT, frequency tables only, no audio generation per the repo README; dtmf_tones repo shows 21 commits, 0 stars, no releases; new, tiny, not adopted). twiml-rust 0.1.0 exists (TwiML generator, 180 downloads) but is not needed: TwiML strings here are tiny and must be XML-escaped by a small helper (escape &, <, >, "). reqwest 0.13.5 (MIT OR Apache-2.0, released 2026-09-08) is already a workspace dependency for REST calls.

3.2 Transfer

  • Port: CallControl trait (provider-agnostic): transfer(target, mode, ctx) -> TransferOutcome, implemented for Twilio (TwilioCallControl, REST) and Telnyx (TelnyxCallControl, REST), each with timeouts on every HTTP call, a bounded retry policy that never retries a possibly-applied command (use GET status first on Twilio; command_id on Telnyx), and credentials in Secret.
  • State machine on the session: Active -> TransferPending(epoch) -> HandedOff | Failed(reason) -> Active|Ended. While pending, the bot stops generating new LLM turns; the farewell audio must be fully played (mark) before the REST call.
  • Twilio cold: REST update with <Dial> (1.5.1). Twilio announced: <Dial><Number url=whisper> (1.5.2). Twilio warm: consult-leg pattern (1.5.3, shape 1) or conference; all UNVERIFIED end to end, hence the pending live check.
  • Telnyx cold: transfer; warm: dial linked leg (+ streamed consult) and bridge, or conference; remember streaming_stop before commands that fail with 90045.
  • Safety (engine invariant: SideEffect never speculative, never twice): transfer_call runs only after the model's tool call finishes (not speculatively), idempotency key = hash(call id, target); destination allow-list or country allow-list in config; log the target redacted.

3.3 Voicemail strategy

Detection layers, in order of authority:

  1. Provider AMD when brisvox originates the call (Twilio DetectMessageEnd sync via the user's Url webhook -> answered_by custom parameter; Telnyx greeting_end or premium via the Dial request + webhooks). The result must reach the session (Twilio: custom parameter at start; Telnyx: webhook or client_state).
  2. In-engine confirmation (always on, cheap): watch the first seconds of the callee audio with VAD/STT: a single long continuous utterance over the Telnyx-documented 3500 ms, many words, or voicemail phrases in the STT text (Turkish phrases such as "mesaj bırakın", "bip sesinden sonra", "sesli mesaj" are a design suggestion, not from a fetched source) overrides human. A beep cue is only reliably available from the provider (machine_end_beep, beep_detected); an in-engine beep detector would be a new DSP component (not researched).
  3. Fallback when AMD says unknown/not_sure: treat as human (Telnyx guide: not_sure "Recommended to treat as if human answered" for standard AMD; for premium the docs give no recommendation); Twilio gives no recommendation (UNVERIFIED), fax -> hang up.

Leave-message flow (state machine):

  • Dialing -> Answered -> Classifying(≤ amd_timeout) -> human: normal conversation; machine: WaitGreetingEnd(≤ greeting_timeout) -> Leaving -> Done.
  • WaitGreetingEnd ends on Twilio machine_end_beep|machine_end_silence|machine_end_other or Telnyx call.machine.greeting.ended (beep_detected or ended); on a Twilio Enable verdict machine_start do NOT speak yet. On Telnyx detect_words, not_sure after the 30 s window means speak anyway or hang up (policy flag).
  • Leaving: play ONE fixed message (pre-rendered or synthesized once, Turkish text from config), barge-in disabled, no LLM turn, no STT-driven interruption; wait for the utterance-end mark (MarkPlayed) with a timeout, then hang up. Message length bounded (config, e.g. 20-30 s; voicemail systems cut off: carrier/system limit UNVERIFIED). The message must not reference data the callee did not consent to (compliance note, not a fetched fact).
  • Provider-side alternative for the message itself: Twilio <Say language="tr-TR"> (voices in 1.5.2) or <Play>; Telnyx speak/playback_start (not fetched: UNVERIFIED Turkish voices). Prefer brisvox's own TTS for voice consistency and Turkish quality.
  • Metrics/logging: AMD verdict, MachineDetectionDuration (Twilio, ms), time from answer to verdict, time from greeting end to first message audio; counted per call outcome (human|machine_message_left|machine_hangup|unknown|fax).

4. Open items to settle with live tests (need accounts, numbers, public wss URL)

  1. Twilio: effect of REST Update (new TwiML) on an active <Connect><Stream>: does Twilio send stop, close the socket, in what order; does a new <Connect><Stream> in the update TwiML start immediately; what does the caller hear in the gap; <Connect action> firing on server close.
  2. Twilio: <Play digits> tone duration/level and whether the far-end IVR reliably accepts it; does in-band G.711 DTMF sent on the stream reach an IVR (tone length/gap needed).
  3. Twilio: Update Call Twiml length limit; error codes/status for updating an ended call; whether Url update triggers a signed request with the usual X-Twilio-Signature.
  4. Telnyx: whether send_dtmf, transfer, bridge are blocked (90045) while a media stream is active; which exact commands; effect of transfer on the stream (stop frame?); whether streaming_stop + transfer + streaming_start is quick enough.
  5. Twilio consult-leg and conference warm transfer end to end (hold/unhold, Hold participant update on a leg created by <Dial><Conference>, drop of the bot leg, ConferenceSid discovery by FriendlyName).
  6. AMD: sync DetectMessageEnd latency distribution on Turkish carriers and Turkish voicemail greetings (Twilio and Telnyx), false-human/false-machine rate, machine_end_other frequency, behaviour with mobile "arama yönlendirme" announcements; whether async AMD can coexist with a bidirectional stream; whether Turkey is a supported AMD destination at all.
  7. Twilio AnsweredBy form-field name in the Url webhook and the AsyncAmdStatusCallbackMethod GET support (docs disagree).
  8. Telnyx: premium AMD payload schemas and default total_analysis_time_millis; call.dtmf.received outside Gather.
  9. Billing: Twilio parent+child leg billing for <Dial> transfers; Telnyx "Call transfer $0.10 per invocation" scope; conference participant minutes.
  10. Rate limits for the REST endpoints on both providers.

Sources (all fetched 2026-10-08)

Twilio

  • https://www.twilio.com/docs/usage/requests-to-twilio (and .md)
  • https://www.twilio.com/docs/voice/api/call-resource (and .md, full pages incl. Update/Delete sections)
  • https://www.twilio.com/docs/voice/tutorials/how-to-modify-calls-in-progress (and .md)
  • https://www.twilio.com/docs/voice/twiml (request parameters), /twiml/play, /twiml/dial, /twiml/number, /twiml/conference, /twiml/refer, /twiml/redirect, /twiml/connect, /twiml/stream, /twiml/say (+ /say/text-speech for Turkish voices and limits)
  • https://www.twilio.com/docs/voice/media-streams (DTMF statements, four-track limit, stopping a bidirectional stream), https://www.twilio.com/docs/voice/media-streams/websocket-messages
  • https://www.twilio.com/docs/voice/api/conference-resource, /conference-participant-resource
  • https://www.twilio.com/docs/taskrouter/contact-center-blueprint/call-control-concepts (cold/warm transfer sequences)
  • https://www.twilio.com/en-us/blog/developers/tutorials/product/warm-transfer-openai-realtime-programmable-sip (2025-09-08)
  • https://www.twilio.com/docs/voice/answering-machine-detection, /answering-machine-detection-faq-best-practices
  • https://www.twilio.com/en-us/blog/answering-machine-detection-generally-available (2019-07-17), https://www.twilio.com/en-us/changelog/answering-machine-detection-is-now-available-for-dial-and-post-to-participants-calls (2022-10-07)
  • https://www.twilio.com/en-us/voice/pricing/us (AMD, Media Streams, conference participant lines)
  • Web search summaries (not primary text): AMD supported countries (2017 beta US/Canada), SIP REFER billing, update-during-stream behaviour (confirmed undocumented). Telnyx
  • https://developers.telnyx.com/api-reference/call-commands/send-dtmf (.md), /transfer-call, /bridge-calls, /sip-refer-a-call, /streaming-start, /streaming-stop, /hangup-call, /dial (full page read up to ~100k chars)
  • https://developers.telnyx.com/api-reference/conference-commands/create-conference, /join-a-conference, /hold-conference-participants
  • https://developers.telnyx.com/docs/voice/programmable-voice/sending-commands, /voice-api-commands-and-resources, /voice-api-webhooks, /answering-machine-detection, /media-streaming
  • https://developers.telnyx.com/api-reference/websockets/stream-call-media-over-websocket (stream frames, dtmf)
  • https://developers.telnyx.com/docs/voice/sip-trunking/features/external-transfers, https://developers.telnyx.com/docs/inference/ai-assistants/warm-transfer-acceptance
  • https://github.com/team-telnyx/telnyx-python (raw src/telnyx/types/call_dtmf_received.py, call_machine_detection_ended.py, call_machine_greeting_ended.py; official SDK models used for webhook field names because the callback doc pages render no schema)
  • https://telnyx.com/pricing/voice-api
  • crates.io API: reqwest 0.13.5, dtmf 0.1.5, dtmf_tones 1.0.2, dtmf_table 1.1.4, twiml-rust 0.1.0, search dtmf; https://github.com/jmg049/dtmf_tones
  • Not fetched: Telnyx Webhook Fundamentals (signature verification steps), send_sip_info, speak, playback_start, Telnyx premium AMD callback pages (render no schema), Twilio Calls list filters (child-call lookup), Twilio SIP REFER billing, per-account rate limits for both providers.

Fact-check log

Fact-checked 2026-10-08: 135 claims checked, 20 corrected. Every cited Twilio and Telnyx page, the four Telnyx SDK model files and the crates.io/GitHub entries were re-fetched on 2026-10-08. Corrections made in place above:

  1. Twilio Create Call: Twiml is the parameter ignored when both Twiml and Url are given (file previously said Url was ignored).
  2. SendDigits vs MachineDetection: page says MachineDetection "will be ignored"; wording fixed.
  3. Update Call description quote "Modifies an active call..." not verbatim on the page; replaced by the actual wording.
  4. Parent/child call quotes reworded to the actual page text ("entirely new Call instance", "is considered in-progress by Twilio", "you must use Status=completed").
  5. <Stream> TwiML page: the quoted "doesn't say what happens..." was not a page quote; "one Stream per Call" belongs to the media-streams overview page.
  6. CallStatus values: initiated is not in the page's list.
  7. Twilio AMD page has no example async payload; marked as constructed.
  8. "Participants / Dial Number / Dial Sip are async only" not stated on the AMD page; marked UNVERIFIED (changelog says "run in the background").
  9. Conference resource quote corrected ("Conference rooms are not directly created from the Programmable Voice API").
  10. Twilio blog (2025-09-08): only "no unit tests" is stated; the claims of no briefing/summary and fragile error handling removed.
  11. Participants announce URL verb list (<Play>, <Say>, <Pause>, <Redirect>) not confirmed; marked UNVERIFIED.
  12. Telnyx 2.1: client_state/command_id/error codes are not on the sending-commands page (re-attributed to command pages); node-routing claim and source/meta error members marked UNVERIFIED; 60-second command_id window added.
  13. Telnyx "compatible with the Standard Webhooks specification" not found on the webhooks page; marked UNVERIFIED.
  14. Telnyx AMD "outbound call creation or inbound call transfer" wording not found; marked UNVERIFIED.
  15. crates.io dtmf downloads: 3108 corrected to 9820.
  16. call.dtmf.received verbatim quote replaced by a paraphrase of the webhooks page.
  17. Telnyx refer sip_notify_response payload field not on the refer page; marked UNVERIFIED.
  18. Twilio FAQ threshold hints: 1800-2000/1400-1500 are for residential/mobile callees (business: SpeechThreshold 1800-3000); clarified.
  19. TaskRouter warm-transfer step 2 reworded (new TaskRouter task, then TwiML into conference); Twilio MachineDetectionTimeout range note (Create page lists none) added.
  20. Telnyx: stop frame after streaming_stop ordering marked UNVERIFIED; premium event-ordering claim marked UNVERIFIED; added conference max_participants conflict, conference.recording.saved, join default, prompt_end_timeout_millis default conflict (5000 vs 30000).

Remaining UNVERIFIED items: Twilio REST rate limits/idempotency/error body; Update Call Twiml max length and error codes for ended calls; effect of REST update on an active bidirectional stream; whether <Connect action> fires on server close; AnsweredBy field name in the Url webhook; AsyncAmdStatusCallbackMethod GET support; MachineDetectionTimeout range (3-59 vs 3-60); AMD countries/Turkish support (both providers); SIP REFER and <Dial> billing; Twilio machine_start definition; whether DTMF tones are also in the media payload; Telnyx rate limits, call_control_id node routing, send_dtmf max digits and stream interplay (90045 scope), premium AMD payload schemas and default timeout, premium event ordering, sip_notify_response, Standard Webhooks compatibility, Webhook Fundamentals signature steps, call.dtmf.received outside Gather, $0.10 transfer invocation scope, Turkish voices for Telnyx speak; ITU DTMF frequency table except keys 5 and 8; carrier voicemail length limits.

G.711 (mu-law / A-law) — codec reference

  • Fetched 2026-10-07. No tool to execute code was available in the research session, so every "DERIVED" value below was hand-computed from the fetched C code. Re-check them with a unit test against the quoted algorithm.
  • Primary algorithm source (Sun Microsystems g711.c, as shipped in RAT): https://sources.debian.org/data/main/r/rat/4.2.20-3/codec_g711.c
  • Other copies of the same Sun file (seen, only partially extracted): https://sources.debian.org/src/sox/12.16-6/g711.c , https://gnuradio.org/cgit/gnuradio.git/plain/gr-vocoder/lib/g7xx/g711.c , https://www.askapache.com/s/s.askapache.net/wireshark-1.1.3/g711.c
  • ITU-T G.191 STL: https://raw.githubusercontent.com/openitu/STL/dev/src/g711/g711.c and g711.h (same dir)
  • Format background: https://en.wikipedia.org/wiki/G.711 , https://en.wikipedia.org/wiki/%CE%9C-law_algorithm
  • Twilio: https://www.twilio.com/docs/voice/media-streams/websocket-messages ; Telnyx: https://developers.telnyx.com/docs/voice/programmable-voice/media-streaming

License

Sun g711.c header (quoted from the RAT copy): "This source code is a product of Sun Microsystems, Inc. and is provided for unrestricted use. Users may copy or modify this source code without charge." (AS IS, no warranty.) Safe to port. The ITU STL licence text was NOT fetched: UNVERIFIED. No STL code is copied: brisvox implements the standard's behaviour independently (see "Decision" below) and credits the Sun code in codec.rs.

Sun reference algorithm (RAT copy, 16-bit short in/out, arithmetic >>)

#define SIGN_BIT (0x80)   #define QUANT_MASK (0xf)   #define SEG_SHIFT (4)   #define SEG_MASK (0x70)
#define BIAS (0x84)       #define CLIP 8159
seg_aend[8] = {0x1F,0x3F,0x7F,0xFF,0x1FF,0x3FF,0x7FF,0xFFF};
seg_uend[8] = {0x3F,0x7F,0xFF,0x1FF,0x3FF,0x7FF,0xFFF,0x1FFF};
search(val, table, size): first i with val <= table[i]; else return size.

linear2ulaw(short pcm_val):             // output already complemented
  pcm_val = pcm_val >> 2;               // 16 -> 14 bit, arithmetic shift (floor)
  if (pcm_val < 0) { pcm_val = -pcm_val; mask = 0x7F; } else mask = 0xFF;
  if (pcm_val > CLIP) pcm_val = CLIP;
  pcm_val += (BIAS >> 2);               // +33
  seg = search(pcm_val, seg_uend, 8);
  if (seg >= 8) return 0x7F ^ mask;     // out of range -> max code
  uval = (seg << 4) | ((pcm_val >> (seg + 1)) & 0xF);  return uval ^ mask;

ulaw2linear(uchar u_val):               // returns 16-bit scale directly
  u_val = ~u_val;
  t = ((u_val & QUANT_MASK) << 3) + BIAS;
  t <<= ((unsigned)u_val & SEG_MASK) >> SEG_SHIFT;
  return (u_val & SIGN_BIT) ? (BIAS - t) : (t - BIAS);

linear2alaw(short pcm_val):
  pcm_val = pcm_val >> 3;               // 16 -> 13 bit
  if (pcm_val >= 0) mask = 0xD5; else { mask = 0x55; pcm_val = -pcm_val - 1; }
  seg = search(pcm_val, seg_aend, 8);
  if (seg >= 8) return 0x7F ^ mask;
  aval = seg << SEG_SHIFT;
  aval |= (seg < 2) ? ((pcm_val >> 1) & QUANT_MASK) : ((pcm_val >> seg) & QUANT_MASK);
  return aval ^ mask;

alaw2linear(uchar a_val):
  a_val ^= 0x55;
  t = (a_val & QUANT_MASK) << 4;  seg = ((unsigned)a_val & SEG_MASK) >> SEG_SHIFT;
  seg==0: t += 8;  seg==1: t += 0x108;  else: t += 0x108; t <<= seg - 1;
  return (a_val & SIGN_BIT) ? t : -t;

Notes: the RAT file comment says the u-law biasing "shifts the encoding range from (0 - 8158) to (33 - 8191)". The sox 12.16 copy of g711.c is an older variant (single seg_end[8] = {0xFF,0x1FF,...,0x7FFF} table); CPython audioop.c (v3.12.0) defines CLIP 32635 and BIAS 0x84 (a 16-bit-domain form). Do not mix variants; whether the CPython form matches the 14-bit one was NOT checked: UNVERIFIED.

STL (ITU G.191) algorithm — differences

STL ulaw_compress: absno = (x<0 ? (~x)>>2 : x>>2) + 33; if (absno > 0x1FFF) absno = 0x1FFF; then segment by counting bits of absno>>6, low = 0xF - ((absno>>segno)&0xF), sign bit 0x80 set when x >= 0. Header comments say "only 12 MSBits are taken into account" (A-law) and 1's complement for negatives. STL alaw_compress: ix = (x<0 ? ~x : x) >> 4, loop-normalise, ^ 0x55, sign bit 0x80 when x >= 0. Decoders alaw_expand/ulaw_expand produce the same 16-bit scale (peak 32256 / 32124 by hand calc).

  • Negative input in mu-law: STL uses ones' complement (~x), Sun uses two's-complement negate after the shift. DERIVED: Sun magnitude = ceil(|x|/4), STL = ceil(|x|/4) - 1, so for negative x the encoders can differ by one quantization step. Example: x = -1 -> Sun 0x7E, STL 0x7F (matches Wikipedia "-1 -> 0x7F" for 14-bit input). Consequently the Sun encoder never emits 0x7F (negative zero); both decode 0x7F as 0.
  • A-law encoders: Sun (-p-1 after >>3) and STL (~x >> 4) agree for all 16-bit inputs; VERIFIED exhaustively by brisvox-audio tests (2026-10-08).
  • Saturation: Sun clips at 8159 (biased 8192 -> seg 8 -> 0x7F ^ mask), STL clips biased value at 0x1FFF; same code (0x80 positive, 0x00 negative) DERIVED.
  • A-law even-bit inversion: Sun/STL XOR 0x55 (sign handled by masks 0xD5/0x55). Wikipedia: "all resulting even bits (LSB is even) are inverted" so silence is 0xD5 (not 0x80). mu-law: all bits complemented, silence 0xFF.
  • Zero-code suppression (T1 networks), ITU-T G.711 text (quoted via search snippet and the wikipedia mirror https://wikipedia.classicistranieri.com/en/articles/g/2E/7/G.711.html): "When using mu-law G.711 in networks where suppression of the all 0 character signal is required, the character signal corresponding to negative input values between decision values numbers 127 and 128 should be 00000010 and the value at the decoder output is -7519." (14-bit scale; DERIVED 16-bit scale -30076, vs. 0x00 = -32124). Neither Sun nor STL implements it. Only the encoder would substitute 0x00 -> 0x02; irrelevant for Twilio/Telnyx (IP, no T1 constraint). Do NOT implement.

Vectors

Legend: SRC = stated in a fetched source; DER = hand-derived from the fetched Sun code (confirm by test).

CaseValueStatus
mu enc 00xFFSRC (Wikipedia mu-law: "0 encodes as 0xFF"; silence = 0xFF) + DER
mu enc -1 (14-bit input, ones' complement ITU table)0x7FSRC; Sun 16-bit code gives 0x7E (DER)
mu enc +32767 / -327680x80 / 0x00DER
mu dec 0xFF / 0x7F0 / 0DER
mu dec 0x80 / 0x00+32124 / -32124DER; 14-bit peak 8031 (x4 = 32124) SRC Wikipedia G.711 ("0 to +-8031")
mu dec 0xFE / 0x7E+8 / -8DER
mu dec 0x02-30076 (-7519 in 14-bit)DER; -7519 SRC (G.711 text above)
A enc 00xD5SRC (Wikipedia: silent channel 0xD5) + DER
A enc -10x55DER
A enc +32767 / -327680xAA / 0x2ADER
A dec 0xD5 / 0x55+8 / -8DER (13-bit +-1, x8)
A dec 0xAA / 0x2A+32256 / -32256DER; 13-bit peak 4032 = 2^12-2^6 (x8 = 32256) SRC Wikipedia ("+-1 to +-(2^12-2^6)")
Round trip: decode(encode(decode(c))) == c for every A-law code (DER); mu-law except 0x7F (which re-encodes as 0xFF).
Max decode magnitudes: mu 32124, A 32256 (see above).

Carrier formats

  • Twilio Media Streams (websocket-messages page): encoding "always audio/x-mulaw", sample rate "always 8000", payload "Raw mulaw/8000 audio encoded in base64"; payload "should not contain audio file type header bytes". Mono / 20 ms chunking not stated in the fetched text: UNVERIFIED.
  • Telnyx media streaming: start-event example shows "encoding": "PCMU", "sample_rate": 8000, "channels": 1; bidirectional codecs listed: PCMU 8k (default), PCMA 8k, G722, OPUS, AMR-WB, L16 16k; chunk size "20 milliseconds to 30 seconds"; mismatched codec is transcoded. Telnyx uses base64 payloads. Default for non-bidirectional: UNVERIFIED.
  • RTP payload types PCMU=0 / PCMA=8: page fetch failed (404), UNVERIFIED here.

Implementation guidance

  • Decode: 256-entry [i16; 256] tables built at startup (const fn). Encode: 64K-entry u8 tables or arithmetic form (cheap; no inference pool needed).
  • Use >> as arithmetic shift on i16 (Rust matches C here); use the 14-bit Sun form above and test against the vectors; add a test pinning the Sun-vs-STL negative-input difference so a future port does not silently change.

Decision (2026-10-08)

brisvox follows the ITU-T G.191 STL behaviour: for negative input, the μ-law magnitude is the ones' complement ((!x) >> 2) as in STL ulaw_compress, not Sun's negate-after-shift. This matches the standard's reference software and Wikipedia's "-1 -> 0x7F"; Sun differs by one step for some negative inputs (-1 -> 0x7E) and never emits 0x7F. A-law and both decoders are identical in the two. Verified exhaustively by brisvox-audio tests (all 65 536 inputs): the table encoders equal a const reference encoder and an independent STL-formulation encoder; the hand-derived vectors above (0xFE -> 8, 0x7E -> -8, 0x02 -> -30076, extremes, silence codes) hold. Verification status (2026-10-08): the decoders equal STL-formulation expanders for all 256 codes of both laws, and every reconstruction level lies inside its code's quantization interval (exhaustive tests in crates/brisvox-audio/src/codec.rs). The STL-formulation test codecs were written from the algorithm summary above, not from the STL source.

Operating brisvox-server

How the server process starts, hosts its call listeners, reports its health and shuts down . Code: brisvox_server::{settings, hosting, runner, providers, tools, health, lifecycle}, crates/brisvox-server/src/main.rs.

The binary loads one agent definition (BRISVOX_AGENT_FILE, docs/CONFIG.md), builds its STT, LLM and TTS adapters once (their keys come from the environment variables the definition names), starts the inference pool (half the cores) and binds the call listeners below. Every call runs the engine (run_call) as one session of the session manager, with the bundled Silero VAD (feature silero-tract, on by default). The generic websocket, Twilio, Telnyx, RTVI and (with the sip / webrtc features) SIP and WebRTC listeners are hosted. The provider adapters are cargo features of brisvox-server (provider-*, or all-providers); the default build has none, so build the binary with the ones the agent names (cargo build --release -p brisvox-server --features all-providers), otherwise the definition is refused at startup with the feature to turn on.

Tools (docs/TOOLS.md "In the server binary"): every tool the definition lists in agent.tools must exist, or the start fails with the tool's position in the file and what would provide it. The binary provides the telephony tools transfer_call, send_dtmf and end_call when a carrier's call control is configured (BRISVOX_CALL_CONTROL, cargo feature call-control) and, with the cargo feature demo-tools (never for callers), canned lookup_order and book_appointment for the examples. An application's own tools plug in through brisvox_server::tools::AppTools in its own main, one line over this library: brisvox_server::app::run_with_tools(Arc::new(MyTools)) (docs/TOOLS.md "Rust tools in your own binary", examples/rust-tools).

Command line. brisvox-server with no arguments runs the server. brisvox-server check <agent-file> [--require-secrets] validates an agent file offline (docs/CONFIG.md "Checking a file"; exit 0 ok, 1 errors), --version and --help print and exit, and any other argument is refused with exit status 2 instead of starting a server.

Smart Turn (cargo feature smart-turn-ort; the ONNX Runtime binaries are downloaded at build time): an agent whose turn policy is adaptive (the default) gets the Smart Turn v3.2 model, loaded at startup from BRISVOX_SMART_TURN_MODEL (default $BRISVOX_MODELS_DIR/smart-turn-v3.2-cpu.onnx, models/ when unset; fetch it with scripts/models/fetch_models.py). A model that does not load stops the start; off runs without it. A build without the feature logs a warning and adaptive turns end at their fallback deadline. The analyses run on an executor of their own (BRISVOX_SMART_TURN_THREADS): its queue holds 32 analyses per thread and refuses the rest, and a refused analysis makes that turn end at its fallback deadline (logged as smart turn analysis failed); the VAD's pool, and the admission control reading it, are not affected, so watch brisvox_smart_turn_rejected_total and brisvox_smart_turn_timed_out_total (see Metrics). The executor's threads are named brisvox-turn-<i>.

Provider resilience: every call runs its STT, LLM and TTS behind the resilience layer with its default settings. The circuit breakers are per provider instance and shared by all calls (5 consecutive failures open one for 30 s), retries are budgeted per call (3) and per process (50), a dead STT or TTS session is recovered in place, and an idle TTS session is replaced at 80 % of the provider's idle limit (Cartesia: 4 of its 5 minutes). Nothing is configured for this; a second provider per stage to fail over to is the agent definition's [stt|llm|tts].secondary (docs/CONFIG.md), whose API keys must be set like the primaries'.

Build for a phone deployment with everything: cargo build --release -p brisvox-server --features all-providers,call-control,smart-turn-ort.

Settings (environment)

Agent definitions (docs/CONFIG.md) describe agents; the process itself is configured by environment variables. Unset or blank means the default; an invalid value stops the start with exit status 1 and a message naming the variable.

VariableDefaultMeaning
BRISVOX_MAX_SESSIONS256 per core (at least 256)Sessions live or reserved at once (1 to 100000); more are refused, never queued. The default follows the cores the process may use (a container's CPU quota counts): about four times what a core serves, so on any machine the pool pressure and the learned ceiling (below) refuse first and this only bounds memory and the queues sized from it. Set it lower to promise a fixed maximum. It also sizes the defaults derived from it (twice it for the listeners' sockets, a quarter for BRISVOX_WEBRTC_MAX_HANDSHAKING). With pressure shedding off (BRISVOX_OVERLOAD_QUEUE_PER_THREAD=0 and BRISVOX_OVERLOAD_QUEUE_WAIT=0) the cautious start and the learned ceiling are off too, and this limit is the only one.
BRISVOX_OVERLOAD_QUEUE_PER_THREAD4Jobs queued per inference-pool thread at or above which new calls are refused (overloaded). 0 ignores the queue depth.
BRISVOX_OVERLOAD_RESUME_QUEUE_PER_THREADhalf of the aboveQueued jobs per thread at or below which the server may recover (must be below the above).
BRISVOX_OVERLOAD_CEILINGtrueLearn a ceiling of held calls from pool pressure: when the pool turns hot hold at most 90% of the calls held then, when it is calm again at most the calls held at that moment (one hot spell at most halves the cap), and raise the cap by 10% (at least one call) per calm window spent at it. The pressure signals lag the calls (a call loads the pool when its caller speaks), so without it a fast ramp is admitted far past capacity. false: each admission is judged on the pool alone.
BRISVOX_OVERLOAD_QUEUE_CONFIRM100msHow long the queue must stay at or over BRISVOX_OVERLOAD_QUEUE_PER_THREAD (in every sample) before it counts. A burst of call starts queues one job per call for microseconds; a pool that is really behind keeps its queue. 0 sheds on the first such sample.
BRISVOX_OVERLOAD_QUEUE_WAIT50msMean time pool jobs waited in the queue over a window, at or above which new calls are refused. 0 ignores waits. With both at 0 pressure shedding is off.
BRISVOX_OVERLOAD_WINDOW2sThe window of the wait average, and the calm time needed to recover.
BRISVOX_PROVIDER_LIMITSnonename=n,...: concurrent calls per provider, e.g. deepgram=50,openai=100,cartesia=50. Names are the agent file's provider values (deepgram, assemblyai, gladia, sherpa_onnx, openai_realtime_stt, openai, anthropic, gemini, openai_compatible, cartesia, elevenlabs, deepgram_aura, openai_speech).
BRISVOX_HEALTH_ADDR0.0.0.0:8081Address of the /healthz + /readyz listener; off disables it; ${PORT} is allowed (see "Port placeholder" below). On a non-loopback address /metrics needs BRISVOX_METRICS_TOKEN (below).
BRISVOX_METRICS_TOKENunsetBearer token /metrics requires when BRISVOX_HEALTH_ADDR is not loopback. Without it /metrics answers 404 there (a warning is logged at start). Never logged.
BRISVOX_INSECURE_LISTENERSfalse1/true: listeners that would refuse to start without authentication (generic websocket, RTVI, WebRTC signaling, Telnyx on a non-loopback address) start anyway, and /metrics is served without a token. Logged loudly. See "Authentication" below and docs/SECURITY.md.
BRISVOX_SESSION_SECRETunsetSigns the short-lived single-use session tokens the generic websocket, RTVI and WebRTC listeners accept (at least 32 bytes; shorter is refused). docs/SECURITY.md.
BRISVOX_SESSION_SECRET_PREVunsetThe previous session secret, still verified while old tokens expire (rotation). Needs BRISVOX_SESSION_SECRET.
BRISVOX_TRUSTED_PROXIESunsetComma-separated CIDR blocks of the reverse proxies whose forwarding header the WebRTC listener believes (10.0.0.0/8,172.16.0.0/12); the client address is the last hop that is not one of them. Unset: the TCP peer is the client. Never 0.0.0.0/0: that makes every client its own proxy.
BRISVOX_CLIENT_IP_HEADERx-forwarded-forThe only forwarding header believed behind a trusted proxy: x-forwarded-for, forwarded (RFC 7239) or x-real-ip (one address; what Railway's edge writes and overwrites). Set the one your proxy writes.
BRISVOX_WEBRTC_HTTP_MAX_PER_IP8Signaling connections one client address (an IPv6 /64) may hold at once; 0 = no limit. Connections from a trusted proxy are exempt.
BRISVOX_DRAIN_TIMEOUT30sHow long a drain waits for calls to end by themselves. 0s cancels at once.
BRISVOX_CANCEL_GRACE5sAfter cancelling, how long sessions get to return.
BRISVOX_ABORT_WAIT1sAfter aborting, how long the task cleanup gets.
BRISVOX_LOG_FORMATtexttext or json (docs/TRACING.md).
RUST_LOGinfoLog filter.
BRISVOX_AGENT_FILEunsetThe agent definition every call runs, as a file path. Required when any listener is on, unless BRISVOX_AGENT_DEFINITION is set.
BRISVOX_AGENT_DEFINITIONunsetThe agent definition as text (TOML), for a host that takes variables but no file mount. Same loader, same size limit (1 MiB) and same messages as the file; errors name the source $BRISVOX_AGENT_DEFINITION. Setting it together with BRISVOX_AGENT_FILE is a startup error. Secrets stay out of it: it names the variables that hold them (api_key_env).
BRISVOX_AGENT_FORMATtomlThe syntax of BRISVOX_AGENT_DEFINITION: toml or yaml (needs the yaml cargo feature). Never guessed from the text. Setting it without BRISVOX_AGENT_DEFINITION is an error (a file's format follows its extension).
BRISVOX_WS_ADDR127.0.0.1:8080 when no other listener is set, otherwise offGeneric websocket listener (docs/WEBSOCKET_PROTOCOL.md, any path); off disables it. On a non-loopback address it needs BRISVOX_WS_TOKEN or BRISVOX_SESSION_SECRET: see "Authentication" below.
BRISVOX_WS_AGENT_FILEunsetAn agent definition file for the calls of the generic websocket listener only; the other listeners run BRISVOX_AGENT_FILE's agent. One process then serves two agents on one inference pool, one Smart Turn executor and one admission control (each with its own providers and agent links; the websocket agent gets no application or telephony tools, and its [recording] table is ignored with a warning). BRISVOX_PROVIDER_LIMITS covers the providers of both agents and every call holds a permit of each limited one (admission comes before the listener's agent is known). /metrics shows the main agent's provider breakers and retries only. Needs BRISVOX_WS_ADDR. Example: a load test's synthetic calls on fake providers next to real calls on real ones (bench/railway).
BRISVOX_WS_TOKENunsetStatic token of the generic websocket listener (development): Authorization: Bearer, the subprotocol brisvox-token.<token> or ?token=.
BRISVOX_TWILIO_ADDRoffTwilio Media Streams listener (path /twilio/stream).
TWILIO_AUTH_TOKENunsetThe account's Auth Token; required with the Twilio listener (every stream's X-Twilio-Signature is checked).
BRISVOX_TWILIO_PUBLIC_URLunsetThe stream URL exactly as written in the TwiML (wss://host/path); required with the Twilio listener. With per-call URLs (below) this is the base: each call's URL is this plus /<segment>.
BRISVOX_TWILIO_STREAM_SECRETunsetAt least 32 bytes. Turns on per-call signed stream URLs: every stream URL ends in /<callSid>.<exp>.<mac>, valid once and until exp (docs/providers/twilio.md, "Per-call stream URLs"). The server also signs the URLs of the streams it restarts itself (DTMF, failed warm transfer). Required with the Twilio listener unless BRISVOX_TWILIO_ALLOW_STATIC_URL=1.
BRISVOX_TWILIO_ALLOW_STATIC_URLoff1 accepts the fixed stream URL (instead of, or besides, per-call URLs). Legacy: the fixed URL's X-Twilio-Signature is one constant; anyone who saw it once (a proxy log) can replay it. Logs a warning at startup.
BRISVOX_TELNYX_ADDRoffTelnyx media streaming listener (path /telnyx/stream).
BRISVOX_TELNYX_STREAM_TOKENunsetThe stream_auth_token Telnyx must send. Required when BRISVOX_TELNYX_ADDR is not a loopback address (Telnyx signs nothing: the path and this token are the only checks), unless BRISVOX_INSECURE_LISTENERS=1.
BRISVOX_RTVI_ADDRoffRTVI listener for Pipecat client SDKs (path /rtvi, docs/RTVI.md).
BRISVOX_RTVI_TOKENunsetStatic token RTVI clients must send (development): ?token=, Authorization: Bearer or the subprotocol brisvox-token.<token>. With neither this nor BRISVOX_SESSION_SECRET the listener is open, which it refuses on a non-loopback address.
BRISVOX_RTVI_ALLOWED_ORIGINSunsetComma-separated browser Origins allowed (unset: any).
BRISVOX_SIP_ADDRoffSIP trunk listener, UDP (feature sip; a binary without it refuses the setting).
BRISVOX_SIP_PUBLIC_IPunsetAddress advertised in SDP, Contact and Via; required when BRISVOX_SIP_ADDR is unspecified (0.0.0.0).
BRISVOX_SIP_TCPfalseAlso take SIP over TCP on the same port.
BRISVOX_SIP_RTP_PORTSunsetUDP port range for RTP, low-high (unset: the OS picks a port per call). Forward the whole range.
BRISVOX_SIP_ALLOWED_SOURCESunsetComma-separated networks allowed to send SIP (10.0.0.0/8, 203.0.113.7): the carrier's signaling ranges.
BRISVOX_SIP_AUTH_USER / BRISVOX_SIP_AUTH_PASSWORDunsetDigest credentials inbound INVITEs must present (both or neither).
BRISVOX_SIP_AUTH_REALMbrisvoxThe digest realm.
BRISVOX_SIP_TLS_ADDRoffSIPS listener (SIP over TLS, conventionally 0.0.0.0:5061). Needs the two files below; the port must differ from BRISVOX_SIP_ADDR's. TLS 1.2 and 1.3, no client certificate.
BRISVOX_SIP_TLS_CERT / BRISVOX_SIP_TLS_KEYunsetPaths of the PEM certificate chain (leaf first) and the PEM private key. Read once at startup (at most 256 KiB each; restart to rotate); setting one without BRISVOX_SIP_TLS_ADDR is an error. The key is never logged or printed.
BRISVOX_SIP_TLS_CAunsetPEM bundle that outbound sips: / ;transport=tls calls verify the callee against (the stack has no built-in roots: list the carrier's CA here).
BRISVOX_SIP_TLS_MAX_CONNECTIONS / BRISVOX_SIP_TLS_MAX_PER_IP1024 / 32TLS connections at once, and from one source address. Over either the connection is closed before the handshake (tls_rejected).
BRISVOX_SIP_TLS_HANDSHAKE_MS10000The longest a client may take for the TLS handshake.
BRISVOX_SIP_TLS_IDLE_TIMEOUT300Seconds an established SIPS connection may carry no byte (in either direction; RFC 5626 CRLF keepalives count) before it is closed and its slots freed (tls_idle_closed). 0 turns the check off.
BRISVOX_SIP_SRTP_AUTH_FAIL_LIMIT50SRTP packets of one call that may fail authentication within one second before the call is hung up (logged once; srtp_flood_hangups). 0 turns the limit off. A burst means tag guessing (the _32 tag has 32 bits) or keys out of sync; the price is that whoever can send UDP to the RTP port can end the call.
BRISVOX_SIP_SRTPoptional with TLS, else offSDES-SRTP policy: off (plain RTP only), optional (SRTP when the offer has a usable a=crypto line and came in over TLS, plain otherwise), required (anything else gets 488). SDES keys are never accepted over UDP or TCP signaling; anything but off without the TLS listener is a startup error. Details and the carriers checked: docs/TELEPHONY.md, "SIP over TLS and SRTP".
BRISVOX_LOG_SIP_STACKfalseLet the SIP stack's own log events (rsipstack) below warn through. Off by default and not for production: the stack logs whole SIP messages at info, including the SDES SRTP master keys of a secure call. RUST_LOG cannot lift the hold; only this can.
BRISVOX_WEBRTC_ADDRoffWebRTC signaling listener, TCP (feature webrtc; a binary without it refuses the setting): WHIP-style POST /whip and DELETE on the session, and the transport's /demo page. See "WebRTC" below.
BRISVOX_WEBRTC_UDP_ADDRthe signaling addressThe one UDP socket every call's media shares (forward this port).
BRISVOX_WEBRTC_CANDIDATE_IPunsetThe address advertised to browsers as the server's media address; required when the UDP socket is bound to an unspecified address (0.0.0.0), and behind NAT or in a container the one browsers can reach.
BRISVOX_WEBRTC_TOKENunsetStatic bearer token POST/DELETE require (RFC 9725 section 4.7; development). With neither this nor BRISVOX_SESSION_SECRET the endpoint is open, which it refuses on a non-loopback address.
BRISVOX_WEBRTC_MAX_CALLS_PER_IP8Calls one client address may hold at once (429 over it), or off.
BRISVOX_WEBRTC_MAX_HANDSHAKINGa quarter of BRISVOX_MAX_SESSIONSCalls that may be in their ICE/DTLS handshake at once (503 over it): offers that never connect cannot starve real calls.
BRISVOX_WEBRTC_OFFERS_PER_MINUTE5Offers one client address may make per minute, burst of as many (429 with Retry-After), or off.
BRISVOX_WEBRTC_WS_PATH/wsThe WebSocket fallback: a WebSocket upgrade on this path of the signaling port serves the generic WebSocket protocol (docs/WEBSOCKET_PROTOCOL.md), with the credentials, Origin rule and limits of POST /whip (docs/DEPLOY.md "WebSocket fallback"). off: the path is 404. Counted in ws_upgrades / ws_refused.
BRISVOX_WEBRTC_CORS_ORIGINunsetAccess-Control-Allow-Origin for pages on another origin (* or one origin). Unset: same origin only. * needs BRISVOX_WEBRTC_TOKEN or BRISVOX_SESSION_SECRET.
BRISVOX_WEBRTC_TCP_ADDRoffICE-TCP listener: a passive TCP candidate next to the UDP one, for clients whose network blocks UDP and hosts with a TCP port but no UDP. docs/DEPLOY.md "WebRTC: making the server reachable".
BRISVOX_WEBRTC_TCP_CANDIDATE_PORTthe listener's portThe port advertised in the TCP candidate when a proxy or port mapping changes it (a platform's TCP proxy).
BRISVOX_WEBRTC_TCP_MAX_CONNECTIONS / _MAX_PER_IP / _IDLE_TIMEOUT / _MAX_FRAME / _QUEUE256 / 16 / 10s / 2048 / 64ICE-TCP limits: connections served at once, per source IP (0 = off; use it behind a TCP proxy), time to name a live call, largest frame, frames queued per connection. docs/CONFIG.md "WebRTC reachability".
BRISVOX_TURN_PROVIDERoffcoturn, cloudflare or twilio: browsers get short-lived TURN credentials on the WHIP 201 (Link: rel="ice-server") and from GET /whip/ice. Variables per provider and BRISVOX_TURN_TTL: docs/CONFIG.md "WebRTC reachability", which also says who may mint relay credentials (without BRISVOX_WEBRTC_TOKEN: anyone) and why the pages use GET /whip/ice rather than the Link headers.
BRISVOX_TURN_URLS-coturn provider: comma-separated turn:/turns: URLs handed to browsers (docs/CONFIG.md "WebRTC reachability").
BRISVOX_TURN_SECRET-coturn provider: the static-auth-secret shared with coturn (use-auth-secret); credentials are derived per call and expire after BRISVOX_TURN_TTL. Never sent to browsers.
BRISVOX_TWILIO_PATH / BRISVOX_TELNYX_PATH / BRISVOX_RTVI_PATH/twilio/stream / /telnyx/stream / /rtviThe websocket path of each listener (Twilio: the path of BRISVOX_TWILIO_PUBLIC_URL).
BRISVOX_LISTENER_HANDSHAKE_TIMEOUT5sEvery websocket listener: TCP accept to the end of the upgrade.
BRISVOX_LISTENER_START_TIMEOUT5sUpgrade to the protocol's start message.
BRISVOX_LISTENER_PING_INTERVAL / BRISVOX_LISTENER_PING_TIMEOUT10s / 30sServer pings; a client silent for the timeout (longer than the interval) ends its call.
BRISVOX_LISTENER_WRITE_TIMEOUT5sLongest single write to a client.
BRISVOX_LISTENER_SEND_TIMEOUT5sLongest wait for the engine's queues (a control event, the hand-over of a new call).
BRISVOX_LISTENER_CLOSE_GRACE2sHow long the two sides of a closing connection wait for each other.
BRISVOX_LISTENER_ACCEPT_QUEUE16Calls waiting to be handed to a session (1 to 1000000).
BRISVOX_LISTENER_MAX_AUDIO_BYTES / BRISVOX_LISTENER_MAX_CONTROL_BYTES65536 / 16384Largest binary / text frame accepted (2 / 512 to 16 MiB).
BRISVOX_RTVI_BOT_SPEAKING_EVENTStrueThe RTVI bridge sends bot-started/stopped-speaking and bot-interrupted from the engine.
BRISVOX_RTVI_METRICStrueThe RTVI bridge sends metrics once per turn.
BRISVOX_RTVI_REPLY_TIMEOUT5sHow long an RTVI client message's handler may take.
BRISVOX_RTVI_EVENT_CAPACITY / BRISVOX_RTVI_TEXT_CAPACITY1024 / 8An RTVI call's engine event ring / typed messages waiting for the turn driver.
BRISVOX_CALL_CONTROLoffCarriers whose REST call control the telephony tools use: twilio, telnyx or both, comma separated (feature call-control).
BRISVOX_TWILIO_ACCOUNT_SIDunsetThe Twilio account (AC...); required with Twilio call control (with TWILIO_AUTH_TOKEN).
BRISVOX_TWILIO_CALLER_IDunsetE.164 caller id of Twilio transfers (the From of a warm transfer's receiving leg; required for warm transfers).
BRISVOX_TWILIO_API_BASEhttps://api.twilio.com/2010-04-01Twilio REST base URL (a regional endpoint, or a test double on loopback).
BRISVOX_TWILIO_HOLD_URLunsetwaitUrl of the conference a warm transfer's caller waits in (hold music).
BRISVOX_TWILIO_STATUS_CALLBACKunsetWhere Twilio posts the status events of a warm transfer's receiving leg; the application behind it calls the callback listener.
TELNYX_API_KEYunsetThe Telnyx API key (v2); required with Telnyx call control.
BRISVOX_TELNYX_CALLER_IDunsetE.164 caller id of Telnyx transfers (required for warm transfers).
BRISVOX_TELNYX_CONNECTION_IDunsetThe Telnyx Call Control application of a warm transfer's receiving leg (required for warm transfers).
BRISVOX_TELNYX_HOLD_URLunsetAudio a warm transfer's caller hears while waiting (without it: silence).
BRISVOX_TELNYX_API_BASEhttps://api.telnyx.com/v2Telnyx REST base URL (a test double on loopback).
BRISVOX_CALLBACK_ADDRoffWhere the callback listener binds (host:port); needs BRISVOX_CALL_CONTROL (feature call-control). See "Callback listener".
BRISVOX_CALLBACK_TOKENunsetThe shared secret of the callback listener, at least 16 characters; required with BRISVOX_CALLBACK_ADDR.
BRISVOX_SMART_TURN_MODEL$BRISVOX_MODELS_DIR/smart-turn-v3.2-cpu.onnxThe Smart Turn model of adaptive turns (feature smart-turn-ort), or off.
BRISVOX_MODELS_DIRmodelsDirectory of the model files.
BRISVOX_EXTRA_CA_FILEunsetTests and benchmarks only. PEM file of CA certificates that every provider adapter trusts besides the public roots (certificate and host name are still verified). It lets the adapters talk TLS to the fake providers of bench/fake_providers, whose certificate is signed by a throwaway CA. Read once, when the first TLS connection is set up; an unreadable or invalid file fails that connection. Not for production: use a real certificate, or BRISVOX_AGENT_TLS_CA for a private agent.
BRISVOX_SMART_TURN_THREADSthe inference pool's threads (half the cores)Threads of the Smart Turn executor: the 20-25 ms analyses run on their own threads so they never delay the VAD windows of other calls. 0 runs them on the VAD's pool as before (use it to compare). One thread is about 55% busy at 200 calls and delays verdicts when turns end together; the default carries ~180 calls per thread with headroom. Small machines: the default is half the cores (at least 1), so 1-3 cores give 1 executor thread; the server logs a warning at start when the VAD pool, the executor and the ONNX Runtime intra-op workers together exceed the available cores.

A listener address is host:port or off. Without any listener (BRISVOX_WS_ADDR=off and the others unset) the binary serves its health endpoints only and needs no agent file.

Port placeholder. The listener address variables (BRISVOX_HEALTH_ADDR, BRISVOX_WS_ADDR, BRISVOX_TWILIO_ADDR, BRISVOX_TELNYX_ADDR, BRISVOX_RTVI_ADDR, BRISVOX_SIP_ADDR, BRISVOX_SIP_TLS_ADDR, BRISVOX_WEBRTC_ADDR, BRISVOX_WEBRTC_UDP_ADDR, BRISVOX_WEBRTC_TCP_ADDR, BRISVOX_CALLBACK_ADDR) accept the exact token ${PORT}, replaced by the PORT variable that PaaS hosts (Railway, Heroku, Fly, Cloud Run) inject: BRISVOX_WS_ADDR=[::]:${PORT}. No other expansion happens ($PORT, ${port}). A value that uses the token while PORT is unset or not a number from 1 to 65535 stops the start with a message naming both variables. A value without the token never reads PORT.

Agent definition in a variable. BRISVOX_AGENT_DEFINITION holds the text of the agent definition instead of a file path (docs/CONFIG.md, "The agent definition from the environment"); brisvox-server check --env validates it offline.

Authentication (docs/SECURITY.md). The generic websocket protocol has no authentication of its own (docs/WEBSOCKET_PROTOCOL.md), and its connections take the same session reservations as every other listener's: anyone who reaches an open one can run the agent on your STT/LLM/TTS keys, and a few hundred idle sockets (each holds a reservation for its 5 s handshake) fill BRISVOX_MAX_SESSIONS, so that signed Twilio calls get 503. That is why it is never on by default next to another listener, and listens on loopback only when it is the only one. And the generic websocket, RTVI, WebRTC signaling and Telnyx listeners refuse to start on a non-loopback address without authentication: set BRISVOX_SESSION_SECRET (your backend mints short-lived single-use tokens; the production way) or the listener's static token (BRISVOX_WS_TOKEN, BRISVOX_RTVI_TOKEN, BRISVOX_WEBRTC_TOKEN, BRISVOX_TELNYX_STREAM_TOKEN; development, logged as a warning), or accept the risk with BRISVOX_INSECURE_LISTENERS=1 (logged as a warning per exposed listener). Loopback binds need nothing. Twilio always verifies its signature; SIP has its own source ACL and digest rule. Terminate TLS in a reverse proxy in front of every public listener.

The WebRTC listener (feature webrtc: cargo build -p brisvox-server --features all-providers,webrtc, which compiles libopus and needs a C compiler and cmake, docs/TESTING.md) hosts brisvox_transport::webrtc (docs/ARCHITECTURE.md "WebRTC transport"; a runnable page: examples/web-assistant). Media is UDP on one shared socket: forward BRISVOX_WEBRTC_UDP_ADDR and set BRISVOX_WEBRTC_CANDIDATE_IP to the address browsers reach. A network that blocks UDP connects through ICE-TCP (BRISVOX_WEBRTC_TCP_ADDR) or a browser-side TURN relay (BRISVOX_TURN_PROVIDER); a server host with no inbound UDP (a one-port PaaS such as Railway) uses the WebSocket fallback or the server's own TURN relay (BRISVOX_WEBRTC_TURN_RELAY, the server allocates a relayed address on a TURN service over TLS/443). All of it is in docs/DEPLOY.md. Differences from the websocket listeners:

  • No gate before the handshake. A call reaches the engine once its ICE and DTLS completed and is admitted then; with no room (BRISVOX_MAX_SESSIONS, overload, provider budget, draining) the browser connects and the call is hung up at once, logged as call without a reservation refused. The transport's own call limit (twice the manager's) bounds the sockets.
  • Authentication. Signaling needs BRISVOX_SESSION_SECRET (a session token per call in Authorization: Bearer) or BRISVOX_WEBRTC_TOKEN (shared, for development) on a non-loopback address, like the other exposed listeners (above): anyone who reaches an open one runs the agent on your provider keys. Signaling must be served over HTTPS (a proxy in front of the TCP port): the DTLS fingerprint travels in the SDP, and over plain HTTP an on-path attacker can swap it and hear the call. DELETE is authorized by the session URL when session tokens are in use. A session token's max_s caps the call.
  • Flood limits. Per client address: 5 offers a minute (BRISVOX_WEBRTC_OFFERS_PER_MINUTE, 429), 8 calls at once (BRISVOX_WEBRTC_MAX_CALLS_PER_IP, 429); calls still handshaking are capped at a quarter of BRISVOX_MAX_SESSIONS (BRISVOX_WEBRTC_MAX_HANDSHAKING, 503). Behind a proxy set BRISVOX_TRUSTED_PROXIES so the address is the visitor's, not the proxy's. The counters are in WebRtcServer::stats().
  • Codec on the inference pool. The Opus work of every call (90 to 376 us a frame) runs on the server's inference pool, never on tokio workers.
  • Like every listener it stops with the server: the cancel stage ends its calls.

The SIP listener needs BRISVOX_SIP_ALLOWED_SOURCES or the digest credentials (or both): an open SIP port is scanned within minutes, and the binary refuses to start without either. For a trunk across the internet add SIPS and SRTP: BRISVOX_SIP_TLS_ADDR (5061) with BRISVOX_SIP_TLS_CERT and BRISVOX_SIP_TLS_KEY, and BRISVOX_SIP_SRTP=required. SDES keys are only ever taken from TLS signaling, so with required the cleartext UDP/TCP port refuses every call (488); keep it for health checks or close it with the ACL. The certificate must be one the carrier trusts (a public CA for Twilio and Telnyx). SipStats counts tls_connections, tls_rejected, tls_handshake_failures, tls_idle_closed, srtp_calls, rejected_insecure, srtp_packets_rejected, srtp_flood_hangups and insecure_dialog_requests (in-dialog requests of a TLS dialog that did not come over TLS: refused or stripped of their SDP; a steady count means someone on the signaling network is probing). Never run the server with RUST_LOG lifting rsipstack (it cannot: the filter is fixed, see BRISVOX_LOG_SIP_STACK). A start-up warning from rsipstack about an unsupported listener type is expected with SIPS (the placeholder that makes requests to TLS callees carry a TLS Via).

Durations are a whole number with ms, s or m (500ms, 45s, 10m); a bare number is seconds; at most one day.

Callback listener

With a carrier's call control (BRISVOX_CALL_CONTROL, feature call-control) and BRISVOX_CALLBACK_ADDR set, the binary serves a small HTTP listener through which the world tells a live call what the carriers' REST call control cannot return (the endpoints, the authentication and the limits are in brisvox_server::callbacks):

RequestEffect
POST /v1/calls/{twilio|telnyx}/{call}/warm/completeThe receiving party of a warm transfer accepted: they join the caller, the bot leaves the call. 200 {"status":"joined"}.
POST /v1/calls/{twilio|telnyx}/{call}/warm/abortThey did not answer, declined or are a machine: their leg ends and the caller goes back to the bot (Twilio; the bot says so) or, with no way back (Telnyx), is hung up. 200 {"status":"aborted","caller":"resumed|parked"}.
POST /v1/amd/twilioA Twilio answering-machine callback (AsyncAmdStatusCallback, form body): the verdict goes to the call's voicemail handling (docs/TELEPHONY.md 6). 200 {"status":"delivered"}, or 202 {"status":"queued"} when the call has not started yet: an asynchronous detector can beat the media stream, so the verdict waits up to a minute for its call (256 at most).
POST /v1/amd/telnyxA Telnyx answering-machine webhook (JSON body).

{call} is the carrier's id of the caller's call: Twilio's CallSid, Telnyx's call_control_id. The application that originates or receives the call has it; it is also in the call's stream metadata.

Every request needs the shared secret as Authorization: Bearer <secret>, or as the password of Authorization: Basic for carriers that can only put credentials in a URL (https://user:<secret>@host/v1/amd/twilio); the compare is constant time and a request without it gets 401 before its body is read. The secret travels in a header: serve the listener over TLS (a proxy) and keep it on a private address when you can. Carrier webhooks are not signature-checked by this listener. Errors are {"error":"<code>","message":"..."}: 404 no_such_call (the call ended, or never was), 409 no_warm_transfer / no_voicemail, 502 carrier_error, 504 timeout, 400/413/431/501 for malformed requests.

The listener is bounded: 32 requests at once (more are closed unanswered), 5 s to read a request or write the answer, 25 s for the handler, head 8 KiB, body 16 KiB, no keep-alive and no chunked bodies. It serves through the drain (a call that is still running may need its callbacks) and stops after the transports. It is a separate port from the health probes on purpose: it changes calls and is authenticated; the probes are neither.

A warm transfer nobody finishes does not leave the caller alone: when the call's session ends (the stream's linger passed, shutdown) with the transfer pending, the server aborts it and hangs the caller up.

Agent process

VariableDefaultMeaning
BRISVOX_AGENT_URLunsetThe agent process every call links to (ws:// or wss://, docs/AGENT_PROTOCOL.md). Unset: calls run on the agent file alone.
BRISVOX_AGENT_TOKENunsetSent as Authorization: Bearer <token> to the agent; never logged.
BRISVOX_AGENT_ALLOW_INSECUREoff1 accepts a plaintext ws:// agent URL whose host is outside the private networks (loopback, RFC 1918, link-local, localhost, *.internal, *.local, a single-label service name, which gets a one-time warning because a resolver's search list can make it public); otherwise such a URL fails the startup. Logs a warning.
BRISVOX_AGENT_CALLER_IDENTITYoff1 sends the caller's and called number and the carrier's call id of telephony calls to the default agent process (session.start.call, docs/AGENT_PROTOCOL.md, "Caller identity"). Withheld while phone numbers are redacted (docs/PRIVACY.md). The definition's [agent_link] caller_identity wins; routes never inherit it.
BRISVOX_AGENT_TLS_CAunsetPEM bundle of a private CA, trusted besides the public roots for wss:// agent URLs.
BRISVOX_AGENT_CONNECT_TIMEOUT_MS2000Bound on connecting to the agent.
BRISVOX_AGENT_HANDSHAKE_TIMEOUT_MS2000Bound on the session.start write and the agent's session.accept, together.
BRISVOX_AGENT_RECONNECT_ATTEMPTS5Reconnection attempts per series for an agent that declared resume; 0 never reconnects.
BRISVOX_AGENT_RECONNECT_WINDOW_MS15000No new reconnection attempt starts later than this after the link was lost.

An agent that is down, slow or rejects a call never fails the call: it runs on the agent file (logged as a warning), and a link that fails during a call leaves the call running on the agent file's tools. The call waits for its agent before it starts: the caller hears up to CONNECT_TIMEOUT_MS + HANDSHAKE_TIMEOUT_MS (4 s by default) of silence when the agent URL is unreachable or slow, so keep both small. The agent's transfer command goes through the call's carrier call control (BRISVOX_CALL_CONTROL, docs/TELEPHONY.md) when the call has one, and answers unsupported otherwise. A link that fails during a call is reopened (bounded backoff) when the agent declared resume; the definition's [agent_link] section and the route named by a call's start message pick the agent process per definition and per call (docs/AGENT_PROTOCOL.md, "Reconnecting" and "Routing").

Admission

Every listener admits through the one session manager (brisvox_server::hosting):

  • Right after the TCP accept, before the websocket upgrade, the listener reserves a session (SessionManager::try_admit). No room (BRISVOX_MAX_SESSIONS calls live or reserved, counting every listener): HTTP 503 with Retry-After: 1. Draining: HTTP 503 without Retry-After (go to another instance). No upgrade, no work spent on a refused connection.
  • SIP reserves on every new INVITE, before the digest challenge and the SDP: no room is a 503 with Retry-After, draining a 503 without it. The reservation is held through the 200 OK and the ACK; an INVITE that does not become a call (refused, cancelled, no ACK) gives it back.
  • The reservation travels with the connection through the handshake and the protocol's start; the call's session is spawned into it. A connection that never becomes a call (a failed handshake, a start timeout, a bad Twilio signature) gives the reservation back.
  • /readyz and the listeners agree: full when every slot is taken, overloaded or provider_budget while admission control refuses (below), draining once the drain began.
  • Each listener also bounds its sockets at twice BRISVOX_MAX_SESSIONS (a connection keeps its socket through the close handshake after its call ended; SIP: its own call limit, at most 100000); the session limit is the one that refuses calls.

Pool pressure and provider budgets

After the drain and session-limit checks, try_admit applies two more rules, so a server that is not at BRISVOX_MAX_SESSIONS still refuses calls it cannot serve well. Every refusal is counted by reason and is answered cleanly by the transport that met it.

  • Pool pressure. The calls' DSP and inference run on the shared inference pool, so when it falls behind every call degrades together; so do the Smart Turn analyses when their executor falls behind (a late analysis delays the end of the turn), so that executor is judged too, on its queue waits and refused or timed-out jobs (not its depth: its jobs take 20-25 ms, so a few queued ones are ordinary), and either one hot refuses new calls. The server reads the pools at each admission attempt and each /readyz probe: queued jobs per pool thread, jobs refused or timed out, and the mean time jobs waited in the queue over BRISVOX_OVERLOAD_WINDOW (a job queues only while the cores are busy, so the wait is the CPU signal; it is judged only with at least 8 jobs in the window). It turns overloaded on the first sample over the wait limit or with new refused or timed-out jobs, and on a queue that has stayed over its limit for BRISVOX_OVERLOAD_QUEUE_CONFIRM (a burst of call starts makes a short queue that drains in microseconds: on a 2-thread pool, 50 calls dialing within a second refused dozens of them with the pool almost idle until the confirmation was added). It recovers only after one full window of samples at or below the resume limit (hysteresis: a pool hovering at the limit does not flap). Sampling is lazy (no background task): an orchestrator polling /readyz also advances the recovery.
  • Learned ceiling. The pressure signals lag the calls: a call admitted now loads the pool a second or more later, when its caller speaks. Measured on Railway (4 vCPU, a capacity of about 250 calls), dialing 900 calls within seconds admitted about 730 before the pool turned hot, and every call degraded (V2V p50 3.7 s). With BRISVOX_OVERLOAD_CEILING (default) the pool turning hot caps the held calls at 90% of the calls held then, the pool turning calm caps them at the calls held at that moment (what it served well), and the cap grows by 10% per calm window spent at it, so it settles near what the machine serves. Running calls are never ended to get under it. Until the pool was first seen hot it starts cautiously: at most 16 calls, doubling per calm window spent at the cap (16, 32, 64, ... every 2 s), so a rush of dials on a fresh server (a restart, a new instance behind a least-connections balancer) cannot overshoot before the pool shows its load. For a hard limit from the first second, set BRISVOX_MAX_SESSIONS to your measured capacity. One hot spell lowers the cap to at most half of the cap it started from, and never below 16 calls; an idle spell longer than two windows is not a recovery. At the cap /readyz reports overloaded, as it does when the Smart Turn executor is behind.
  • Provider budgets. With BRISVOX_PROVIDER_LIMITS, every call holds one permit of each limited provider its agent uses (the STT, LLM and TTS named in the agent file) for its whole life; permits are all-or-none and come back on every end path (hang-up, error, panic, cancel, abort, a connection that never became a call). A limit for a provider the agent does not use is ignored with a warning. Because every call needs a permit of every limited provider, one exhausted provider means no call can be admitted until one ends.
  • Refusal per transport. Websocket, Twilio, Telnyx and RTVI listeners answer the HTTP upgrade with 503 and Retry-After: 1 s at the session limit, 5 s overloaded, 2 s provider budget, none when draining; the body names the reason (server busy, server overloaded, provider capacity reached, server shutting down). SIP answers 503 with Retry-After for all three, none when draining.
  • Metrics. /readyz carries the counters and the numbers behind the verdict (below), and SessionManager::stats has rejected_at_capacity, rejected_overloaded, rejected_provider_budget and rejected_shutting_down. Entering and leaving overload is logged once each (brisvox::server, warn and info).

Health endpoints

Served on BRISVOX_HEALTH_ADDR over plain HTTP/1.1, GET or HEAD, one request per connection (Connection: close), Cache-Control: no-store.

Path200 when503 whenBody
/healthzthe process serves the listener (also while draining)never{"status":"ok"}
/readyzsessions are admitteddraining, every slot is taken, the pool is overloaded or a provider budget is exhaustedsee below
/metricsBRISVOX_METRICS is not offnever (404 when off, or on a non-loopback address without BRISVOX_METRICS_TOKEN; 401 without the bearer token)Prometheus text, see Observability
{"status":"ready","accepting":true,"active":3,"reserved":0,"max":256,
 "rejected":{"full":0,"overloaded":0,"provider_budget":0,"shutting_down":0},
 "pressure":{"overloaded":false,"queue_depth":0,"threads":4,"queue_per_thread_milli":0,"mean_wait_us":120,"entered":0},
 "providers":[{"name":"deepgram","limit":50,"in_use":3,"rejected":0}]}

status is ready, full (at max; accepting stays true, a slot frees when a call ends), overloaded (pool pressure, see above), provider_budget (some provider has no permit left) or draining (accepting false, permanent). pressure is present when the server hosts calls (it reads the inference pool); queue_per_thread_milli is queued jobs per thread times 1000, mean_wait_us the mean queue wait of the last judged window and entered how many times the server turned overloaded. providers lists the limited providers. active counts running sessions, reserved the admissions a transport accepted but has not started yet. Other paths answer 404, other methods 405, a malformed request 400, a request head over 4 KiB 431. At most 32 probes are served at once (more are closed unanswered) and each has 2 s to send its request and read the answer.

Use /healthz as the liveness probe and /readyz as the readiness probe. Liveness never fails because of a drain or a full server, so neither restarts the container.

/metrics and the health address. The probes stay open on 0.0.0.0:8081 by default: Kubernetes probes the pod's address and a container runtime probes from outside the network namespace, so a loopback default would break both, and they carry only counts. /metrics (call rates, error rates, latencies) is served without a token only on a loopback health address. On any other address it answers 404 until BRISVOX_METRICS_TOKEN is set, and then requires Authorization: Bearer <token> (constant time, 401 otherwise); the server logs a warning when it turns /metrics off for this reason. BRISVOX_INSECURE_LISTENERS=1 serves it openly (private networks). Prometheus: authorization: { credentials_file: /path/to/token } in the scrape job.

Observability

Metrics, traces and log redaction. Code: brisvox_server::observability; conventions: docs/TRACING.md.

VariableDefaultMeaning
BRISVOX_METRICStrueServe GET /metrics on the health listener. The counters are kept either way.
BRISVOX_TRACESoffotlp ships one trace per turn to an OpenTelemetry collector (needs the otlp build feature).
BRISVOX_OTLP_ENDPOINThttp://127.0.0.1:4318/v1/traceshttp://host[:port][/path] of the collector's OTLP/HTTP receiver. Plain HTTP only: run a collector or agent next to the server and let it carry TLS and credentials to the backend.
BRISVOX_OTLP_QUEUE1024Traces waiting for the exporter task; when full the newest is dropped and counted.
BRISVOX_OTLP_BATCH64Most traces per request.
BRISVOX_OTLP_FLUSH2sLongest wait before a partial batch is sent.
BRISVOX_OTLP_TIMEOUT5sDeadline of one request (connect, send, answer). A failed batch is dropped and counted, never retried.
BRISVOX_OTLP_SERVICEbrisvox-serverThe service.name resource attribute.

Build the OTLP exporter with --features otlp. It has no dependency of its own.

Prometheus metrics

GET /metrics on BRISVOX_HEALTH_ADDR (text exposition 0.0.4). Scrape it every 5 to 15 s; the cost of a scrape is a few hundred atomic reads and one string. Nothing is recorded per audio frame: histograms and counters are updated once per finished turn, per call end, or read at scrape time.

FamilyTypeLabelsMeaning
brisvox_stage_latency_secondshistogramstageOne observation per finished turn: end_of_turn, stt_final, llm_first_token, llm_first_sentence, tts_first_byte, into_llm, out_of_tts, post_endpoint, response, time_to_silence; llm_round has one per LLM request. Definitions: docs/LATENCY.md.
brisvox_turns_totalcounteroutcome (completed, interrupted)Turns finished.
brisvox_sessions_active, _reserved, _max, _peakgaugeSession manager.
brisvox_sessions_started_total, brisvox_sessions_ended_total{reason}counterreasonSessions started and ended (completed, failed, panicked, cancelled, aborted).
brisvox_session_admissions_rejected_totalcounterreason (full, overloaded, provider_budget, shutting_down)Admissions refused.
brisvox_pool_overloaded, _queue_depth, _mean_wait_seconds, brisvox_pool_overload_entered_totalgauge/counterInference pool pressure; present when the server hosts calls.
brisvox_smart_turn_queue_depth (gauge), brisvox_smart_turn_rejected_total, brisvox_smart_turn_timed_out_total (counters)gauge/counterThe Smart Turn executor's own queue: analyses waiting, refused because the queue was full, and answered too late. Absent with BRISVOX_SMART_TURN_THREADS=0 (the analyses then share the VAD pool and show in brisvox_pool_*). Admission control reads only the VAD pool, so these are the only signal of an overloaded executor.
brisvox_provider_concurrency_limit, _in_use, brisvox_provider_budget_refusals_totalgauge/counterproviderPer-provider concurrency budgets (BRISVOX_PROVIDER_LIMITS).
brisvox_provider_errors_totalcounterstage, kindProvider failures the engine counted (connect failures, session deaths, LLM request/stream errors and timeouts, TTS send failures and stalls), added when a call ends.
brisvox_engine_events_totalcountereventEngine trouble of finished calls: queue and stale drops, pool trouble, finals-gate, settle and mark timeouts, truncations.
brisvox_calls_ended_totalcounterCalls whose end-of-call statistics were added.
brisvox_breaker_stategaugestage, role, providerCircuit breaker: 0 closed, 1 half-open, 2 open.
brisvox_breaker_events_totalcounterstage, role, provider, eventAttempts admitted, refused, failed, probes, openings, closings.
brisvox_retry_budget_available, brisvox_retry_budget_total{outcome}gauge/counteroutcome (granted, refused)The process retry budget.
brisvox_resilience_events_totalcounterstage, eventPer-call resilience counters summed: attempts, retries, failovers (served by the secondary), breaker_skips, budget_refusals, unavailable, recoveries, recovery_failures, replayed, idle_refreshes. Added at every finished turn and when a call ends.
brisvox_traces_total, brisvox_trace_batches_totalcounteroutcomeTraces built and dropped; OTLP batches sent and failed.

Useful alerts: rate(brisvox_smart_turn_rejected_total[5m]) > 0 or rate(brisvox_smart_turn_timed_out_total[5m]) > 0 (the Smart Turn executor is overloaded: every affected turn ends at its fallback silence deadline instead of the model's verdict; add executor threads with BRISVOX_SMART_TURN_THREADS or shed calls); brisvox_breaker_state == 2 for more than a minute (a provider is down); rate(brisvox_resilience_events_total{event="failovers"}[5m]) > 0 (calls are running on a secondary); rate(brisvox_resilience_events_total{event="budget_refusals"}[5m]) > 0 (the retry budget is spent: an outage is bigger than the budget allows); rate(brisvox_session_admissions_rejected_total[5m]) by reason; histogram_quantile(0.95, sum by (le) (rate(brisvox_stage_latency_seconds_bucket{stage="response"}[5m]))).

Traces

With BRISVOX_TRACES=otlp each finished turn is one trace: a root span turn (attributes brisvox.session.id, brisvox.agent, brisvox.turn.epoch, brisvox.turn.interrupted, brisvox.turn.llm_rounds, the response latency) and child spans user_speech, end_of_turn, stt_final, llm (one per request of a tool loop), tts, audio_out and, after a barge-in, interruption. The spans carry the real timing of the call's stages (the per-stage marks of the V2V bench), built once when the turn is over: tracing adds no cost while a turn runs. An interrupted turn is exported when the output reports silence (or when the next turn ends), so its interruption span is complete.

Logs

BRISVOX_LOG_FORMAT=json writes one JSON object per line. Every line is scrubbed before it is written: the values of environment variables that look like secrets (*_KEY, *_TOKEN, *_SECRET, *_PASSWORD, *_CREDENTIAL, AUTH_*) are replaced by [REDACTED] wherever they occur, and so are values after credential-looking names (api_key=, "token":"...", Authorization: Bearer ..., ?key=...) and the password of scheme://user:password@host. The scrub over-redacts rather than under-redacts. Credentials in code are brisvox_core::Secret anyway; this is the second line of defence.

The line call admitted (target brisvox::server, info, one per started call) carries the fields transport (websocket, webrtc, twilio, ...), session (the engine session id) and call_id: the transport's own id of the call, which for the websocket transport is the server-assigned ws-<n> (a counter per listener, not the call_id a client may send in start, which is only kept as call metadata client_call_id), and - for a transport that does not label its calls. Use it to match the line to a benchmark or client log of the same call, then follow session through the rest of the call's lines.

Recording and privacy

Optional transcript and audio recording, PII redaction and retention. Off unless BRISVOX_RECORDING_DIR is set; what is written, what the redaction does not do and a checklist for the operator (not legal advice) are in docs/PRIVACY.md. A recording variable without the directory is a start error.

VariableDefaultMeaning
BRISVOX_RECORDING_DIRunsetDirectory for call-*.transcript.jsonl, call-*.caller.wav, call-*.bot.wav; created at start.
BRISVOX_RECORDING_TRANSCRIPTStrueWrite each call's transcript (older name BRISVOX_RECORD_TRANSCRIPTS still works).
BRISVOX_RECORDING_AUDIOfalseWrite the caller's and the bot's audio (never redacted; older name BRISVOX_RECORD_AUDIO).
BRISVOX_RECORDING_REDACT_PIItrueReplace emails, IBANs, national IDs, card and phone numbers in transcripts before they are written (older name BRISVOX_REDACT_PII).
BRISVOX_RECORDING_REDACT_KINDSallWhich kinds: email,iban,id,card,phone,number.
BRISVOX_RECORDING_RETENTION30dDelete recordings this old (15m, 36h, 30d, at least 1h); forever keeps them.
BRISVOX_RECORDING_SWEEP_INTERVAL1hTime between retention sweeps (at least 1m).
BRISVOX_RECORDING_MAX_MINUTES120Longest audio kept per call and track (1 to 600).
BRISVOX_RECORDING_AGE_RECIPIENTSunsetComma-separated age public keys (age1...): recordings are written encrypted as <name>.age; the server holds no private key. Needs a build with the cargo feature recording-encryption (docs/PRIVACY.md, "Encryption at rest").
BRISVOX_LOG_REDACT_PIIfalseAlso scrub emails, IBANs, national IDs, card and phone numbers from log lines (docs/PRIVACY.md, "Logs").
BRISVOX_LOG_REDACT_KINDSemail,iban,id,card,phoneThe kinds the log scrub replaces; needs BRISVOX_LOG_REDACT_PII=true.

The agent file's [recording] table (docs/CONFIG.md) overrides the content settings above. Use a directory dedicated to the recordings: the retention sweeper reads every entry of it.

Provider resilience settings

The circuit breakers and retry budgets of the resilience layer (docs/PROVIDERS.md). The defaults are the ones in the table; each applies process-wide.

VariableDefaultMeaning
BRISVOX_BREAKER_FAILURES5Consecutive failures that open a provider's breaker (1 to 1000).
BRISVOX_BREAKER_OPEN_FOR30sHow long an open breaker refuses attempts before one probe is let through.
BRISVOX_CALL_RETRY_BUDGET3Retries (failovers, recoveries) one call may make.
BRISVOX_CALL_RETRY_REFILL20sOne retry token comes back to a call per this long; 0s keeps the budget full.
BRISVOX_PROCESS_RETRY_BUDGET50Retries the whole process may make.
BRISVOX_PROCESS_RETRY_REFILL100msOne token comes back to the process budget per this long; 0s keeps it full.
BRISVOX_PROVIDER_ATTEMPT_TIMEOUT2sDeadline of a provider attempt that may be followed by another.
BRISVOX_LLM_FIRST_EVENT4sWith a secondary LLM: silence after which a response is re-issued to the secondary.
BRISVOX_PROVIDER_HEALTHY_AFTER10sHow long a session must live to count as healthy.

The agent file names the secondaries ([stt|llm|tts].secondary, docs/CONFIG.md); these variables set how eagerly the process fails over to them. Breaker states, the budget and the failovers are on /metrics (above).

Shutdown

Signals: SIGTERM or SIGINT on Unix; Ctrl-C or Ctrl-Break on Windows.

  1. Drain starts at once. The session manager stops admitting: /readyz answers 503 draining, and every listener answers new connections with HTTP 503 before the upgrade (see Admission; the caller retries elsewhere). The listeners stay bound, so running calls keep their connections. /healthz stays 200. An admission a listener reserved before the signal is honoured: that call runs and is drained like the others.
  2. Wait. Running calls get BRISVOX_DRAIN_TIMEOUT (on the injected clock) to end by themselves. The drain ends as soon as none is left. A second signal ends this stage early (an operator who pressed Ctrl-C twice wants out).
  3. Cancel. Every remaining session's cancellation token fires; sessions stop their pipeline (bounded by the engine's stop timeout) and return within BRISVOX_CANCEL_GRACE. The listeners' connections are closed at the same moment (the client gets the protocol's stop/close), and a connection still in its handshake gives its reservation back. The SIP listener hangs up its calls with a BYE (bounded by 2 s) while its signaling endpoint still runs, and an INVITE still waiting for its ACK gives its reservation back.
  4. Abort. Sessions still running are aborted (their task is dropped; drop guards clean the registry) and get BRISVOX_ABORT_WAIT.
  5. Exit. The listeners stop (each transport's shutdown is bounded by 2 s), the health listener closes (up to 1 s), then the tokio runtime is shut down with a 1 s bound (RUNTIME_SHUTDOWN_TIMEOUT): a blocking task left behind by an aborted session (inference, an offload pool shutdown) is abandoned instead of holding the exit, so the status below is still reported. The worst case is the sum of the three timeouts plus about seven seconds (the figure the container stop timeouts in docs/DEPLOY.md use).

Exit status:

StatusMeaning
0Every session ended by itself (or none was running).
1Startup failed: invalid setting, agent definition invalid or naming tools, a provider that does not build, a listener or health address not bindable, no signal handler.
3Some sessions were cancelled (drain timeout or second signal) and stopped in time.
4Some sessions ignored cancellation and were aborted.
5Some sessions were still registered after the abort stage (a bug; alert on it).

The drain's result is logged once (drain finished, with initial, drained, cancelled, aborted, leaked, forced, elapsed).

Kubernetes

Set terminationGracePeriodSeconds above drain + cancel_grace + abort_wait + 7 s (default 30 + 5 + 1 + 7 = 43 s; use 45 s or more), otherwise the kubelet's SIGKILL cuts the drain. Calls are long: a drain timeout of several minutes with a matching grace period lets them end naturally. The pod leaves the Service endpoints when it is deleted; the readiness flip at drain start covers load balancers that only watch /readyz. No pre-stop sleep is needed: the listeners refuse new calls with 503 from the first moment of the drain instead of accepting and then dropping them.

Probes need no token (/healthz, /readyz); give Prometheus BRISVOX_METRICS_TOKEN from a Secret.

livenessProbe:  { httpGet: { path: /healthz, port: 8081 }, periodSeconds: 10 }
readinessProbe: { httpGet: { path: /readyz,  port: 8081 }, periodSeconds: 2 }
terminationGracePeriodSeconds: 45

Docker / systemd

docker stop sends SIGTERM and kills after 10 s by default: pass --time 45 (or stop_grace_period: 45s in compose). For systemd use KillSignal=SIGTERM and TimeoutStopSec=45.

The container image, its healthcheck and an example compose file with a websocket and a Twilio agent are in docs/DEPLOY.md.

Deploying brisvox-server in a container

The container image, the example compose file and how to put them behind TLS. The process itself (settings, listeners, admission, drain, exit status) is docs/OPERATIONS.md; agent definitions are docs/CONFIG.md; the carriers are docs/TELEPHONY.md.

Files: Dockerfile, .dockerignore, deploy/compose.yaml, deploy/agents/{web,phone}.toml, deploy/{ws,twilio}.env.example, deploy/healthcheck.sh.

The image

Releases publish it as ghcr.io/bilal07karadeniz/brisvox-server (tags vX.Y.Z and latest, linux/amd64 and linux/arm64), built with BRISVOX_FEATURES=all-providers,call-control,smart-turn-ort,webrtc (docs/RELEASING.md). To build it yourself, with another feature set if you need one:

docker build -t brisvox/server .
# another feature set (the SIP trunk listener, a smaller provider list):
docker build -t brisvox/server --build-arg BRISVOX_FEATURES=all-providers,call-control,smart-turn-ort,sip .

Build arguments: BRISVOX_FEATURES (cargo features of brisvox-server on top of its defaults mimalloc, yaml, silero-tract; default all-providers,call-control,smart-turn-ort), STRIP_BINARY (1), RUST_VERSION (1.99.0, checked against rust-toolchain.toml so the two cannot drift), DEBIAN_RELEASE (trixie; not bookworm, see below), BUILD_JOBS (empty: cargo uses every core; 4 caps rustc jobs for small build hosts), CARGO_CHEF_VERSION (0.1.78), PYTHON_VERSION (3.12). The build needs network access (crates, the ONNX Runtime binaries that ort downloads, the Smart Turn model) and BuildKit (docker build on current Docker).

StageBaseDoes
chefrust:1.99.0-slim-trixiecompiler, build-essential, cmake, cargo-chef
plannerchefcargo chef prepare: the dependency recipe (recipe.json)
builderchefcargo chef cook (dependencies only, cached until a Cargo.toml/Cargo.lock changes), then cargo build --release --locked -p brisvox-server, stripped
modelspython:3.12-slim-trixiescripts/models/fetch_models.py: pinned downloads, SHA-256 verified, the build fails on a mismatch
runtimedebian:trixie-slimca-certificates, libstdc++6, a non-root user, the binary, the model

What is in the runtime image. One binary (/usr/local/bin/brisvox-server, uid/gid 10001, exec-form ENTRYPOINT, so it is PID 1 and gets SIGTERM itself), the Smart Turn model (/opt/brisvox/models/smart-turn-v3.2-cpu.onnx, 8.7 MB, BRISVOX_MODELS_DIR), the two reference agent definitions in /etc/brisvox/agents/, the health probe script, CA certificates (TLS to the providers and the carriers) and libstdc++6 (the ONNX Runtime of smart-turn-ort is linked statically but needs the C++ runtime). The Silero VAD is compiled into the binary. No shell tooling beyond what debian-slim has, no curl, no package manager state (apt lists are removed).

Size (measured 2026-10-13, docker build on WSL2/Docker 29.8, default features). docker images: 64.5 MB of compressed content, 160.7 MB unpacked (du -sx / in the container; the DISK USAGE column, 231 MB, also counts the containerd snapshot copy). Layers: debian trixie-slim 87.7 MB, ca-certificates + libstdc++6 4.7 MB, stripped brisvox-server 65.4 MB (every provider adapter, the Silero VAD, mimalloc, the static ONNX Runtime), Smart Turn model 8.7 MB, the probe and the reference agents 45 kB. The first estimate of this page (90 to 120 MB) was low: the static ONNX Runtime makes the binary 65 MB. Build time on 4 cargo jobs (--build-arg BUILD_JOBS=4): 411 s cold (dependency layer, binary and model); a source-only change rebuilds just the last cargo layer.

Why trixie (Debian 13) and not bookworm. With bookworm the link step failed: the prebuilt ONNX Runtime that ort downloads for smart-turn-ort references glibc 2.38 symbols (__isoc23_strtol, __isoc23_strtoull) and a GCC 13 libstdc++ (std::__cxx11::basic_string::_M_replace_cold), and bookworm has glibc 2.36 and GCC 12. The build stage and the runtime stage must both be trixie (the runtime's libstdc++6 must be at least as new as the one the binary was linked against). The DEBIAN_RELEASE build argument still selects the release, so this can be revisited when ort ships older-glibc binaries.

Why debian-slim and not distroless. distroless/cc would save about 15 MB, but it has no shell, so the HEALTHCHECK would need either a probe subcommand in the binary or a separate static binary. deploy/healthcheck.sh uses bash's /dev/tcp, which debian-slim has, and the orchestrators that do their own HTTP probes (Kubernetes) can ignore it. If the size matters more, switch the runtime stage to gcr.io/distroless/cc-debian12:nonroot, copy libstdc++ if needed and drop the HEALTHCHECK for an external probe.

Models: baked in, not mounted. The model is 8.7 MB, pinned by hash and tied to the smart-turn-ort feature of this binary; baking it makes the image self-contained and immutable (no volume to provision, no unverified file at startup). To use another model mount it and set BRISVOX_SMART_TURN_MODEL, or set it to off.

Healthcheck. HEALTHCHECK runs deploy/healthcheck.sh: GET /healthz on BRISVOX_HEALTH_ADDR (default 0.0.0.0:8081), healthy on a 200. The probe dials the configured host, or loopback when that is a wildcard (0.0.0.0, [::], empty), so a specific bind such as 10.0.0.5:8081 or [::1]:8081 stays healthy. /healthz is liveness: it stays 200 during a drain and when the server is full, so docker never restarts a draining container. Load balancers that route calls should watch /readyz (503 while draining, full, overloaded or out of a provider budget). Probe timing: every 10 s, 4 s timeout, 15 s start period, 3 retries.

Stopping. STOPSIGNAL SIGTERM starts the drain (docs/OPERATIONS.md, "Shutdown"). Give the container a grace period above BRISVOX_DRAIN_TIMEOUT + BRISVOX_CANCEL_GRACE + BRISVOX_ABORT_WAIT + 7 s (43 s by default): docker stop -t 45, compose stop_grace_period: 45s, Kubernetes terminationGracePeriodSeconds: 45. For long calls raise the drain timeout and the grace period together.

Configuration is environment variables (docs/OPERATIONS.md) and one agent file mounted at BRISVOX_AGENT_FILE (default /etc/brisvox/agent.toml; the image does not contain one at that path, mount yours, or point the variable at a reference file such as /etc/brisvox/agents/web.toml). A host that cannot mount a file (a one-port PaaS such as Railway) passes it in a variable or bakes it in with a one-line Dockerfile layer: see "The agent definition" in "Platforms with one HTTPS port and no UDP (PaaS)" below. The image enables no call listener: choose one with BRISVOX_WS_ADDR, BRISVOX_TWILIO_ADDR, BRISVOX_TELNYX_ADDR, BRISVOX_RTVI_ADDR (BRISVOX_SIP_ADDR with the sip feature). Inside a container a listener must bind 0.0.0.0 to be reachable through a published port.

The generic websocket listener (and RTVI, WebRTC signaling, Telnyx) refuses to bind a non-loopback address without credentials (docs/SECURITY.md): inside a container that means BRISVOX_SESSION_SECRET (or the listener's static token), or BRISVOX_INSECURE_LISTENERS=1 when the published port is loopback-only, as below. /metrics is off on the container's 0.0.0.0:8081 health address until BRISVOX_METRICS_TOKEN is set; /healthz and /readyz work as before.

docker run --rm -p 127.0.0.1:8080:8080 -p 127.0.0.1:8081:8081 --stop-timeout 45   --ulimit nofile=65536:65536 \
  -e BRISVOX_WS_ADDR=0.0.0.0:8080 -e BRISVOX_INSECURE_LISTENERS=1 --env-file deploy/ws.env \
  -v "$PWD/deploy/agents/web.toml:/etc/brisvox/agent.toml:ro" brisvox/server

Compose: a websocket agent and a Twilio agent

deploy/compose.yaml runs the same image twice with different definitions and listeners:

ServiceListenerAgentPublishes (loopback)
agent-wsgeneric websocket, 0.0.0.0:8080deploy/agents/web.toml: Deepgram + OpenAI + Cartesia8080 (calls), 8081 (health)
agent-twilioTwilio Media Streams, 0.0.0.0:8082, path /twilio/streamdeploy/agents/phone.toml: AssemblyAI + Groq + Deepgram Aura, tools transfer_call and end_call over Twilio call control8082 (calls), 8083 (health)
cp deploy/ws.env.example deploy/ws.env            # keys for agent-ws
cp deploy/twilio.env.example deploy/twilio.env    # keys and Twilio settings for agent-twilio
docker compose -f deploy/compose.yaml up --build
curl http://127.0.0.1:8081/readyz

deploy/*.env is git-ignored (deploy/.gitignore); the templates carry names only, no values. Both services run hardened (read-only root filesystem with a small /tmp tmpfs, all capabilities dropped, no-new-privileges, a process limit) with a 45 s stop grace period. Edit the two agent files for your prompt, voices and destinations: they are validated like the files in examples/agents. The Twilio file's +1555... number is a placeholder.

Twilio setup: set BRISVOX_TWILIO_PUBLIC_URL=wss://agent.example.com/twilio/stream and a BRISVOX_TWILIO_STREAM_SECRET of at least 32 random bytes. Your number's voice webhook answers every call with TwiML whose stream URL is that URL plus a per-call segment, <Connect><Stream url="wss://agent.example.com/twilio/stream/CAxxxx.1760000120.mac"/></Connect>, which your webhook computes from the call's CallSid (a few lines of HMAC: docs/providers/twilio.md, "Per-call stream URLs"; a ready webhook that does it, with tests, is examples/phone-receptionist/twiml_server.py, which reads the same BRISVOX_TWILIO_STREAM_SECRET). The server checks the segment (one use, until its expiry, for that call only) and X-Twilio-Signature over the whole URL, so a proxy must not change the path (so the proxy route must cover the per-call path, see the Caddyfile below), and the URL must be the public one, not the container's. The secret is not optional: the Twilio listener does not start without BRISVOX_TWILIO_STREAM_SECRET, so deploy/twilio.env.example has the line active and empty. A static TwiML Bin that cannot compute the segment needs BRISVOX_TWILIO_ALLOW_STATIC_URL=1 instead (and no secret), which accepts the fixed URL and its replayable signature; use it for a first try only.

TLS and authentication in front

Twilio needs wss://, and the generic websocket listener refuses to start on a non-loopback address without credentials (docs/SECURITY.md): anyone who reaches an open one runs your agent on your provider keys. agent-ws in deploy/compose.yaml sets BRISVOX_INSECURE_LISTENERS=1 because compose publishes its ports on 127.0.0.1 only. To expose it, put a reverse proxy on the host that terminates TLS, set BRISVOX_SESSION_SECRET in deploy/ws.env (your backend mints single-use session tokens; docs/SECURITY.md) and, if you like, authenticate at the proxy too. WebRTC signaling must always be behind HTTPS (the DTLS fingerprint travels in it) and the proxy must be listed in BRISVOX_TRUSTED_PROXIES so the per-address limits see the visitor. For example with Caddy:

agent.example.com {
    # With the star: every call connects to its own path, /twilio/stream/<segment>. An exact
    # `/twilio/stream` would answer those with 404.
    handle /twilio/stream* {
        reverse_proxy 127.0.0.1:8082
    }
    # Your voice webhook (examples/phone-receptionist/twiml_server.py), if it runs next to the server:
    handle /voice {
        reverse_proxy 127.0.0.1:8000
    }
    handle /ws {
        # authenticate here (basic_auth, forward_auth, mTLS, ...), then:
        reverse_proxy 127.0.0.1:8080
    }
}

Caddy and nginx upgrade websocket connections without extra settings in this form (nginx needs the usual Upgrade/Connection headers and a proxy_read_timeout above the longest silence you expect; the server pings every 10 s). The health ports (8081, 8083) carry counts only and are open for probes, but should not be public either; /metrics on them needs BRISVOX_METRICS_TOKEN (set it in the env files, then scrape with Authorization: Bearer <token>).

Sizing

  • Calls per container are bounded by CPU: the inference pool takes half the cores the container can use, and admission refuses new calls when it (or the Smart Turn executor) falls behind, learning a ceiling of held calls as it goes (BRISVOX_OVERLOAD_*, docs/OPERATIONS.md). BRISVOX_MAX_SESSIONS (default 256 per core) is a hard upper bound above that. Rust reads cgroup CPU quotas, so cpus: 4 gives a pool of two threads. Load-test with your agent (docs/LATENCY.md) and set BRISVOX_MAX_SESSIONS and BRISVOX_PROVIDER_LIMITS from the result; scale out with more containers rather than a larger limit.
  • File descriptors. Every open call is at least one socket, and Docker gives a container a soft RLIMIT_NOFILE of 1024. A process that keeps that limit stops accepting at about 1017 calls (accept() fails with EMFILE, the callers see "connect timed out", CPU is idle: measured in the capacity run, which first stopped at exactly 1017 concurrent calls, bench/results/capacity-brisvox-nofile1024-linux-2026-10-13.md). brisvox-server raises its own soft limit to the hard limit at startup (an open-file limit line at info with the before and after, a warn when it cannot; it never fails the start; Windows has no such limit and does nothing; src/fdlimit.rs, the rlimit crate, MIT, Unix only). That only helps up to the hard limit, which you still set: keep --ulimit nofile=65536:65536 (compose: ulimits.nofile, already in deploy/compose.yaml; Kubernetes: the container runtime's default or a LimitNOFILE drop-in on the node) to at least 2 x BRISVOX_MAX_SESSIONS plus a margin for provider sockets. A container whose hard limit is also 1024 (--ulimit nofile=1024:1024) cannot be helped by the server.
  • Memory is per call (audio buffers, STT/LLM/TTS sessions) plus the models; set a container memory limit from your load test and leave headroom.
  • SIP (feature sip) needs UDP: publish BRISVOX_SIP_ADDR's port and the whole BRISVOX_SIP_RTP_PORTS range, set BRISVOX_SIP_PUBLIC_IP, and use host networking or a load balancer that preserves source addresses. Docker's userland proxy is a poor fit for RTP at scale. SIPS (BRISVOX_SIP_TLS_ADDR) is TCP: publish its port too, mount the certificate chain and key as files (BRISVOX_SIP_TLS_CERT, BRISVOX_SIP_TLS_KEY; read once at startup) and terminate nothing in front of it (a TLS-terminating load balancer would see the SDES keys and the carrier's source address is lost). Rotating the certificate is a restart (a drain loses no call).

Kubernetes

The same image works: set BRISVOX_* through a Secret/ConfigMap, mount the agent file, and use the probes from docs/OPERATIONS.md (/healthz liveness, /readyz readiness, port 8081, terminationGracePeriodSeconds: 45 or more). The image's HEALTHCHECK is ignored by Kubernetes.

WebRTC: making the server reachable from browsers

The browser initiates the connection, the server is ICE-lite and only answers. So a call needs a path to the server: a public UDP port (BRISVOX_WEBRTC_UDP_ADDR, advertised as BRISVOX_WEBRTC_CANDIDATE_IP), or a public TCP port for ICE-TCP (BRISVOX_WEBRTC_TCP_ADDR). Protocol facts and sources: docs/providers/webrtc.md section 11; every variable: "WebRTC reachability" in docs/CONFIG.md.

Client networkWhat gets a call through
Open internet, any NAT, server with public UDPnothing extra (the default)
UDP blocked, TCP out allowed to the server's portICE-TCP (BRISVOX_WEBRTC_TCP_ADDR), or TURN over TLS/443
Strict firewall (443 only, TLS inspection) or symmetric NATTURN turns: on 443 (BRISVOX_TURN_PROVIDER)
Anything where a web page loads (one HTTP(S) port, HTTPS-only networks)nothing to configure: the bundled pages fall back to a WebSocket on the signaling port (below)
Server host has no inbound UDP (a one-port PaaS such as Railway)the WebSocket fallback (below); to keep WebRTC media: the server's own TURN relay (BRISVOX_WEBRTC_TURN_RELAY=only, below), ICE-TCP through a TCP proxy, the websocket/RTVI transports, or media on a UDP host; a browser TURN alone does not help

WebSocket fallback on the signaling port

The signaling listener also accepts a WebSocket upgrade on BRISVOX_WEBRTC_WS_PATH (default /ws, off to disable) and serves the generic WebSocket audio protocol there (docs/WEBSOCKET_PROTOCOL.md): the same port, so the same HTTP proxy, TLS certificate and domain that already carry the page and POST /whip. The browser module sdk/web/brisvox-call.js (sdk/web/README.md; a website imports it, and the bundled pages /demo and examples/web-assistant/web/index.html are built on it) tries WebRTC first and switches to wss://<same host>/ws when RTCPeerConnection is missing or throws, the WHIP request fails at the network level, or the connection is not connected after 4 seconds (or failed). The page then shows "Connected (WebSocket fallback)". Nothing else has to be reachable: no UDP, no second TCP port, no TURN.

  • What it costs. Audio is 24 kHz PCM16 over TCP (48 KB/s each way) instead of Opus over UDP: more bandwidth, TCP head-of-line blocking after a lost packet, and no browser jitter buffer or loss concealment (the page keeps an 80 ms buffer of its own, at most 400 ms). On a clean network it sounds the same; on a lossy one WebRTC is better. Echo cancellation is the browser's, on the microphone, as with WebRTC.
  • Same protection as POST /whip. The same static token or session tokens (audience brisvox-webrtc, single use), the same Origin/CORS rule, offer rate, per-address call cap, handshake pool and BRISVOX_MAX_SESSIONS admission: docs/SECURITY.md. A session token that opened a WebRTC session which then never connected (the page hangs it up with DELETE before falling back) is given back, once, before the DELETE is answered, so the page presents the same token to the WebSocket. A session that timed out, closed or connected keeps its token spent. The page's 4 s timer can fire while the browser is still finishing ICE and the server has already connected: the WebSocket attempt then gets 403, and the page says that the token was already used and that your backend should mint a new one. If you wrap the page, mint a fresh token for each attempt (WebRTC, then fallback) when you can.
  • Behind a proxy: it must pass the HTTP/1.1 Upgrade (Railway, Fly, nginx with proxy_set_header Upgrade, Cloudflare all do) and keep an idle connection for longer than 30 s (the server pings every 10 s). Set BRISVOX_TRUSTED_PROXIES so per-address limits see visitors, and keep the Host header (the default Origin rule compares it with the page's origin; if your proxy rewrites it, set BRISVOX_WEBRTC_CORS_ORIGIN to the page's origin).
  • When TURN is still worth it. The fallback is the right answer for "it must work wherever a page loads". TURN (or the server's own relay below) keeps the lower latency and the Opus and loss handling of WebRTC for visitors behind UDP-blocking firewalls and symmetric NATs, at the cost of a TURN provider; ICE-TCP is the middle road when you can expose a TCP port. Start with the fallback; add TURN if you measure a quality or latency problem on those networks.

Two limits to know before choosing a WebRTC reachability option:

  • TURN relays UDP to the server. A relayed browser still sends its media to the server's UDP candidate, from the TURN server's address. TURN fixes the client side (blocked UDP, symmetric NAT) but needs the server to have inbound UDP. Browsers cannot relay to a TCP candidate. A server with no UDP at all needs the server's own TURN client ("The server's own TURN relay" below).
  • ICE-TCP is the second choice. The server advertises UDP with a higher priority, so UDP is used whenever it works. TCP adds head-of-line blocking to real-time audio: expect worse glitches under loss than UDP.

Browser TURN credentials

Set BRISVOX_TURN_PROVIDER and the server hands short-lived credentials to the page, on the WHIP 201 as Link: <turns:...>; rel="ice-server"; username="..."; credential="..." (RFC 9725 section 4.6) and as JSON from GET /whip/ice ({"iceServers":[...]}, same bearer token as POST /whip, Cache-Control: no-store). The demo page and examples/web-assistant fetch /whip/ice before creating the offer. The long-lived secret never leaves the server; a provider outage only means no relay is offered (logged, the call still connects).

  • coturn (self-hosted, no per-minute fee): BRISVOX_TURN_PROVIDER=coturn, BRISVOX_TURN_URLS=turns:turn.example.com:443?transport=tcp,turn:turn.example.com:3478?transport=udp, BRISVOX_TURN_SECRET=<static-auth-secret>. Credentials are computed locally (no network call).
  • Cloudflare Realtime TURN: in the Cloudflare dashboard create a TURN key (Realtime > TURN); you get a key id and an API token. BRISVOX_TURN_PROVIDER=cloudflare, BRISVOX_TURN_CLOUDFLARE_KEY_ID, BRISVOX_TURN_CLOUDFLARE_API_TOKEN. Its URLs include turns:turn.cloudflare.com:443?transport=tcp, which passes most firewalls.
  • Twilio Network Traversal Service: BRISVOX_TURN_PROVIDER=twilio, BRISVOX_TURN_TWILIO_ACCOUNT_SID, BRISVOX_TURN_TWILIO_AUTH_TOKEN (or an API key: BRISVOX_TURN_TWILIO_API_KEY_SID plus its secret as the auth token). Twilio offers turn: on 443/TCP but no turns: (no TLS), which TLS-inspecting firewalls may refuse.

BRISVOX_TURN_TTL (seconds, 30 to 86400, default 600) is the credential lifetime. Set it above your longest call: the browser refreshes its relay allocation with the same credential, so a call that outlives it can lose the relay (expected from the REST-credential scheme, not tested against a live TURN server). Fetched (Cloudflare, Twilio) credentials are shared for 60 s by all callers; coturn credentials are computed per request.

Running coturn with TLS on 443

On a small VPS (public IP, ports 443/tcp, 3478/udp+tcp and the relay range open). turnserver.conf:

realm=turn.example.com
use-auth-secret
static-auth-secret=<long random secret, the same as BRISVOX_TURN_SECRET>
listening-port=3478
tls-listening-port=443
cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
min-port=49160
max-port=49200
no-cli
fingerprint

Run it with CAP_NET_BIND_SERVICE (or as root) to bind 443, and keep 443 for coturn alone: if the same host also serves HTTPS, give coturn its own IP or put it on another host. coturn relays to the media address of the brisvox server, so the relay must be allowed to reach it (allowed-peer-ip / denied-peer-ip in coturn; deny private ranges you do not want browsers to reach through your relay and allow the brisvox server's address). Option names are from the coturn README fetched 2026-10-13 for use-auth-secret, static-auth-secret, tls-listening-port, cert, pkey and realm; check the rest against your coturn version. This exact file was not run.

Per-host recipes

VPS or bare metal (public IP). The simplest and the best for latency.

BRISVOX_WEBRTC_ADDR=127.0.0.1:8088          # signaling: behind your TLS proxy (microphones need HTTPS)
BRISVOX_WEBRTC_UDP_ADDR=0.0.0.0:50000       # media: open 50000/udp in the firewall and cloud security group
BRISVOX_WEBRTC_CANDIDATE_IP=203.0.113.10    # the public IPv4 of the host
BRISVOX_WEBRTC_TOKEN=...                    # or session tokens (BRISVOX_SESSION_SECRET)

Add BRISVOX_WEBRTC_TCP_ADDR=0.0.0.0:50000 (TCP and UDP ports do not clash, so the same number works) to also accept ICE-TCP, and a TURN provider for strict client networks. In Docker use network_mode: host or publish 50000/udp (and /tcp) explicitly; the userland proxy is a poor fit at scale (see SIP above).

Fly.io. UDP works (docs: https://docs.fly.io/networking/udp-and-tcp, read 2026-10-13) with three conditions: a dedicated IPv4 address on the app, the UDP socket bound to the fly-global-services address (not 0.0.0.0), and the same external and internal port. In fly.toml:

[[services]]            # signaling over HTTPS
  protocol = "tcp"
  internal_port = 8088
  [[services.ports]]
    port = 443
    handlers = ["tls", "http"]

[[services]]            # media
  protocol = "udp"
  internal_port = 50000
  [[services.ports]]
    port = 50000

BRISVOX_WEBRTC_UDP_ADDR takes an IP, not a host name: resolve the address in the entrypoint, for example export BRISVOX_WEBRTC_UDP_ADDR="$(getent hosts fly-global-services | cut -d' ' -f1):50000", and set BRISVOX_WEBRTC_CANDIDATE_IP to the app's dedicated IPv4. Assume about 1300 bytes of UDP payload (Fly's note); brisvox's packets are under 1200. Not deployed to Fly.

Kubernetes. Media is one UDP port per pod and the SDP answer names the address of the pod that handled the POST, so signaling can go through any Ingress, but media must reach that pod directly: do not put the media port behind a load balancer that spreads UDP across pods. Run one pod per node with hostPort: 50000 (UDP, and TCP for ICE-TCP) or hostNetwork: true, and set BRISVOX_WEBRTC_CANDIDATE_IP per pod from the node's public address (the downward API gives status.hostIP, which is the internal node IP on most clouds: use the node's external IP, e.g. from a label your provisioner sets). Open the port in the cloud firewall. TURN credentials need no cluster support. Use the probes from docs/OPERATIONS.md as above.

One HTTPS port and no UDP (Railway and similar platforms). A host that exposes only HTTP(S) cannot receive WebRTC media. The call continues on the WebSocket fallback above, or on the server's own TURN relay; the whole recipe is the next section, "Platforms with one HTTPS port and no UDP (PaaS)".

Platforms with one HTTPS port and no UDP (PaaS)

Many application platforms (Railway is the example used below) give a service exactly this: a public domain whose TLS is terminated at the platform's edge, HTTP and WebSockets forwarded to one port inside the container (the platform tells the container which one in PORT), a private network between the services of a project, environment variables instead of mounted files, a build done by the platform, and no inbound UDP from the internet. This section is what to know to run brisvox-server there for a website voice button (or any client that only speaks HTTPS), in general terms; the facts that differ per platform are in "Examples" at the end. Hosts that can receive UDP (a VPS, Fly.io, Kubernetes with hostPort) are in "Per-host recipes" above and give lower latency.

How a call works there

browser --https / wss--> platform edge --> brisvox-server  (one port: page, POST /whip, GET /ws)
                                               |
                                               +--ws://<tools service, private network>:8765--> your SDK agent (optional)

The browser talks only to the one HTTPS domain. The WebRTC signaling listener (BRISVOX_WEBRTC_ADDR) serves the bundled page, POST /whip and the WebSocket audio endpoint /ws on that port. WebRTC media cannot reach the server, so the call runs on WebSocket audio on the same port ("WebSocket fallback on the signaling port" above). Two ways to get there from the page:

  • Say so in the page. new BrisvoxCall({ ..., transport: "websocket" }) skips the WebRTC attempt: no 4 s wait, no second session token. The bundled /demo page does the same with ?force=ws. This is the recommendation on a host known to have no UDP.
  • Leave transport: "auto". The WebRTC attempt cannot connect, and after 4 s (or when ICE fails) the call continues on wss://<your-domain>/ws with a second session token.

The media options, best first:

  1. One HTTP port, WebSocket audio (above). Nothing else is needed. The server still wants a media socket and an address to advertise: BRISVOX_WEBRTC_UDP_ADDR=127.0.0.1:0 is a socket nobody can reach (WebRTC cannot connect, which is the point here). The other transports also work over the HTTPS port (BRISVOX_WS_ADDR, BRISVOX_RTVI_ADDR, for your own clients and the Pipecat SDKs), or put a small brisvox instance with BRISVOX_WEBRTC_* on a UDP host (VPS, Fly.io) for WebRTC media with lower latency.
  2. The server's own TURN relay (BRISVOX_WEBRTC_TURN_RELAY=only): WebRTC media over TLS/443 through a managed TURN service, with nothing extra on the client; described below. Not yet run against a real TURN service.
  3. ICE-TCP through a TCP proxy, when the platform offers one. Add a TCP proxy to the service for the ICE-TCP port, then BRISVOX_WEBRTC_TCP_ADDR=0.0.0.0:<the service's internal port>, BRISVOX_WEBRTC_TCP_CANDIDATE_PORT=<the proxy's public port> and BRISVOX_WEBRTC_CANDIDATE_IP=<the IP the proxy domain resolves to> (an IP, not a name; check it stays stable), and BRISVOX_WEBRTC_TCP_MAX_PER_IP=0: every connection comes from the proxy, so the default per-address cap of 16 would limit all browsers together. The UDP candidate advertised next to it is unreachable and is simply skipped by the browser. Limits: clients must be able to reach a non-443 TCP port (many corporate networks cannot), TCP proxy idle timeouts may not be documented (a live call exchanges packets every few seconds), the server sees the proxy's address instead of the client's, and a TURN relay does not help (it cannot reach a TCP candidate). Untested against a platform; the TCP path is tested with an in-process str0m client.

A browser TURN provider alone (BRISVOX_TURN_PROVIDER for the page) does not help: TURN relays UDP to the server, which has no UDP ingress.

Build

  • Features. The image default (all-providers,call-control,smart-turn-ort) has no webrtc, and a binary without it refuses BRISVOX_WEBRTC_ADDR at start. Build with BRISVOX_FEATURES=all-providers,smart-turn-ort,webrtc (the browser row of "What to build with" in the README); webrtc compiles libopus (cmake and a C compiler are in the build stage). Add call-control if the same service also takes phone calls (transfer, DTMF, hang-up); add sip only on a UDP host. A full Docker build with webrtc has not been run by the project (the documented pass used sip): watch the first build. Whether platform variables reach the build depends on the platform: a Dockerfile ARG is the portable way, and ours declares BRISVOX_FEATURES, BUILD_JOBS and STRIP_BINARY in its build stage.
  • BUILD_JOBS. Optional cap on rustc jobs (for example 2); a full build peaks near 1 GB of RAM per job and a platform's builder size is often not documented.
  • Why webrtc for a WebSocket call. The page, POST /whip and the /ws fallback all live in the WebRTC signaling listener. If you only need your own clients to speak the WebSocket protocol (docs/WEBSOCKET_PROTOCOL.md), the generic BRISVOX_WS_ADDR listener needs no webrtc feature (no libopus, shorter build), but it has no bundled page, no Origin rule, no per-address limits and does not enforce a token's max_s (docs/SECURITY.md); use BRISVOX_WEBRTC_ADDR for browsers.
  • Build time. The image compiles the whole server. The project measured 411 s cold on a 32-core host with BUILD_JOBS=4 (the section "The image"). A platform's build timeout and builder size may be unknown or small, so a cold build on a small plan can run out of time. Then use a prebuilt image: build once (locally or in CI), push it to a registry the platform can pull from (this repository does not publish an image), and give the platform a repository of one file (see "The agent definition").

The agent definition

A platform service usually cannot mount a file, and the image has none at BRISVOX_AGENT_FILE's default path. The options, simplest first:

  • A variable: paste the agent file's text into BRISVOX_AGENT_DEFINITION (multi-line values work on platforms that allow them) and leave BRISVOX_AGENT_FILE unset; BRISVOX_AGENT_FORMAT=yaml for a YAML definition. Setting both variables is an error. Check it first with BRISVOX_AGENT_DEFINITION="$(cat agent.toml)" brisvox-server check --env. Changing the agent is then a variable edit and a redeploy, no image build. docs/CONFIG.md, "The agent definition from the environment".

  • A fork or your own copy of the repository: put your file in deploy/agents/ (the Dockerfile copies that directory to /etc/brisvox/agents/) and set BRISVOX_AGENT_FILE=/etc/brisvox/agents/my-agent.toml. Nothing else changes.

  • A prebuilt image plus a one-line layer: a repository of one file:

    FROM ghcr.io/YOUR-ORG/brisvox-server:YOUR-TAG
    COPY agent.toml /etc/brisvox/agent.toml
    

    With BRISVOX_AGENT_FILE=/etc/brisvox/agent.toml (the image's default). The build is seconds instead of minutes and does not depend on the platform's builder limits.

  • A platform volume also works for a file, but check who owns it (the image runs as uid 10001) and whether a volume makes a redeploy interrupt the service. Prefer baking the file in.

Secrets stay out of the file: the definition names variables (api_key_env), and the keys are the platform's variables.

Server settings that matter on such a host

PORT=...                                     # injected by the platform (set it yourself if its docs say so)
BRISVOX_WEBRTC_ADDR=0.0.0.0:${PORT}          # the server replaces the exact token ${PORT}
BRISVOX_WEBRTC_UDP_ADDR=127.0.0.1:0          # a media socket nobody can reach; the call uses /ws
BRISVOX_AGENT_DEFINITION=<the agent file's text>     # or BRISVOX_AGENT_FILE=<path of a baked-in file>
BRISVOX_SESSION_SECRET=<at least 32 random bytes>   # your backend mints single-use tokens (docs/SECURITY.md)
BRISVOX_WEBRTC_CORS_ORIGIN=https://www.example.com  # only when the page is on another origin
BRISVOX_MAX_SESSIONS=64                      # from a load test; the default is 256
DEEPGRAM_API_KEY=...                         # whatever the agent file's api_key_env names
BRISVOX_AGENT_URL=ws://<tools service>:8765  # the SDK agent, if you run one
BRISVOX_AGENT_TOKEN=...                      # the same token on both sides

For a quick demo BRISVOX_WEBRTC_TOKEN=<token> replaces the session secret; anyone who has the token can start calls on your provider keys, so use session tokens for a real site.

  • Listen on the platform's port. The platform routes its public domain to the one port the service listens on and tells you which in PORT. Write the placeholder instead of a number: BRISVOX_WEBRTC_ADDR=0.0.0.0:${PORT} (or [::]:${PORT}). The exact token ${PORT} in any listener address variable is replaced by the value of PORT; if PORT is unset or not a port number the start fails naming both variables. The server does this itself because the container starts the binary directly, with no shell to expand $PORT. Details: docs/OPERATIONS.md, "Port placeholder". Platforms often have their own reference syntax for variables (Railway's is ${{...}}); a single-brace ${PORT} reaches the server as written, and writing the port out (0.0.0.0:8080) works too. A mismatch between the listening port and the platform's target port is a 502 from the platform's edge. The listener must bind 0.0.0.0 (or ::), never loopback.

  • Credentials. Off loopback the signaling listener refuses to start without BRISVOX_SESSION_SECRET or a static token (docs/SECURITY.md). Your backend mints single-use session tokens for the page.

  • Health check. A platform probe reaches the service's public port, not the health listener: /healthz and /readyz are on BRISVOX_HEALTH_ADDR (port 8081), which the probe cannot reach, and the image's HEALTHCHECK is not used. Set the platform's health check path to /demo: the signaling listener answers GET /demo with 200 and no credentials (the bundled page; BRISVOX_WEBRTC_* as above). For liveness and readiness during operation keep BRISVOX_HEALTH_ADDR on the private network and scrape or probe it from another service (/readyz is 503 while draining or full); /metrics needs BRISVOX_METRICS_TOKEN there.

  • Client addresses and the proxy. Behind the platform's edge the TCP peer is the edge, so the per-address limits (5 offers a minute, 8 calls at once, 8 signaling connections per address; docs/SECURITY.md) would treat every visitor as one client, and the fallback uses two offers per call. brisvox reads the client address from a header (BRISVOX_CLIENT_IP_HEADER: x-real-ip, x-forwarded-for or forwarded) only on connections from addresses listed in BRISVOX_TRUSTED_PROXIES. That needs two facts from the platform: which header its edge writes (and overwrites when a client sends one), and which addresses its proxies connect from. Many platforms document the first and not the second. So:

    1. Safe default (the proxy range is not published). Leave BRISVOX_TRUSTED_PROXIES unset and relax the per-address limits that would throttle all visitors together: BRISVOX_WEBRTC_OFFERS_PER_MINUTE=off, BRISVOX_WEBRTC_MAX_CALLS_PER_IP=off, BRISVOX_WEBRTC_HTTP_MAX_PER_IP=0. Abuse control is then the single-use session token (your backend decides who gets one), BRISVOX_MAX_SESSIONS, the handshake pool and BRISVOX_PROVIDER_LIMITS; add rate limiting where you mint tokens.
    2. Per-visitor limits through the header. Set BRISVOX_CLIENT_IP_HEADER to the header the edge writes and list the proxy addresses you observe in your own logs and nothing wider in BRISVOX_TRUSTED_PROXIES. Never 0.0.0.0/0. Requests that arrive over the platform's private network do not pass the edge, so any service of your project can send its own header; if your project's own services fall in the range you trusted, they are trusted too. Use this only when no other service of the project is reachable by strangers, and re-check the range after the platform changes, since an unpublished range is not a contract.
  • WebSockets through the proxy. The proxy must pass the HTTP/1.1 Upgrade and keep an idle connection for longer than 30 s (the server pings every 10 s); keep the Host header or set BRISVOX_WEBRTC_CORS_ORIGIN (see "WebSocket fallback on the signaling port").

  • One replica. The single-use token record, the sessions and the admission counters live in the process (docs/SECURITY.md), and a platform may not offer sticky routing for HTTP domains. Run one replica and scale by splitting traffic per service.

  • Deploys and live calls. On a new deployment a platform sends SIGTERM to the old instance and SIGKILL after its own drain time; if that time is short, every open call is cut. Set the platform's drain time to the server's drain budget: at least 45 s (the default drain is BRISVOX_DRAIN_TIMEOUT 30 s + BRISVOX_CANCEL_GRACE + BRISVOX_ABORT_WAIT + 7 s = 43 s), more if you raise BRISVOX_DRAIN_TIMEOUT for long calls (and check the platform's maximum). The drain stops new calls and lets the open ones finish (docs/OPERATIONS.md, "Shutdown"). A browser whose call is cut gets a closed WebSocket and your page should offer to reconnect.

The server's own TURN relay

For a host that has HTTP(S) and nothing else inbound (a one-port PaaS, as in this section), the server itself is a TURN client. Per call it opens one TLS connection to a managed TURN service, allocates a relayed UDP address there, creates permissions for every IP in the browser's offer, and advertises that relayed address in the SDP answer as a host candidate. The browser sends its media to the TURN service's public address; the service forwards it down the TLS connection to the server, and the server's packets go back the same way (ChannelData). Design, bounds and failure behaviour: the relay module docs and "WebRTC transport" in docs/ARCHITECTURE.md; protocol facts and sources: docs/providers/webrtc.md section 12; every variable: "WebRTC reachability" in docs/CONFIG.md.

Example with Cloudflare Realtime TURN (create a TURN key in the Cloudflare dashboard, Realtime > TURN):

PORT=8080                                      # injected by the platform, or set it yourself
BRISVOX_WEBRTC_ADDR=0.0.0.0:${PORT}            # signaling over the platform's HTTPS domain (microphones need HTTPS)
BRISVOX_WEBRTC_UDP_ADDR=0.0.0.0:50000          # bound but unreachable; not advertised in `only` mode
BRISVOX_WEBRTC_TURN_RELAY=only                 # no direct UDP candidate; a failed allocation answers 503
BRISVOX_TURN_PROVIDER=cloudflare
BRISVOX_TURN_CLOUDFLARE_KEY_ID=...
BRISVOX_TURN_CLOUDFLARE_API_TOKEN=...          # a secret: a platform variable, never in the repo
BRISVOX_WEBRTC_TOKEN=...                       # relay traffic costs money: do not leave signaling open
  • The same provider serves both sides: the browser is told the TURN URLs too (GET /whip/ice), and the server picks the best turn:/turns: URL over TCP from the provider's answer, turns: on port 443 first (Cloudflare lists turns:turn.cloudflare.com:443?transport=tcp). A provider that offers only UDP URLs (Twilio lists turn: on 443/TCP only) works for the server through that turn: URL, unencrypted at the TURN layer (the media is still DTLS-SRTP end to end).
  • BRISVOX_WEBRTC_TURN_RELAY=on keeps the direct candidates (set BRISVOX_WEBRTC_CANDIDATE_IP) and adds the relayed one at a lower priority: a fallback for servers that are probably reachable. only is for hosts that are certainly not.
  • BRISVOX_WEBRTC_TURN_RELAY_TIMEOUT (default 3 s) bounds the whole setup of one call. On expiry the call is answered with its direct candidates (on) or refused with 503 Retry-After (only), and relay_setup_failures counts it.
  • Latency. A relayed packet makes two extra hops (browser to the TURN edge, edge to your server over a TCP connection) instead of one. Expect tens of milliseconds more than a direct path, depending on where the TURN service is relative to the server and the caller (not measured); TCP head-of-line blocking after a lost packet adds more, in bursts. The call setup adds about six round trips to the TURN service (TCP, TLS, allocate with its authentication challenge, permissions) before the answer is sent: budget a few hundred ms.
  • Cost. Cloudflare Realtime TURN bills the data the TURN server sends to a TURN client ($0.05/GB after 1000 GB free per month, fetched 2026-10-13): here that is the caller's audio towards your server (and, if the browser also relays, towards the browser). Opus at the default settings is a few tens of kbit/s, i.e. well under 20 MB per call-hour per direction. One TLS connection and one allocation exist per live call; check the provider's allocation and connection limits for your call volume.
  • What it needs from the offer. The browser's offer must carry its candidates (a complete, non-trickle offer: the bundled pages wait for ICE gathering) because a TURN service drops data from an IP without a permission, and the permissions are created before the answer is sent. Browsers hide host addresses behind mDNS names; their server-reflexive and relay candidates (which appear when the page has TURN or STUN servers, as with GET /whip/ice) carry the IPs that matter. An offer without any IP candidate gets no relay.
  • Secrets and logs. The relay uses the credentials the provider issues; they are never logged (a log-capture test at trace over a whole call finds neither the password nor the shared secret). The TURN client crates' own events (turn_client_proto, stun_proto, turn_types) are chatty and, at trace, print whole STUN messages (nonce, integrity HMAC), so the server drops them below warn even when you set RUST_LOG (BRISVOX_LOG_TURN_CLIENT=1 lifts that, for debugging). If you embed the library and install your own tracing subscriber you must apply the same filter: filter_fn(brisvox_transport::webrtc::turn_client_log_allowed) next to your level filter (FilterExt::and); a RUST_LOG directive does not do it.
  • Trust and bounds. The TURN URLs come from your configuration or the provider's API; the server connects to whatever host they name, so do not point BRISVOX_TURN_* at an untrusted endpoint (and restrict egress if the provider path is not trusted). Per call the server asks the TURN service for at most 8 permissions, for the public (non-loopback, non-link-local) IPs of the offer's candidates in the allocation's address family: that is the cost a hostile offer can put on your TURN quota, after the per-IP offer rate limit. Queues are 20 packets (400 ms) per direction; relay_queue_slow and relay_slow_writes count a TURN connection that is too slow.
  • Not verified against a real service yet: the tests run the client against an RFC 8656 server implementation in-process, over TCP and TLS.

Your tools service (SDK agent)

The tools are an optional second service in the same project; without BRISVOX_AGENT_URL every call runs on the agent file alone (no tools of your own). Give it no public domain; the server reaches it over the platform's private network. Use ws:// (a private network is encrypted by some platforms, and brisvox accepts plain ws:// for *.internal hosts, docs/AGENT_PROTOCOL.md) and the token:

  • Bind address. Both SDKs listen on 127.0.0.1 by default (sdk/typescript/src/agent.ts, serve: options.host ?? "127.0.0.1"; Python: host="127.0.0.1"), which is unreachable from another service, and the symptom is quiet: the server's handshake with the agent times out and calls run on the agent file alone, without your tools. Bind all interfaces:

    • TypeScript: await agent.run({ host: "::", port: 8765 })
    • Python: agent.run(host="::", port=8765)

    :: is dual-stack on Linux, so it accepts IPv4 and IPv6; use it when the private network may be IPv6-only, otherwise 0.0.0.0 is enough (the library notes recommend ::; not tested here).

  • Token. Set BRISVOX_AGENT_TOKEN on both services (a shared variable or a reference); the SDKs read it, and the server sends it as Authorization: Bearer.

  • Port. Any port; the URL names it (:8765). The private network needs no exposed port.

  • Health check. None: the SDK agent answers a plain HTTP request with an error status (the TypeScript one with 426), which a platform health check would count as unhealthy. Leave the platform's health check off for this service.

  • Image. Install the SDK from the registry in your own image (pip install brisvox-agent, npm install @brisvox/agent; pip install brisvox and npm i brisvox are the same SDKs under the short name), or vendor it from a checkout of this repository (pip install ./sdk/python after copying the directory; npm install a built sdk/typescript), as in book/src/quickstart/sdk-agent.md. Two ready Dockerfiles show the vendored variant, both built with the repository root as the build context (a service whose source is your clone or fork, with the platform told which Dockerfile to use): examples/booking-agent/Dockerfile (Python: pip install of the copied sdk/python, the booking agent) and sdk/typescript/examples/Dockerfile (TypeScript: npm ci && npm run build of the copied sdk/typescript in a build stage, compiled dist and production node_modules in the final one). Both listen on 0.0.0.0 (pass --host :: on an IPv6-only private network), run as a non-root user and need no health check. For your own agent, copy one of them and swap the COPY lines of the example for your code.

  • Restarts. The agent link reconnects an agent that declared resume (docs/AGENT_PROTOCOL.md); a call whose agent is unreachable at its start falls back to the agent file alone.

Checklist

ServiceSettingMeaning
brisvoxBRISVOX_FEATURES (build)Cargo features of the image; must include webrtc for browsers.
brisvoxBUILD_JOBS (build)Optional cap on rustc jobs.
brisvoxPORTThe port the platform routes its domain to; equals the domain's target port.
brisvoxBRISVOX_AGENT_DEFINITION or BRISVOX_AGENT_FILEThe agent definition's text, or the path of a baked-in file (not both).
brisvoxBRISVOX_WEBRTC_ADDR0.0.0.0:${PORT}: page, POST /whip and the /ws fallback on the public port.
brisvoxBRISVOX_WEBRTC_UDP_ADDR127.0.0.1:0: a media socket nobody can reach.
brisvoxBRISVOX_SESSION_SECRET or BRISVOX_WEBRTC_TOKENCredentials for starting a call (required on a non-loopback address).
brisvoxBRISVOX_WEBRTC_CORS_ORIGINThe page's origin when it is not served by this service.
brisvoxBRISVOX_TRUSTED_PROXIES, BRISVOX_CLIENT_IP_HEADERSee "Client addresses and the proxy"; unset by default.
brisvoxBRISVOX_WEBRTC_OFFERS_PER_MINUTE, BRISVOX_WEBRTC_MAX_CALLS_PER_IP, BRISVOX_WEBRTC_HTTP_MAX_PER_IPPer-address limits; relaxed when the proxy's header cannot be trusted.
brisvoxBRISVOX_MAX_SESSIONS, BRISVOX_PROVIDER_LIMITSAdmission and per-provider concurrency.
brisvoxprovider keysThe variables the agent file's api_key_env names.
brisvoxBRISVOX_AGENT_URL, BRISVOX_AGENT_TOKENThe SDK agent (second service) and its bearer token.
brisvoxBRISVOX_METRICS_TOKENOnly if you scrape /metrics on the health address.
platformdrain timeAt least 45 s between SIGTERM and SIGKILL.
toolsBRISVOX_AGENT_TOKENThe same token.
toolsyour ownDatabase URL and the like.

Also: the health check path is /demo on brisvox and none on the tools service; one replica; the tools service binds :: or 0.0.0.0.

Examples

What differs per platform, from the platform's own documentation. Only platforms the project has researched are listed.

Railway. Facts read from docs.railway.com on 2026-10-14 (what is only a forum report is marked as such); this recipe has not been deployed to Railway by the project, and the points marked unverified are the ones to test first. Railway offers HTTP/HTTPS with WebSockets on a public domain, a TCP proxy, a private network between the services of a project, and no inbound UDP from the internet (its docs list none; staff and community threads say so; re-checked 2026-10-13).

  • Build. Railway uses the Dockerfile in the repository root when there is one (the build log says "Using detected Dockerfile"; another path: the variable RAILWAY_DOCKERFILE_PATH). Service variables reach a Docker build only when the Dockerfile declares them with ARG, so BRISVOX_FEATURES and BUILD_JOBS are service variables. A private registry for a prebuilt image needs Railway's Pro plan. Railway's build timeout and builder size are not in its documentation (a forum report puts the Hobby limit near 20 minutes: unverified). A volume is mounted as root at run time (Railway's RAILWAY_RUN_UID=0 is the documented workaround for the uid 10001 of the image) and makes a redeploy interrupt the service.
  • PORT. Railway injects PORT and routes the public domain to the port the service listens on; its documentation does not state the default value (reports say 8080: unverified) and says to set PORT yourself when you use a domain target port. Set PORT=8080 and the domain's target port to 8080. Railway's own references use double braces (${{...}}); a single-brace ${PORT} reaches the server as written (not verified on Railway; 0.0.0.0:8080 written out works too).
  • Health check. Railway probes the service's PORT, only at deploy time, from the host healthcheck.railway.app; set the path to /demo. Not run against Railway's probe: unverified.
  • Client addresses. Railway documents X-Real-IP as the client-address header and overwrites a client-sent one; it does not list X-Forwarded-For, publishes no address range for its proxies and says the 100.x peer addresses seen in practice are not a contract (staff answer, 2026-10). So option 1 above is the safe default; for option 2 use BRISVOX_CLIENT_IP_HEADER=x-real-ip and, for example, BRISVOX_TRUSTED_PROXIES=100.64.0.0/10 (the CGNAT block the reported 100.x edge peers fall in; unverified as a contract: re-check after Railway changes). Requests over *.railway.internal do not pass the edge, and the edge peers seen so far were 100.x addresses, so your project's own services may fall in the same block.
  • WebSockets. Railway's WebSocket connections are not subject to its HTTP duration and idle limits (HTTP/1.1 only); the domain accepts 10,000 concurrent connections.
  • Drain. By default Railway gives the old deployment 0 seconds between SIGTERM and SIGKILL. Set RAILWAY_DEPLOYMENT_DRAINING_SECONDS=60 (at least 45).
  • Private network. The hostname is <service-name>.railway.internal (in a variable: RAILWAY_PRIVATE_DOMAIN). A reference to another service's variable is ${{tools.X}}: BRISVOX_AGENT_URL=ws://${{tools.RAILWAY_PRIVATE_DOMAIN}}:8765 and BRISVOX_AGENT_TOKEN=${{tools.BRISVOX_AGENT_TOKEN}}; a database is ${{Postgres.DATABASE_URL}}. Railway's private DNS resolves to IPv4 and IPv6 in environments created after 2025-10-16 and to IPv6 only in older ones, so a tools service bound to 0.0.0.0 alone can be unreachable in an old environment: bind ::.
  • Checklist. Domain target port 8080; health check path /demo on brisvox and none on tools; one replica; draining seconds set; the agent URL uses .railway.internal of the tools service; the SDK agent binds ::.

Fly.io. Fly can receive UDP (with a dedicated IPv4 address), so it does not need this section for media: see "Per-host recipes".

Verification status

Static checks (no Docker): the agent files load through the server's own validator with every provider feature on; cargo check --locked -p brisvox-server --features all-providers,call-control,smart-turn-ort passes; the compose file parses; healthcheck.sh passes bash -n.

Live pass on a Docker host (2026-10-13, WSL2 Ubuntu, kernel 6.6.87.2, Docker 29.8.0, Compose 5.5.1, no real provider keys: placeholder values satisfy the "key must be set" check and are never used for a network call; see "What was not tested"):

checkresult
docker build -t brisvox/server .first attempt failed to link (bookworm, see above); fixed by trixie; then OK in 411 s, image 160.7 MB unpacked / 64.5 MB compressed
hadolint/hadolint on the Dockerfileclean after the two intended DL3008 (unpinned apt packages) were annotated with # hadolint ignore=DL3008
docker compose -f deploy/compose.yaml configvalid; both services resolve
up agent-wsstarts, (healthy) after the first probe, /healthz 200, /readyz 200 {"status":"ready","accepting":true,...}, agent web-agent loaded with Vad::Silero and Smart Turn on; runs read-only, all capabilities dropped, no-new-privileges, 53 MB RSS idle, 8 pids
websocket handshake (ws://127.0.0.1:8080/, start message)101 Switching Protocols, then a started message; the call then fails at Deepgram (placeholder key) and the STT gives up reconnecting, the call stays open
docker stop -t 45 with one call openthe drain waited for the call: DrainReport { initial: 1, drained: 1, cancelled: 0, aborted: 0, leaked: 0, forced: false, elapsed: 23.8s }, brisvox-server exiting exit_code: 0, container exit code 0, not OOM-killed, 24.5 s in all (inside the 45 s grace)
docker stop with no call0.67 s, exit code 0
up agent-twilio with empty keysfails fast with TWILIO_AUTH_TOKEN is required (the restart policy then retries: set the env file first)
up agent-twilio with placeholder keysstarts, healthy, Twilio listener on :8082, CallControls { twilio: true }, health on host port 8083
--cpus=4the inference pool takes 2 threads (/readyz pressure.threads), unlimited: 16 on the 32-core host
open-file limitDocker's default inside the container is 1024 (soft), which capped a container at 1017 calls in the capacity run; compose now sets ulimits.nofile 65536 (/proc/<pid>/limits of the started container reads 65536)
no agent file mountedclean error: cannot open the file: No such file or directory, non-zero exit

Fixes made by this pass: DEBIAN_RELEASE bookworm -> trixie (the link failure), compose ulimits.nofile, a BUILD_JOBS build argument (a 32-core build host with 12 GB of RAM cannot run 32 rustc jobs), hadolint annotations, compose project name: brisvox (was the directory name deploy), and docker stop -t (--time is deprecated in Docker 29).

What was not tested: a full call with real providers (needs keys: the live provider passes need provider keys), the sip feature image, Kubernetes, a TLS proxy in front.

Security: threat model and secure deployment

What an attacker can do to a brisvox server, what the server does about it, and what you must do yourself. Source of the findings: an independent security review of 2026-10-13. Code: brisvox_transport::auth (tokens), brisvox_transport::webrtc (limits), brisvox_server::hosting (fail-closed rules), brisvox_server::health (/metrics).

What is worth attacking

  • Your provider bill. Every call spends STT, LLM and TTS minutes. Anyone who can open a call can burn them.
  • Audio and transcripts of callers and of the bot, the LLM context, recordings.
  • Tool calls. SideEffect tools do real things (transfer a call, book a table).
  • Capacity. The session limit is shared: a flood of idle connections turns real callers away.
  • Secrets in the environment: provider keys, carrier tokens.

Where the server listens, and what protects each door

ListenerPlain or TLSProtection
WebRTC signaling (BRISVOX_WEBRTC_ADDR), including its WebSocket fallback at BRISVOX_WEBRTC_WS_PATHplain HTTP / ws://session token or static token (the same for POST /whip and the /ws upgrade); Origin check on the upgrade; per-address limits; needs an HTTPS proxy
Generic WebSocket (BRISVOX_WS_ADDR)plain ws://session token or static token
RTVI (BRISVOX_RTVI_ADDR)plain ws://session token or static token; optional Origin allow-list
Twilio (BRISVOX_TWILIO_ADDR)plain ws://X-Twilio-Signature over the configured public URL
Telnyx (BRISVOX_TELNYX_ADDR)plain ws://the stream token (and an unguessable path)
SIP (BRISVOX_SIP_ADDR)UDP/TCP: cleartext. SIPS (BRISVOX_SIP_TLS_ADDR): TLS 1.2+ with SDES-SRTP media (BRISVOX_SIP_SRTP)source ACL, connection limits, handshake deadline and idle timeout, digest; SDES keys only over TLS, in-dialog requests of a TLS dialog only over TLS; SRTP authentication-failure limit; cleartext UDP/TCP only on a private trunk or behind an SBC
Health (BRISVOX_HEALTH_ADDR)plain HTTPprobes carry counts only; /metrics behind a token off loopback
Callback (BRISVOX_CALLBACK_ADDR)plain HTTPbearer or basic secret, constant time

No listener terminates TLS. Put a reverse proxy in front of every one that is reachable from the internet. For WebRTC this is not optional: the media is DTLS-SRTP, but the DTLS certificate fingerprint travels in the SDP offer and answer over the signaling request. If that request is plain HTTP, anyone on the path can swap the fingerprints and sit in the middle of the media; the encryption is then decoration. Browsers also require HTTPS for the microphone on every host but localhost.

Fail closed

The generic WebSocket, RTVI, WebRTC signaling and Telnyx listeners refuse to start when bound to a non-loopback address (0.0.0.0, a LAN or public address) without authentication:

ListenerWhat satisfies the check
Generic WebSocketBRISVOX_SESSION_SECRET, or BRISVOX_WS_TOKEN
RTVIBRISVOX_SESSION_SECRET, or BRISVOX_RTVI_TOKEN
WebRTC signalingBRISVOX_SESSION_SECRET, or BRISVOX_WEBRTC_TOKEN
TelnyxBRISVOX_TELNYX_STREAM_TOKEN (Telnyx sends it; there are no session tokens for a carrier)

BRISVOX_INSECURE_LISTENERS=1 starts them anyway and the server logs one warning per exposed listener ("anyone who can reach it spends your provider keys"). Use it for a private network or a container whose port you publish on loopback only (deploy/compose.yaml does). Loopback binds (127.0.0.1, [::1]) need nothing: that is development. Twilio always verifies its signature, and SIP refuses an open trunk by its own rule.

The same rule is in the transport: WebRtcConfig::validate refuses cors_origin = "*" without bearer_token or sessions, because any web page could otherwise start calls.

Static tokens (BRISVOX_*_TOKEN) keep working for development. They are shared by every client and never expire, so the server logs a warning at start for each one in use.

Session tokens

A token is minted by your backend (which has your login, your user, your quota) for one visitor, and lives for seconds to minutes. The server only verifies. One token opens at most one call. If it leaks, it is useless a minute later and cannot be used twice.

v1.<base64url(payload JSON)>.<base64url(HMAC-SHA256(secret, payload JSON bytes))>

base64url without padding. The HMAC key is the secret's UTF-8 bytes, the signed bytes are the decoded payload exactly as sent. Payload fields:

FieldTypeMeaning
audstringbrisvox-webrtc, brisvox-rtvi or brisvox-ws: which listener the token is for. A token for one is refused by the others.
expintegerUnix seconds (not milliseconds), at most 24 hours after the server's now (plus the skew below); a later exp is refused (401, "token lifetime too long"). The token is refused from exp on (plus the skew).
nbfinteger, optionalUnix seconds. Refused before it (default 0). Must be before exp.
sidstringYour opaque session id, 1 to 128 bytes, no control characters. Single use: the server remembers it until exp plus the skew and refuses a second token with the same sid. Use a random value per token.
agentstring, optionalThe agent you intend; reaches the call's metadata (session.agent).
max_sinteger, optionalLongest call in seconds, 1 to 86400. Enforced on the WebRTC signaling port: for WebRTC calls (the call is hung up) and for the /ws WebSocket fallback served on that same port (the call ends with stop{ended}). The standalone WebSocket listener (BRISVOX_WS_ADDR) and the other listeners report it in the metadata (session.max_s) and do not enforce it yet.
metaobject, optionalAt most 16 entries, keys up to 64 bytes, scalar values (strings up to 256 bytes, numbers, booleans). Reaches the call's metadata as session.meta.<key>.

The whole token is at most 4096 bytes (413 above that).

What the server checks, in this order: length; three dot-separated parts and the v1 prefix; the HMAC signature (constant time, current secret then the previous one) before the payload is parsed, so a stranger who cannot sign never reaches the JSON parser and every wrong signature, tampered payload or wrong secret gives the same answer; the payload's limits; aud; nbf/exp against the server's clock with 30 seconds of skew on both sides (your backend's clock and the server's differ a little; do not rely on the skew, run NTP); and last, the single-use set.

Why exp is capped. The server remembers every sid until exp plus the skew. A backend that mints exp in milliseconds or with a ten-year TTL would park each sid in the set almost for good, fill it, and make every later token a 503. So a token whose exp is more than 24 hours (MAX_TOKEN_LIFETIME) ahead is refused, and the minting helpers (Rust mint_token, Python mint_session_token, TypeScript mintSessionToken) refuse such a TTL, a non-integer or millisecond-looking now, and a max_s outside 1 to 86400. Mint TTLs of 30 to 120 seconds.

The single-use set is bounded (100000 live session ids). Expired entries are dropped first. When it is still full the server refuses new tokens (503) instead of evicting an unexpired entry, because evicting would let a spent token be used again. Only holders of the secret can add entries, so an outsider cannot fill it; if you legitimately mint more than 100000 live tokens, shorten the TTL. When the set is full the server logs a warning (at most once a minute) and answers 503. A token refused for capacity (the server is full, a per-address limit) is not used up: the client can retry with the same token. A token is used up once the server has a call slot for it, so a token whose offer is then refused as malformed (400), or fails on a server error (500) while the call is built, is spent: mint a new one for the retry.

The set lives in the server's memory. A restart empties it: a token that was already used but has not expired opens one more call after the restart, and several server instances (each with its own set, behind a load balancer) each accept a token once. The TTL bounds this; keep it short (30 to 120 seconds) and do not mint long-lived tokens.

What reaches the call. session_id, session.agent, session.max_s and session.meta.<key> are added to the call's metadata (CallInfo::metadata, in the transport's Started event), where the host application can read them to know who is calling. The generic WebSocket adapter strips any session_id / session.* keys the client put in its start message: only the signed values count.

Where the token goes

ListenerHow the client presents it
WebRTCAuthorization: Bearer <token> on POST /whip. DELETE on the session URL needs no token once session tokens are in use: the URL (128 random bits from the system RNG, known only to the client the 201 went to) is the capability, and the token that opened the call is single use and may have expired by hang-up time. With only a static token, DELETE needs it as before (RFC 9725).
RTVI?token=<token> (what Pipecat client SDKs send), Authorization: Bearer, or the WebSocket subprotocol brisvox-token.<token>.
Generic WebSocketthe same three.

A browser's WebSocket cannot set headers, so a web page uses the subprotocol:

const ws = new WebSocket("wss://agent.example.com/rtvi", ["brisvox", "brisvox-token." + token]);

The server selects brisvox (so the token is not echoed back); a client that offers only the brisvox-token. entry gets no subprotocol in the answer (the token entry is never echoed; a browser then refuses the connection, so pages offer both entries). Prefer the header or the subprotocol to the query string: a query string lands in proxy and CDN logs, browser history and Referers. If you must use ?token=, strip the query from your proxy's access log (nginx: log $uri, not $request; Caddy: log { format filter { request>uri query { delete token } } }). Every answer the server produces during signaling or the upgrade carries Cache-Control: no-store.

Minting

The secret is BRISVOX_SESSION_SECRET on the server (at least 32 bytes; shorter is refused at start). Generate it with openssl rand -base64 48. Your backend holds the same value and never sends it to a browser.

Rust (brisvox_transport::auth, features = ["auth"]):

#![allow(unused)]
fn main() {
use std::collections::BTreeMap;
use brisvox_core::Secret;
use brisvox_transport::auth::{Audience, TokenSpec, mint_token};

let now = clock.wall().duration_since(std::time::UNIX_EPOCH)?.as_secs();
let token = mint_token(
    &Secret::new(std::env::var("BRISVOX_SESSION_SECRET")?),
    &TokenSpec {
        audience: Audience::Webrtc,
        sid: random_id(),               // unique per token
        not_before: 0,
        expires: now + 60,
        agent: Some("support".into()),
        max_s: Some(600),
        meta: BTreeMap::from([("user".into(), "u-42".into())]),
    },
)?;
}

Python (sdk/python/brisvox_agent/session_token.py has this, with tests):

import base64, hashlib, hmac, json, os, secrets, time

def mint(aud, ttl=60, **claims):
    payload = json.dumps(
        {"aud": aud, "exp": int(time.time()) + ttl, "sid": secrets.token_urlsafe(16), **claims},
        separators=(",", ":"),
    ).encode()
    sig = hmac.new(os.environ["BRISVOX_SESSION_SECRET"].encode(), payload, hashlib.sha256).digest()
    b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
    return f"v1.{b64(payload)}.{b64(sig)}"

token = mint("brisvox-webrtc", agent="support", max_s=600, meta={"user": "u-42"})

TypeScript / Node (sdk/typescript/src/session-token.ts, exported as mintSessionToken):

import { createHmac, randomBytes } from "node:crypto";

export function mint(aud: string, ttl = 60, claims: object = {}): string {
  const payload = Buffer.from(JSON.stringify({
    aud, exp: Math.floor(Date.now() / 1000) + ttl, sid: randomBytes(16).toString("base64url"), ...claims,
  }));
  const sig = createHmac("sha256", process.env.BRISVOX_SESSION_SECRET!).update(payload).digest();
  return `v1.${payload.toString("base64url")}.${sig.toString("base64url")}`;
}

The three implementations produce byte-identical tokens for the same input (the Python and TypeScript tests and the Rust test tokens_minted_by_the_sdk_helpers_verify share golden vectors). Integers only for exp, nbf and max_s (a float timestamp does not parse).

Advice: mint on demand, per page load or per call; TTL 30 to 120 seconds (the token is only needed until the call starts); never log tokens; do not mint for anonymous visitors without your own rate limit, because every token you hand out is a call you pay for.

Rotation

  1. Generate a new secret. Set it as BRISVOX_SESSION_SECRET and the old one as BRISVOX_SESSION_SECRET_PREV; restart. Tokens signed with either verify.
  2. Point your backend at the new secret.
  3. After the longest token TTL, remove BRISVOX_SESSION_SECRET_PREV.

Flood limits (WebRTC signaling)

Opening a WebRTC call costs the server a call slot for up to connect_timeout (15 s) before the browser even connects, and an attacker needs no media to do that. The signaling endpoint therefore limits, in this order, after the credential check:

  1. Offers per address per minute: a token bucket, default 5 (burst 5, one more every 12 s; BRISVOX_WEBRTC_OFFERS_PER_MINUTE, off disables). Over it: 429 with Retry-After. An IPv6 client counts as its /64. The table of tracked addresses is capped at 8192; when it is full and no bucket can be dropped, the offer is let through untracked (counted in rate_table_full), because failing closed there would let an attacker lock everyone out; the other limits still apply.
  2. Calls in total (BRISVOX_MAX_SESSIONS, twice that at the transport): 503.
  3. Calls still handshaking: a smaller pool, default a quarter of BRISVOX_MAX_SESSIONS (a library user of the transport: a quarter of max_calls) (BRISVOX_WEBRTC_MAX_HANDSHAKING). A call leaves it when ICE and DTLS complete, or when connect_timeout abandons it. Offers that never connect cannot take more than that share. Over it: 503 with Retry-After.
  4. Calls per address at once, default 8 (BRISVOX_WEBRTC_MAX_CALLS_PER_IP): 429.

Behind a reverse proxy the TCP peer is the proxy, so without more every visitor would share one address and one bucket. Set BRISVOX_TRUSTED_PROXIES (CIDR list, for example 10.0.0.0/8,172.16.0.0/12). Only when the TCP peer is in that list does the server read one forwarding header, chosen with BRISVOX_CLIENT_IP_HEADER: x-forwarded-for (the default), forwarded (RFC 7239) or x-real-ip (one address; a list or a repeated header is ignored, and the TCP peer stands). The other headers are never looked at, so a client cannot plant a value in the header your proxy does not write. Set it to the one your proxy writes. The server takes the last hop that is not itself a trusted proxy: the entry your nearest proxy wrote. Entries further left are whatever the client claimed and are ignored, so a client cannot choose its identity by sending its own header. From any other peer the headers are ignored entirely. Make your proxy append to (or replace) the header rather than pass the client's through. If the list is empty, the per-address limits apply to the proxy's address: set it.

List the proxies' real addresses, never 0.0.0.0/0 (or ::/0). A catch-all makes every client a "trusted proxy": a client that connects directly then writes the header itself. Every entry of the same address family is skipped as "a proxy" (the limit falls back to the peer), but an entry of the other family (an IPv6 hop when only 0.0.0.0/0 is listed) is believed, so a client picks its own address and the per-address limits stop limiting. Treat a catch-all as "limits off".

The per-address call cap (item 4) is keyed like the offer bucket: an IPv4-mapped IPv6 address counts as its IPv4 address and any other IPv6 address as its /64, so a client cannot multiply its cap with the addresses of its own prefix.

Signaling connections per address at once: default 8 (BRISVOX_WEBRTC_HTTP_MAX_PER_IP, 0 = no limit), so a stranger cannot hold every signaling slot with idle connections (the excess is closed unanswered). Connections from a trusted proxy are exempt (they are many visitors'; the proxy's own limits apply). An IPv6 client counts as its /64.

Counters (WebRtcServer::stats): offers_unauthorized, offers_rate_limited, offers_ip_limited, offers_handshake_limited, rate_table_full.

The generic WebSocket, RTVI and Telnyx listeners are bounded by max_calls, the handshake and start timeouts and the manager's admission (docs/OPERATIONS.md); they have no per-address limit: put one in the proxy.

Health and metrics

/healthz and /readyz carry counts only and stay open: Kubernetes probes the pod's address and a container runtime probes from outside the network namespace, so binding the health listener to loopback would break them. /metrics carries call counts, error rates and latencies; it is off (404) on a non-loopback health address unless BRISVOX_METRICS_TOKEN is set, and then requires Authorization: Bearer <token> (constant time, 401 without it). Prometheus:

scrape_configs:
  - job_name: brisvox
    authorization: { credentials_file: /etc/prometheus/brisvox-metrics-token }
    static_configs: [{ targets: ["brisvox:8081"] }]

The server logs a warning when it turns /metrics off for this reason. BRISVOX_INSECURE_LISTENERS=1 serves it openly (a private network).

The WebSocket fallback on the signaling port

GET /ws with a WebSocket upgrade starts a call exactly as POST /whip does, so it gets the same protection and nothing weaker:

  • Credential. The signaling listener's own: the static BRISVOX_WEBRTC_TOKEN, or a session token with aud brisvox-webrtc (the same token a page would send to /whip; no new audience). It travels in Authorization: Bearer, the subprotocol brisvox-token.<token> (what a browser can set) or ?token= (logged by proxies: prefer the others). Missing: 401. Invalid, expired, wrong audience or already used: 403. Over 4 KiB: 413. Fail closed: with neither secret configured the listener is open exactly as POST /whip is, and the server refuses to start such a listener on a non-loopback address.
  • Single use. A session token is checked, then used up only after the call has its slot (a refusal for capacity does not burn it). Two exceptions, both for a request that never started a call. (1) The page hangs up (DELETE) a WebRTC session that had not connected (ICE and DTLS incomplete): the token is given back before the 200 is sent, so the page's WebSocket fallback right after it finds the token usable. (2) The server could not serve a request it had already accepted the token for (the media relay is unavailable: 503). Nothing else gives a token back: not a connect timeout, not a closed or lost connection, not a DELETE of a session that had connected (the 4 s page timer can fire while the browser is still finishing ICE, so a late WebSocket attempt may find the token spent: the page says so, mint a new token). A session id is refunded at most once (until exp + skew, it is remembered as refunded in a set bounded like the single-use set; when that set is full a refund is refused, never an entry evicted): a token cannot be cycled through POST / DELETE / POST to hold handshake slots until it expires. A token that opened a call is never given back, so a token still opens at most one call; the retries the one refund allows are bounded by the offer rate and the call caps below, and a refunded session cost the provider nothing (no engine call existed).
  • Origin. A browser does not apply CORS to WebSockets, so the server checks Origin itself, to the same effect as the CORS header of POST: with BRISVOX_WEBRTC_CORS_ORIGIN=* any origin (credentials are then required), with an origin that origin or the page's own host, with none only a page served from the request's Host (compared without the scheme). A request without Origin (not a browser) is judged by its token alone. A proxy that rewrites Host makes the default rule fail: set BRISVOX_WEBRTC_CORS_ORIGIN to the page's origin.
  • Limits. The same offer rate per address (BRISVOX_WEBRTC_OFFERS_PER_MINUTE: an upgrade counts as an offer, so a page that falls back uses two), per-address call cap (BRISVOX_WEBRTC_MAX_CALLS_PER_IP), handshake pool (BRISVOX_WEBRTC_MAX_HANDSHAKING, held until the start message), the call limit, and BRISVOX_MAX_SESSIONS when the call reaches the engine. The upgrade leaves the HTTP connection pools once it is a call (it is bounded by the call limits then). A session token's max_s ends the call (stop{ended}); this is the one WebSocket path that enforces it (the standalone BRISVOX_WS_ADDR listener only reports it).
  • Bounds. HTTP head 8 KiB and 64 fields, Sec-WebSocket-Key a 16-byte base64 value, version 13 only, every refusal plain HTTP before the upgrade; after it the generic protocol's frame limits (64 KiB audio, 16 KiB control), start in 5 s, pings, bounded queues (docs/WEBSOCKET_PROTOCOL.md). BRISVOX_WEBRTC_WS_PATH=off closes the door (404).

Reverse proxy checklist

  • HTTPS on every public listener; HTTP/1.1 upgrade for the WebSocket paths; a read timeout above the longest silence you expect (the server pings every 10 s).
  • WebRTC: proxy only the TCP signaling port (it also carries the WebSocket fallback at /ws, which needs the Upgrade pass-through above). Media is UDP on BRISVOX_WEBRTC_UDP_ADDR; forward it and set BRISVOX_WEBRTC_CANDIDATE_IP. Serve the page from the proxied origin, or set BRISVOX_WEBRTC_CORS_ORIGIN to the page's exact origin (never * without credentials: the server refuses that).
  • Pass the client address in X-Forwarded-For and list the proxy in BRISVOX_TRUSTED_PROXIES.
  • Do not log query strings for RTVI/WebSocket paths if clients use ?token=.
  • Keep 8081 (health), the callback listener and SIP off the public internet.
  • Limit connections per address and request rates in the proxy too; the server's own limits are the last line.

nginx fragment for WebRTC signaling:

location /whip {
    proxy_pass http://127.0.0.1:8088;
    proxy_set_header X-Forwarded-For $remote_addr;   # replaces what the client sent
    proxy_set_header Host $host;
    client_max_body_size 128k;
    limit_req zone=whip burst=10 nodelay;
}

Covered since, and what is still open

Delivered after this document's first version (2026-10-13): an agent link refuses ws:// to a public host unless allow_insecure; Twilio stream URLs are signed per call and single use; SideEffect tools can require a confirmation in a later user turn (confirm, docs/TOOLS.md); recordings can be encrypted at rest with age (docs/PRIVACY.md); browsers get short-lived TURN credentials, ICE-TCP covers UDP-blocking networks, and a server without UDP can relay its media through TURN (docs/DEPLOY.md).

Still open: SIP over TLS with SDES-SRTP is built and tested against scripted peers ( docs/TELEPHONY.md "SIP over TLS and SRTP") but not yet against a real carrier, has no DTLS-SRTP, no AEAD/256-bit suites and no certificate hot reload (an adversarial review of the SIP/SRTP work is fixed: the dialog's transport is recorded from the real connection and in-dialog requests must stay on TLS, a re-key brings a fresh local key, a suite change needs a new key, TLS connections have an idle timeout, key lifetimes below 2^31 are refused, SDES keys are redacted from logs, outbound TLS through a proxy verifies the callee's domain, and a burst of failed SRTP authentications ends the call), and the cleartext UDP/TCP listeners stay private-trunk-only; max_s is enforced on the WebRTC signaling port only (WebRTC calls and its /ws fallback), not on the standalone WebSocket listener or the carriers' listeners; TLS for every listener terminates at a reverse proxy, never in the server itself (mandatory for WebRTC signaling: without HTTPS the DTLS fingerprints can be swapped); the replay set and the Twilio used-set live in one process's memory (see above).

Variable reference

VariableDefaultMeaning
BRISVOX_SESSION_SECRETunsetSigns session tokens (at least 32 bytes). Enables them on the generic WebSocket, RTVI and WebRTC listeners.
BRISVOX_SESSION_SECRET_PREVunsetThe previous secret, still verified during a rotation. Needs BRISVOX_SESSION_SECRET.
BRISVOX_WS_TOKENunsetStatic token of the generic WebSocket listener (development).
BRISVOX_INSECURE_LISTENERSfalseAllow non-loopback listeners without authentication, and an open /metrics there.
BRISVOX_TRUSTED_PROXIESemptyCIDR list of reverse proxies whose forwarding header is believed (WebRTC). Never 0.0.0.0/0.
BRISVOX_CLIENT_IP_HEADERx-forwarded-forThe only forwarding header believed behind a trusted proxy: x-forwarded-for, forwarded or x-real-ip (one address).
BRISVOX_WEBRTC_HTTP_MAX_PER_IP8Signaling connections per client address at once (0 = no limit); trusted proxies are exempt.
BRISVOX_WEBRTC_MAX_CALLS_PER_IP8Calls per client address at once, or off.
BRISVOX_WEBRTC_MAX_HANDSHAKINGa quarter of BRISVOX_MAX_SESSIONSCalls in their ICE/DTLS handshake at once.
BRISVOX_WEBRTC_OFFERS_PER_MINUTE5Offers per client address per minute, or off.
BRISVOX_METRICS_TOKENunsetBearer token /metrics requires on a non-loopback health address.

Recording and privacy

Code: brisvox_server::privacy; the engine seam is brisvox_engine::CallerAudioTap (SessionBuilder::with_caller_audio_tap). This page says what the server can write to disk, how to turn it on, what the redaction does and does not do, and what an operator still has to decide. It is engineering documentation, not legal advice: laws on recording calls differ by country and by who the caller is, and the checklist at the end is a prompt for the conversation with your own counsel, not a statement of what the law requires.

What is recorded

Nothing, by default. Recording is on only when BRISVOX_RECORDING_DIR is set. Per call it can write up to three files into that directory, which must be dedicated to the recordings (see "Retention"):

FileHoldsOn by default once the directory is set
call-<unix start>-<session>.transcript.jsonlA header line, one line per user turn and per bot response, an end line.yes
call-<unix start>-<session>.caller.wavThe caller's audio, mono PCM16, at the call's own rate (8 kHz telephony, 16 kHz websocket, ...).no
call-<unix start>-<session>.bot.wavThe bot's audio, mono PCM16, at the TTS rate, laid out on the call's timeline.no (same switch)

The file name carries the call's start (unix seconds) and the server's session counter, nothing about the caller: no phone number, name or stream id is written anywhere by this feature. If your deployment can map a session to a caller (the agent link, telephony metadata), the files become linkable to a person through that mapping and are personal data either way.

Transcript format

JSON lines, UTF-8:

{"type":"call","version":2,"session":17,"agent":"support","started_unix":1760000000,"pii_redacted":true}
{"type":"user","t_ms":4210,"text":"my email is [EMAIL] and my number is [PHONE]"}
{"type":"bot","t_ms":6120,"text":"Thanks, I noted [EMAIL].","interrupted":false,"heard":"full","cut":false}
{"type":"user","t_ms":9800,"text":"stop, only the weekday hours"}
{"type":"bot","t_ms":10450,"text":"We open at nine.","interrupted":true,"heard":"partial","estimated":false,"round":0,"barge_in_ms":9300,"cut":false}
{"type":"end","t_ms":31800,"events_lost":0}
  • t_ms is milliseconds since the recording of the call started, on the server's clock.
  • user lines hold the whole text the LLM answered for that turn (spoken finals and typed text); interim hypotheses are not recorded.
  • bot lines hold only what the caller heard, never the text that was merely sent to speech synthesis (a barge-in leaves sentences nobody heard; writing them would put words in the caller's file that were never delivered). See "What a bot line holds" below.
  • events_lost counts conversation events the writer fell too far behind for (a stalled disk). Normally 0.

What a bot line holds

heardWritten whentext
fullthe transport reported the response played to its end (BotStoppedSpeaking), or the engine's answer says the whole round was playedthe response's text ("cut": true if a very long one was truncated at 32 KiB)
partialthe response was interrupted ("interrupted": true) and the engine settled what was playedthe heard prefix only, from the same played-text truncation (PlayoutBook) that cuts the LLM's own context, so the transcript and the LLM agree on what the caller heard; "estimated": true when it is a speaking-rate estimate because the TTS sent no word timestamps
unknownthe heard extent is not known: the engine had no answer, the answer or a conversation event was lost (events_lost, RecordingStats::heard_lost), or the call ended before the engine settledempty. The unheard text is never written as a guess

The engine settles what was played when the caller's next turn ends, or at the end of the call, so the line of an interrupted response is written then (a partial or unknown bot line can appear after the following user line; barge_in_ms says when the barge-in itself happened, and there is one line per LLM round, with round). A response whose BotInterrupted event was lost is never written as complete: without the transport's confirmation of the end, only the engine's answer or unknown is written. Nothing is held back: text that is not going to be written is dropped from memory as soon as its response ends unconfirmed.

Audio format

Two separate mono WAV files, so each side keeps its native rate and nothing is mixed or resampled. Both start at the call's start (to within one frame); the bot file carries silence between responses and only the audio that was due to be heard: when the caller barges in, what the bot had queued but not yet played is cut from the file (to the accuracy of the transport's pacing). Missing caller frames (the engine drops the oldest audio when it falls behind) are filled with silence of the missing length, so timing holds.

The header is written with the streaming length first and rewritten with the true lengths at the end of the call, so a file left by a crash still plays (decoders read it to the end of the file). Recorded audio per call and track is capped (BRISVOX_RECORDING_MAX_MINUTES). Samples are written in batches of 64 KiB (one disk operation per batch, not one per 20 ms frame), so a crash loses at most the last 64 KiB of a track. Only whole 16-bit samples are written: a frame with an odd byte count loses its last byte (counted), so the rest of the file never shifts.

Encryption at rest

Optional, and compiled in only on request: build brisvox-server with the cargo feature recording-encryption (it adds the age crate, 0.12, MIT OR Apache-2.0, and a larger crypto dependency tree, which is why it is off by default) and set BRISVOX_RECORDING_AGE_RECIPIENTS to one or more age X25519 public keys, comma-separated. Every recording file of every call (the transcript and both WAV files) is then written as a standard age file with the extra suffix .age, for example call-1760000000-17.transcript.jsonl.age. The server never holds a private key: it can write recordings but not read them back, so a stolen disk, backup or volume snapshot exposes nothing without the key you keep elsewhere. No plain file is written next to it.

Make a key pair on a machine you trust, not on the server:

age-keygen -o key.txt          # prints "Public key: age1..." ; key.txt holds the private key

Put only the age1... line on the server (BRISVOX_RECORDING_AGE_RECIPIENTS=age1...; several keys let several people, or a backup key, decrypt). Keep key.txt offline or in your secret manager; anyone who has it can read every recording made for its public key. Losing it loses the recordings. Decrypt with the stock tool:

age -d -i key.txt call-1760000000-17.transcript.jsonl.age > transcript.jsonl
age -d -i key.txt call-1760000000-17.caller.wav.age > caller.wav

Startup checks: a value that is not an age1... X25519 recipient, a private key pasted by mistake (AGE-SECRET-KEY-..., refused without being printed), more than 16 recipients, or the variable set in a build without the feature (the error names the feature) all stop the start. A blank BRISVOX_RECORDING_AGE_RECIPIENTS (set but empty) means plain recordings, exactly as if it were unset. Like every recording variable it needs BRISVOX_RECORDING_DIR.

Behaviour to know:

  • A WAV file keeps the streaming header (length fields 0xFFFFFFFF): an encrypted stream cannot be rewritten at the end of the call, so the true lengths are never patched in. Players and converters (ffmpeg, sox) read to the end of the file; ffmpeg -i caller.wav -c copy fixed.wav writes a file with proper lengths if a tool insists on them.
  • An unfinished file is not fully readable. age releases a 64 KiB chunk only when more data follows it, and writes the final chunk with an end mark when the file is closed. A call whose process crashes leaves an .age file that decrypts up to the last full chunk and then fails with a truncation error. A transcript is a few KiB, so it is typically lost entirely; a WAV loses its tail from the last full 64 KiB chunk. (Plain files lose at most the last 64 KiB of a WAV.) Files of a call that ended normally, including one cut by an orderly shutdown (the server finishes the recordings first), are complete. The one exception is the 15 s finish timeout at shutdown: a recording that has not finished by then is aborted and its encrypted file is truncated like after a crash.
  • Cost. The encryption of each batch runs on the blocking pool, never on an async worker, and every disk operation keeps its timeout; an encryption that stalls past the timeout stops that file's recording like a failed write (counted, logged once) and never the call.
  • Retention treats X.age exactly like X: the sweeper deletes call-*.transcript.jsonl.age, call-*.caller.wav.age and call-*.bot.wav.age by age, and still touches nothing else.
  • Encryption protects files at rest. The server process still sees the plaintext in memory while the call runs, and the redaction (above) still applies before the transcript is encrypted.

Settings

Two sources, the agent file winning over the environment:

  1. Environment variables (docs/OPERATIONS.md has the full table). An invalid value, or a recording variable set without BRISVOX_RECORDING_DIR, stops the start with a message naming the variable: a privacy setting is never silently ignored.
  2. The agent file's [recording] table (TOML and YAML, docs/CONFIG.md): transcript, audio, redact, redact_kinds, retention_days. A key set there wins over the matching variable. The directory is environment-only. A [recording] table with any key but no BRISVOX_RECORDING_DIR is an error at startup.
VariableFile keyDefaultMeaning
BRISVOX_RECORDING_DIR-unset (nothing recorded)Directory for the files; created at startup. Use a directory dedicated to the recordings.
BRISVOX_RECORDING_TRANSCRIPTStranscripttrueWrite the transcript.
BRISVOX_RECORDING_AUDIOaudiofalseWrite the two WAV files. Audio cannot be redacted.
BRISVOX_RECORDING_REDACT_PIIredacttrueRedact the transcript text before it is written.
BRISVOX_RECORDING_REDACT_KINDSredact_kindsall sixWhich kinds the redaction replaces: email,iban,id,card,phone,number.
BRISVOX_RECORDING_RETENTIONretention_days30dDelete recordings this old (90s, 15m, 36h, 30d; at least 1h and at least the audio cap; forever to keep them; the file takes whole days, 1 to 3650).
BRISVOX_RECORDING_SWEEP_INTERVAL-1hHow often the sweeper runs (at least 1m).
BRISVOX_RECORDING_MAX_MINUTES-120Longest audio kept per call and track (1 to 600).
BRISVOX_RECORDING_AGE_RECIPIENTS-unset (plain files)Comma-separated age X25519 public keys (age1...): every recording file is written encrypted as <name>.age. Needs the cargo feature recording-encryption (a start error otherwise). See "Encryption at rest".

The first versions of the three switches were named BRISVOX_RECORD_TRANSCRIPTS, BRISVOX_RECORD_AUDIO and BRISVOX_REDACT_PII. They still work as aliases of the BRISVOX_RECORDING_* names above; setting both names to different values is an error.

The server logs, once at startup, whether recording is on and with which switches, and warns when audio is recorded or retention is forever.

PII redaction

With redaction on (BRISVOX_RECORDING_REDACT_PII=true, the default) every user and bot line goes through redact_pii before it is serialized: the original text never reaches the disk. redact_kinds narrows it to some of the kinds below (turning a kind off leaves that kind in the file; an empty list is refused). It replaces:

WhatRulePlaceholder
Email addresslocal@domain.tld[EMAIL]
IBANtwo letters, two digits, up to 30 more characters (single spaces allowed), ISO 7064 mod-97 checksum[IBAN]
Turkish national ID (TC kimlik no)11 digits with the two official check digits[ID]
Payment card number13 to 19 digits (spaces, dashes, dots between groups), Luhn checksum[CARD]
Phone number7 to 15 digits (leading +, spaces, dashes, dots, parentheses)[PHONE]
Any other long number16 or more digits[NUMBER]

It leaves dates (2026-10-13, 13.10.2026), digits inside words and codes (A1234567890), and numbers of fewer than seven digits alone. It errs toward redacting: an amount like 1.234.567 or any other run of seven or more digits is replaced even though it is not a phone number.

What redaction does not do

  • Audio is never redacted. caller.wav and bot.wav contain everything said. If you record audio, the transcript's redaction protects nothing that the audio holds. The server warns about this at startup.
  • Digits spoken as words ("five three two ...") are not recognised, nor are spelled-out emails ("jane at example dot com"), unless the STT already wrote them as digits and symbols.
  • Names, street addresses, dates of birth, health and other free-text details are not detected. There is no named-entity model in the redactor.
  • Logs are scrubbed of credentials only, unless you opt in (next section).
  • It is a pattern filter with checksum validation for IBANs, IDs and cards, not a proof. Treat the redacted transcript as reduced-risk data, not as anonymous data.

Logs

Log lines never contain transcript text or audio: they carry counts, file names and error text. Provider error messages and a few diagnostics can still echo user-derived text, so logs have an opt-in PII scrub on top of the always-on credential scrub (docs/TRACING.md):

VariableDefaultMeaning
BRISVOX_LOG_REDACT_PIIfalseAlso replace emails, IBANs, national IDs, card and phone numbers in every log line, with the same filter and placeholders as the transcripts.
BRISVOX_LOG_REDACT_KINDSemail,iban,id,card,phoneNarrow or widen the kinds (number also replaces any 16+ digit run). Setting it without BRISVOX_LOG_REDACT_PII=true is an error.

It is off by default because the filter errs toward redacting: a counter or a duration of seven or more digits (elapsed_ns=1234567) is shown as [PHONE]. Turn it on where logs leave the trust boundary (a hosted log service) and accept the noise.

The caller's number and the agent process

An SDK agent (docs/AGENT_PROTOCOL.md) does not learn who is calling unless you ask: the caller's number, the called number and the carrier's call id reach it in session.start.call only when the definition sets [agent_link] caller_identity = true (or the default process is opted in with BRISVOX_AGENT_CALLER_IDENTITY=1). A route to another agent process has its own switch and never inherits it. It is the transfer of a personal identifier to another process (and possibly another team), so it is off by default and the agent process becomes part of your data map.

The redaction settings above are respected: while the server redacts phone numbers, the numbers are not given to the agent process either. That is the case when recording is on with redaction enabled and the phone kind (or the number kind: it replaces only digit runs of 16 or more digits, but it counts as covering phone numbers here, on the safe side) selected (the default whenever BRISVOX_RECORDING_DIR is set and a transcript or audio is written), or when BRISVOX_LOG_REDACT_PII=true with the phone or number kind. The agent then gets carrier, carrier_call_id and pii_redacted: true, and no from or to. The carrier's call id is an opaque identifier, not a number, and stays. If your agent needs the number to verify or greet the caller, either turn the redaction off for the kinds it conflicts with (a decision for your counsel, since the number then also appears in transcripts or logs) or look the caller up by the carrier's call id through the carrier's API.

The server does not write the numbers to its own logs or recordings from this path. Your agent process should treat them like any other caller data: keep them out of its logs and apply your retention to whatever it stores.

Retention

The sweeper runs at startup and then every BRISVOX_RECORDING_SWEEP_INTERVAL. It looks at the files directly in the recording directory (no recursion, symlinks are not followed), considers only names this feature generates (call-*.transcript.jsonl, call-*.caller.wav, call-*.bot.wav, each also with the .age suffix of an encrypted recording), and deletes those last modified at least BRISVOX_RECORDING_RETENTION ago. Anything else in the directory is never touched. A sweep reads the directory in rounds of 4 096 entries (yielding to the runtime between rounds) until the listing is exhausted, so a file is reached however many other entries the directory holds; only a listing of more than 10 million entries stops a sweep early (it is reported as incomplete and the next sweep continues). Keep the directory dedicated to the recordings: every foreign entry is still read and counted by the sweep, a huge unrelated directory makes each sweep slow, and files the feature did not name are never cleaned up. Deletion is a plain unlink: it does not overwrite the blocks, and it does not reach backups, snapshots or copies you made. Combine it with disk encryption.

The sweeper's task handle is kept for the life of the server: at shutdown the server waits for it and logs its counters; a panic of the sweep is logged as an error and counted (SweepStats::panics), because it means expired recordings are no longer being deleted.

Retention counts from the file's last write, so a call that is still running keeps its files young; the minimum of one hour (and the audio cap) exists so a sweep can never take the files of a call in progress.

To delete one call's recording on request, remove its three files by hand (the session number and start time are in the name and in the transcript's header line).

How it stays out of the call's way

  • The audio taps run on the pipeline's own tasks and only copy a Bytes handle into a bounded queue of 1 024 frames. A full queue drops the newest frame and counts it (RecordingStats::frames_dropped); the call is never slowed. The last 16 slots are reserved for barge-in markers: audio is refused before they are used, so a stalled disk does not cost the bot track its barge-in cut (a marker that still finds no room is counted, RecordingStats::interrupts_lost).
  • The bot track keeps at most 30 s of audio that is still waiting to be due; a response synthesized much faster than real time loses the audio past that (the newest is dropped and counted) instead of growing memory.
  • The answers of the engine about what was played reach the transcript task through a bounded queue of 64; a full queue drops the newest answer and counts it (RecordingStats::heard_lost), and that line is written as unknown.
  • The transcript reads the call's conversation-event ring, which drops its oldest events for a slow reader. Nothing is added to the pipeline.
  • Every disk operation has a 10 s timeout (measured on the server's injected clock). A failing file is disabled, logged once (the file name and the error, never text) and the call continues; recording failures never end or fail a call.
  • When the call ends, the recording tasks drain what is queued and finish the files; the call's teardown waits for that for at most 15 s.
  • Files are created exclusively (an existing file is never overwritten) and, on Unix, with mode 0600. Make the directory itself private too.

These are the questions that usually come up under Turkey's KVKK (Law No. 6698) and the EU/UK GDPR; other regimes ask similar ones.

  1. Do you need to record at all? Data minimisation points to transcripts with redaction on, audio off, and the shortest retention that serves the purpose.
  2. Basis and notice. Both regimes expect a lawful basis (consent, contract, legitimate interest, or a legal obligation, depending on the case) and that callers are told, before or at the start of the call, that it is recorded, why, for how long and how to exercise their rights. Put the notice in the agent's greeting (system_prompt/first message) and in your privacy notice. Recording calls can additionally be regulated by telecommunications or interception rules that differ by country and by whether one or both parties must consent.
  3. Special categories. A voice recording used to identify or authenticate a person can be biometric data, and a call can contain health or other special-category details. Both laws treat these more strictly. Redaction does not detect them.
  4. Processors and transfers. The STT, LLM and TTS providers receive the call's content whether or not you record. Recording adds a second copy under your control. Check your agreements with each provider and the rules for sending data abroad (KVKK Art. 9, GDPR Chapter V).
  5. Registers and records. Depending on your size and role, KVKK may require registration with VERBIS and GDPR a record of processing activities; recordings belong in them.
  6. Security. Encrypt the volume, restrict access to the directory, keep backups on the same retention, and log who reads recordings (the server does not read them back).
  7. Rights requests. Be able to find and delete one caller's recordings on request. Because file names carry no caller identity, you need your own mapping from caller to session/start time (for example from your telephony or CRM logs).
  8. Retention. Set BRISVOX_RECORDING_RETENTION to what you can justify. forever is possible and logged as a warning; it is rarely defensible.

Testing

crates/brisvox-server/src/privacy/**/tests (redaction, the tracks, the settings, the sinks on a real file system, the sweeper), crates/brisvox-engine/tests/audio_in.rs (the caller-audio tap) and crates/brisvox-server/tests/recording.rs (a call through the engine runner and the RTVI listener, then the files it left).

Errors and tracing conventions

Status: in force. Code: brisvox_core::{error, telemetry}, brisvox_server::telemetry.

Errors

  • Every library crate has src/error.rs with one #[non_exhaustive] pub enum Error built with thiserror, plus pub type Result<T, E = Error>. Both are re-exported at the crate root.
  • brisvox_core::Error holds the cross-cutting cases (Cancelled, Timeout { what, after }, ChannelClosed). Every other crate wraps it as Error::Core(#[from] brisvox_core::Error) (#[error(transparent)]), so ? converts it without extra code.
  • No anyhow / eyre in library crates. Binaries report errors with eprintln! + ExitCode.
  • Error messages never contain credentials. Provider error detail strings are written by our adapters and must not echo request headers or URLs that carry keys.
  • Cancelled is not a fault: log it at DEBUG at most, never at ERROR.

Spans

Create spans only through the constructors in brisvox_core::telemetry, so names and fields stay stable for log queries:

SpanConstructorLevelFieldsLifetime
sessionsession_span(id)INFOidwhole session (root of all session work)
turnturn_span(epoch)INFOepochone turn; the epoch is the interrupt epoch
stagestage_span(name)DEBUGname ("vad", "stt", "llm", "tts", …)one stage's work inside a turn

Nesting is session{id}:turn{epoch}:stage{name}. Spawned tasks must carry their parent span (.instrument(span)), or their events lose the session context.

Levels

  • ERROR: a session failed or a server-level fault happened. Someone should look.
  • WARN: degraded but recovered (retry, fallback provider, dropped frames by overflow policy).
  • INFO: lifecycle (session start/end, turn boundaries, provider connect/close). Low volume only.
  • DEBUG: per-stage detail, decisions (turn-end, interruption reasons).
  • TRACE: per-frame / per-token detail. Never emit above TRACE on the per-frame hot path, and never create spans per audio frame. Disabled callsites are near-free (cached interest check).

Secrets

  • Every credential (API keys, tokens, auth headers, signed URLs) is stored as brisvox_core::Secret<T>. Its Debug and Display print [REDACTED], so logging a config struct with ?cfg is safe.
  • Read the value only with Secret::expose() where it is sent to its destination. Grep for expose() when auditing.
  • Secret has no PartialEq/Deref/AsRef/Hash: == on credentials is not constant-time, and implicit access would bypass expose(). When serde arrives: implement Deserialize only; any Serialize must write [REDACTED].
  • Test telemetry::tests::secret_is_redacted_in_tracing_output proves secrets do not leak through %, ?, nested Debug and format arguments.

Subscriber

Binaries call brisvox_server::telemetry::init(format) once at startup:

  • Filter from RUST_LOG (default info; unset or blank both mean the default). An invalid or non-unicode RUST_LOG is a startup error, not a silent fallback.
  • The TURN client crates' events and spans (turn_client_proto, stun_proto, turn_types) below warn are dropped by a per-layer filter in front of the output, whatever RUST_LOG says (they print whole STUN messages at trace); BRISVOX_LOG_TURN_CLIENT=1 lifts it. Embedders with their own subscriber apply brisvox_transport::webrtc::turn_client_log_allowed (or brisvox_core::telemetry::turn_client_log_allowed) themselves.
  • The SIP stack's events and spans (rsipstack) below warn are held back the same way, whatever RUST_LOG says: the stack logs every SIP message at info, whole, which on a secure call includes the SDES SRTP master keys. BRISVOX_LOG_SIP_STACK=1 lifts it (debugging only); embedders apply brisvox_core::telemetry::sip_stack_log_allowed.
  • Format from BRISVOX_LOG_FORMAT: text (default) or json (one object per line).
  • A second init/install returns Error::Telemetry and does not panic. install(format, filter) is the environment-free variant (used by tests and embedders).

Libraries never install a subscriber. Tests that need log output install a scoped one with tracing::subscriber::with_default.

Metrics, traces and log redaction

brisvox_server::observability (operator view: docs/OPERATIONS.md, "Observability"):

  • Turn traces are built from the timeline, not from tracing spans. The per-stage marks (brisvox_core::timeline) already hold every stage's start and end on the call's Clock. When a turn finishes, the TimelineSink of the call's recorder turns its TurnRecord into one OpenTelemetry trace (a turn root and a child span per stage) and the stage latencies into Prometheus histograms. The session/turn/stage spans above stay what they are: log context. Nothing is added per audio frame, token or chunk.
  • Cost. One finished turn costs a handful of atomics, about a dozen small allocations for the trace and one non-blocking export; observability::tests::a_turn_costs_microseconds measures the whole observed turn, 11 marks included.
  • Exporters implement TraceExporter and must not block. MemoryExporter (bounded) is for tests and embedders; OtlpExporter (feature otlp) queues to a bounded channel (drops the newest when full, counted) and posts OTLP/HTTP JSON in batches under a timeout.
  • Redaction. telemetry::init installs the subscriber with a RedactingMakeWriter: each event is buffered whole and scrubbed (Redactor) before it is written, so a credential in an error text, a URL or a header dump never reaches stdout. Secret remains the first line; observability::tests::secrets_never_appear_in_{text,json}_logs run the real subscriber. A new place that writes logs by another route (a custom writer) must use the same wrapper. With BRISVOX_LOG_REDACT_PII=true the same pass also replaces personal identifiers (emails, IBANs, IDs, card and phone numbers; Redactor::with_pii, docs/PRIVACY.md "Logs").

Benchmark results

Every figure on this page comes from a results file committed under bench/results/ (the file starts with the command that produced it and a description of the machine and setup) or from Latency design and measurements. Regenerate a file with the command at its top.

Read this first

  • All numbers below are from a Windows x86_64 development machine (32 logical cores, release build, shared with other workloads), over mock providers with fixed, configured latencies (STT final 150 ms, LLM first token 350 ms, TTS first byte 150 ms). They show how much time the engine adds on top of providers. They do not show what a live call over real providers feels like.
  • Linux numbers exist for two things (WSL2, kernel 6.6, Docker, over mocks, 2026-10-13): the load simulation confined to 4 cores (sim-baseline-linux.md: 500 calls, overhead p99 0.185 ms; the saturation sweep passes 10 ms of wake p99 at 10000 calls) and the capacity ramp of both systems in equal 4-CPU containers (below). The rest of this chapter is Windows: timers tick at about 15.6 ms and coalesce wake-ups, so those files say that figures for comparisons "come from a Linux run".
  • Capacity with the real models comes first, and it is much lower. With the Silero VAD and Smart Turn running on every call in both systems (MODELS=1 bench/capacity_linux.sh, docs/LATENCY.md, "Capacity with the models"), the same 4-CPU / 4 GiB Linux container on a quiet host carried 325 concurrent calls within the SLO for brisvox (3 runs: 325 each; ~425 MB, RSS of its one process = PSS) against 20 for Pipecat 1.12.0 with its own models, tuned (20, 15, 20, and 20, 20, 15 on a re-run; memory-bound, ~4.0-4.2 GB PSS at 20 calls, its workers killed at the 4 GiB limit around 25): about 16 times the calls. LiveKit Agents 1.8.7 in the same container with its own turn detector carried 23 (22, 23, 25) with that detector's threshold lowered, and none with its defaults (the detector waited its 2.5 s maximum on about 45 % of the benchmark's turns); its failing steps were job-start bursts with CPU and memory to spare, so 23 is a lower bound under this arrival pattern (bench/livekit_baseline/README.md). Memory is PSS (shared pages split between processes) for all three systems, so the figures are comparable (docs/LATENCY.md, "Memory counted alike").
  • The comparison with Pipecat below is the framework alone, over mock providers. The harness (bench/pipecat_baseline: a Pipecat bot that speaks the same websocket protocol, measured with the same client and the same definition of voice-to-voice) gave, with the same 4-CPU container, 2100 concurrent calls within the SLO for brisvox against 90 (tuned) or 70 (defaults) for Pipecat 1.12.0 (docs/LATENCY.md, "Capacity benchmark, Linux"). That is the framework cost without models (an energy VAD and a fixed Smart Turn answer) and without real providers.
  • Live providers. The same agent file on both systems: Deepgram nova-3, OpenRouter openai/gpt-6-luna with reasoning effort none pinned to one upstream, Deepgram Aura voice; 5 concurrent calls, 100 turns per system in strictly alternating blocks on one Windows machine over the public internet. Voice-to-voice p50 / p90 / p95 / p99: brisvox 1.85 / 2.21 / 2.88 / 5.20 s, Pipecat 1.97 / 2.98 / 4.35 / 6.14 s. Pipecat sent more than one LLM request in 56 of 100 turns (cause not investigated), and the same nominal VAD stop is 96 ms in Pipecat and 128 ms in brisvox in this setup (bench/results/v2v-live-*-c5-*, docs/LATENCY.md "Live comparison with real providers", which also keeps the earlier one-call 30-turn run). Other providers' adapters are tested against mocks and recorded protocol fixtures until their keys are available.

Definitions

Voice-to-voice (V2V): the first non-silent bot audio sample leaving the transport minus the ground-truth end of the caller's speech, measured on the client side. Engine overhead: V2V minus the time spent waiting on providers and on the configured end-of-turn policy. Full definitions: Latency design and measurements.

Engine overhead per stage

From bench/results/v2v-sim.md (brisvox-bench v2v --sim --runs 10: the production pipeline over mock providers behind the real websocket transport, 60 latency turns and 30 barge-ins). The engine's own timeline stamps each span where the work happens:

Stagep50p95Budget at p95
end of turn to LLM request (turn_end_to_llm_request)0.051 ms0.089 ms3 ms, met
first TTS audio to transport (tts_audio_to_transport)0.035 ms0.064 ms2 ms, met
barge-in decision to Clear applied (interrupt_to_clear)0.012 ms0.022 ms1 ms, met

The worst barge-in clear of the 30 was 0.669 ms, one scheduling delay.

Voice-to-voice over mocks

Same run, measured by the client over the websocket: 60 of 60 turns answered, none talked over, none missed.

scopeturnsp50p90p95p99
all fixtures601009.6 ms1030.2 ms1034.3 ms1057.5 ms

The configured floor in that run is 950 ms (450 ms from the end of speech to the end-of-turn decision, plus the mock LLM's 350 ms and the mock TTS's 150 ms). What is above the floor is the engine, the websocket transport, the 20 ms frame grid, and the Windows timer's overshoot on the mocks' sleeps (8.4 ms at p50 in each of the LLM and TTS spans, according to Latency design and measurements). The engine's own stages add about 1 ms of it; the stage table above is the engine's share.

Memory

From the same run (resident set of the whole process, engine, transport and harness together): 10.2 MiB idle and cold, 22.1 MiB idle after the calls (allocator caches and lazily built state), 29.7 MiB peak with 8 calls alive at once: about 1.0 MiB per call on the production pipeline, of which roughly half is the Smart Turn audio ring and its analysis snapshot (256 KB each).

Concurrent calls (reference pipeline)

From bench/results/sim-baseline.md (brisvox-bench sim-load --workers 4): N simulated calls at once in one process, real time, the reference pipeline with an energy VAD (not the production pipeline, and not using the inference pool), 3 turns per call.

callsansweredfailedoverhead p50overhead p99overhead maxCPU corespeak RSSRSS per call
100300/30000.077 ms0.842 ms1.231 ms0.2822.4 MiB142 KiB
300900/90000.130 ms1.965 ms2.321 ms0.4748.8 MiB133 KiB
5001500/150000.173 ms1.675 ms6.038 ms0.3775.8 MiB132 KiB

Not a capacity limit: the host has 32 cores and the process used under half a core, so this shows low overhead and flat memory growth, not the maximum number of calls on a given machine. Mocks do no TLS, WebSocket, JSON or codec work. The Linux run confined to 4 cores and pushed towards saturation is in docs/LATENCY.md ("Linux, confined to 4 cores").

Speculative LLM start

From bench/results/speculation-fixtures.md (brisvox-bench speculation --seeds 20): the production engine over mock providers on a paused clock, deterministic per seed, real FLEURS speech fixtures, server-side V2V, the adaptive turn policy, a 350 ms mock LLM.

speculationturnsV2V p50extra LLM requests per turn
off120/120923 ms0.00
on, STT interims that already hold the full text120/120782 ms0.00
on, STT interims a word short of the final120/120925 ms1.00

Speculation helps only when the last interim transcript at the VAD stop already equals the final one; otherwise it costs a full extra request per turn and gains nothing. The file also holds the other turn policies, a 100 ms LLM, the stable-partial gate and the resumed-speech scenario. How well a real STT's interims converge is a live measurement that has not been made.

All results files

FileWhat
v2v-sim.md, v2v-sim.jsonV2V and engine stage times over the websocket, mock providers
v2v-mock-bot.md, v2v-mock-bot.jsonThe harness against a mock bot: harness and transport self-check
sim-baseline.md, sim-500-1worker.mdIn-process load, N simulated calls
fixtures-sim.mdWhole simulated calls on real speech, server-side
speculation-fixtures.md, v2v-speculation.md, v2v-speculation-gate.mdSpeculative LLM start
inference-pool.md, resampler.md, smart-turn.mdComponent costs

The harness (crates/brisvox-bench), its options and the metric definitions are described in bench/results/README.md; the numbers are discussed section by section in Latency design and measurements. How the engine is proven correct (deterministic simulation, chaos, isolation and leak tests) is in Testing and the quality gate.

Latency

Definitions (used by every benchmark; do not change silently)

  • User end-of-speech (EOS): ground-truth timestamp from fixture sidecar.
  • Voice-to-voice (V2V): first non-silent bot audio sample leaving the transport minus EOS, measured on the client side of the transport.
  • Internal overhead: V2V minus time spent waiting on providers and on the configured end-of-turn policy. Measured in simulation with mock providers.

Turn timeline

Code: brisvox_core::timeline. Each stage calls TimelineRecorder::mark(epoch, event, clock.now()) with the epoch of the frame/turn it is working on (never a guessed future epoch). Epochs are turn numbers: the turn state machine starts a new epoch for every user turn. Rules:

  • Mark rule per event: first occurrence wins, except UserSpeechEnd, VadStop and SttFinalize (latest before TurnEnd) and SttFinal (latest before LlmRequest): a user may pause and resume within one turn.
  • Stages that do not know turn epochs (the engine's VAD worker and STT stage) mark the user turn in progress with mark_open_turn(event, at): ignored (counted late) when there is no turn yet or the current turn has already ended.
  • A newer epoch finishes the current turn (summary event + history); an older or already finished epoch is late: counted, never attributed elsewhere. Exception: AudioOutStopped (it happens after its turn) patches the finished record of its epoch.
  • Barge-in: the turn state machine calls interrupt(old, new, at, speech_start), which marks Interrupted on old and starts new atomically (seeding its UserSpeechStart with the barge-in speech start), so a stage that tags the new turn first cannot lose it.
  • Session end must call finish(); summaries are otherwise emitted when the next turn starts.
  • Timestamps come from the injected Clock; a metric whose endpoints are missing or reversed is None, never negative. VADs backdate UserSpeechEnd to the last speech frame (the fixtures' ground truth is compared against that).
EventMarked byExact moment
UserSpeechStartVADfirst VAD speech-start of the turn
UserSpeechEndVADlast speech frame before the turn-end decision (latest wins)
SttInterimSTT adapterfirst interim transcript received
SttFinalSTT adapterlast final received before the LLM request (latest wins)
TurnEndturn state machineend-of-turn decision taken
LlmRequestLLM adapterfirst request of the turn written to the connection. Each tool-loop round has its own record (TimelineRecorder::llm_request; up to MAX_LLM_ROUNDS = 32 per turn, see docs/TOOLS.md)
LlmFirstTokenLLM adapterfirst text or tool-call delta received
LlmFirstSentenceTTS segmenterfirst speakable chunk emitted
TtsRequestTTS adapterfirst text of the turn sent to TTS
TtsFirstByteTTS adapterfirst audio byte received
FirstAudioOutoutput pacing/transportfirst bot audio frame handed to the transport
Interruptedturn state machine (interrupt)barge-in accepted
AudioOutStoppedengine: the turn driver when its fast-path Clear is applied (else TransportOut at the Interrupt); reference: output pacingbot playout flushed after the barge-in (first wins)
VadStopengine VAD worker (mark_open_turn)the VAD confirmed the stop: the decision, not backdated
SttFinalizeengine STT stage (mark_open_turn)the Finalize for that stop handed to the provider session
FirstChunkengine segmenter stagethe first chunk of the response leaves the segmenter; marked once per response (a comparison per later chunk, no lock), so with TtsFirstText it times the hop between the two stages
TtsFirstTextengine TTS stagethe first non-blank text of the response reached the TTS stage, once per response, with LlmFirstSentence and TtsRequest. TimelineRecorder::is_marked lets a stage skip a mark another one made: TransportOut no longer repeats the turn driver's AudioOutStopped

Derived per-turn metrics (TurnRecord::metrics; logged as turn finished, target brisvox::timeline, numeric microsecond fields, absent when None):

  • end_of_turn_delay = TurnEnd - UserSpeechEnd
  • stt_final_delay = SttFinal - UserSpeechEnd
  • llm_ttft = LlmFirstToken - LlmRequest; llm_first_sentence = LlmFirstSentence - LlmRequest
  • tts_ttfb = TtsFirstByte - TtsRequest
  • post_endpoint_latency = FirstAudioOut - TurnEnd (providers + internal overhead after the end-of-turn decision)
  • response_latency = FirstAudioOut - UserSpeechEnd (server-side V2V: excludes transport and client buffering, so V2V as defined above is always larger)
  • time_to_silence = AudioOutStopped - Interrupted (barge-in responsiveness)

Overhead (timeline::tests::overhead_per_event_is_small, a turn rotation every 13 events, no subscriber): ~14 ns/event release, ~60 ns debug (Windows x86-64). Per-event logs are TRACE (enabling TRACE costs more than 1 us/event: formatting); summaries INFO, computed only when enabled. History: last 32 finished turns (grown lazily); exporters use drain_finished().

Budget (rough; the engine's own share is measured: "Engine stage times and memory")

StageTypicalLevers (task)
End-of-turn decision200–800 msadaptive policy, Smart Turn, STT signals
STT final100–300 msfinalize on VAD stop
LLM first token200–500 msspeculative start, warm connections, prompt caching
First speakable chunk20–150 msearly-clause segmenter
TTS first byte100–250 mspre-opened WS contexts, cached phrases
Transport + buffers50–100 ms20 ms frames, minimal buffering, no extra resample

Principles

  1. Overlap stages instead of waiting (speculation, streaming everywhere).
  2. Nothing is set up on the critical path (connections, TLS, model sessions are warm).
  3. Measure every stage of every turn (TurnTimeline) — no unmeasured claims.
  4. Regressions are bugs: benchmark results are committed and compared.

In-process load baseline

Measured before the production pacer's per-session phase was turned off by default (2026-10-08 and 2026-10-13) and not re-measured: sim-load runs the reference pipeline of brisvox-sim, whose pacer never had that phase, so the change does not apply to these numbers (the sim-load and saturation tables below).

brisvox-bench sim-load runs N simulated calls concurrently in one process, in real time, on a multi-thread runtime: the full reference pipeline of brisvox_sim::session::SimSession per call, 3 scripted user turns each, mock providers with log-normal latencies (medians: STT final 150, LLM first token 350, TTS first byte 150 ms). Result: bench/results/sim-baseline.md (regenerate: cargo run --release -p brisvox-bench -- sim-load --workers 4 --out bench/results/sim-baseline.md; the binary refuses to write it, and exits non-zero, if any call failed, any turn went unanswered, a barge-in happened, tasks leaked or marks were missing).

Metrics:

  • Overhead per turn = (FirstAudioOut - TurnEnd) - (LlmFirstSentence - LlmRequest) - (TtsFirstByte - TtsRequest). It covers the hops TurnEnd -> LlmRequest and TtsFirstByte -> FirstAudioOut (also reported separately). It does not see a late wake-up inside a provider interval (counted as provider time, since marks are taken on receipt) nor anything before TurnEnd (input frames, VAD, STT, the end-of-turn wait).
  • Wake = how long a task woken from outside the runtime (a plain thread, every 5 ms) waits until it runs: the scheduling pressure every hop sees, including the ones overhead cannot.
  • CPU cores = process CPU time / wall time on the plateau (every call started, none finished), so ramp, linger and settle do not dilute it.
  • RSS per call = (peak - before) / N; it includes fixed process growth, so the 500-call figure is closest to the marginal cost (the 100 -> 500 slope agrees: ~132 KiB/call).

Baseline 2026-10-08 (Windows x86_64, 32 logical cores, release, 4 tokio workers; a shared desktop with other workloads running, so maxima are noisy): 500 calls -> 1500/1500 turns, overhead p50 0.17 / p99 1.7 ms (max 6.0), wake p50 0.04 / p99 1.8 ms, 0.37 cores on the plateau, ~132 KiB peak RSS per call, ~13 tasks per call, 0 leaked. Same 500 calls on 1 worker (bench/results/sim-500-1worker.md): overhead p99 1.9 ms, wake p99 4.0 ms, 0.30 cores. An earlier, quieter run of the same command gave overhead p99 ~1.1 ms: run-to-run spread on this host is about 2x at the tail.

What this does and does not show:

  • Worker threads are not a CPU limit: on a 32-core host the 4 (or 1) workers, mock timers, the timer driver and the probe threads may use any core. Headroom here comes from low utilisation (< 0.4 cores), not from proven 4-core capacity. A Linux run confined to 4 cores (taskset), pushed towards saturation, is in "Linux, confined to 4 cores" below.
  • Windows timers tick at ~15.6 ms and coalesce wake-ups into bursts; Linux (1 ms) will look different. Production runs on Linux.
  • Lower bounds only: mocks do no TLS, WebSocket, JSON or codec work, the energy VAD is one RMS pass per 20 ms frame, and nothing uses the inference pool. Real stages (model VAD / turn detection at 50 frames/s/call, provider I/O) are measured with the V2V and capacity benches below.
  • Session phases: calls start 2 ms apart over a 1 s ramp, so 20 ms frame deadlines cluster (burstier than real traffic: conservative).

Linux, confined to 4 cores (measured 2026-10-13; bench/sim_load_linux.sh reproduces it). The same sim-load (3 turns per call, mock providers in-process, real time) run natively in WSL2 Ubuntu (Linux 6.6.87.2-microsoft- standard-WSL2) on the 32-core Windows 11 PC, under taskset -c 0-3 with 4 tokio workers (available_parallelism reads the affinity: 4). Files bench/results/sim-baseline-linux.md (the Windows command plus taskset) and sim-saturation-linux-run{1,2}.md (two identical sweeps, --sessions 500,1000,2000,3000,3500,4000,5000,6000,8000,10000,12000); sim-saturation-linux-first.md is a first sweep kept for honesty (below).

callsansweredoverhead p99wake p99CPU corespeak RSS
1030/300.105 ms0.128 ms0.028.0 MiB
100300/3000.075 ms0.117 ms0.0620.2 MiB
300900/9000.116 ms0.100 ms0.1449.3 MiB
5001500/15000.185 ms0.133 ms0.2775.1 MiB

Against the Windows baseline at 500 calls (overhead p99 1.675 ms, wake p99 1.750 ms, 0.37 cores) Linux is about 9 times better at the tail on 4 cores; the acceptance of "overhead p99 < 10 ms at 300 simulated sessions on 4 cores" is met by a factor of 85. The saturation sweep (overhead and wake p99 in ms, runs 1 / 2, same 4 cores):

callsoverhead p99wake p99wake maxCPU corespeak RSS MiB
10000.276 / 0.2350.121 / 0.1040.265 / 0.6310.49 / 0.44135 / 135
20000.442 / 0.3480.148 / 0.2040.598 / 5.980.81 / 0.66263 / 263
40000.665 / 0.7240.536 / 0.5741.51 / 15.31.25 / 1.22562 / 599
60001.040 / 0.9101.326 / 1.0029.37 / 4.591.73 / 1.64804 / 823
80001.300 / 1.1196.235 / 2.00813.4 / 4.612.25 / 2.171230 / 1043
100001.497 / 1.49217.848 / 19.55126.7 / 36.42.76 / 2.751485 / 1298
120001.863 / 1.59229.479 / 24.38738.7 / 31.23.10 / 3.091740 / 1553

(5000, 3000 and 3500 are in the files.) Every step answered every turn (36000 of 36000 at 12000 calls), no call failed, no task leaked.

  • Where it saturates: the first threshold crossing is wake p99 > 10 ms at 10000 simultaneous calls (17.8 and 19.6 ms; 8000 calls: 6.2 and 2.0 ms), at 2.75 of the 4 cores, with the overhead p99 still 1.5 ms (overhead p99 > 10 ms was not reached up to 12000 calls: 1.9 and 1.6 ms, with 3.1 cores busy). The scheduling delay of the tokio workers grows before the engine's own hops do, because the sim spends its time in timers and wake-ups.

  • A noisy first sweep. sim-saturation-linux-first.md (run directly after the baseline) shows wake p99 13.4 ms and overhead p99 6.2 ms at 4000 calls and wake max 19.7 ms at 3000, which runs 1 and 2 did not reproduce (0.5 ms at 4000). The host is a shared Windows desktop under WSL2; the likely cause is host noise, not the engine, but it was not isolated. The conclusion therefore reads: wake p99 stays below 10 ms up to at least 8000 calls in the two repeats (the first sweep exceeded it at 4000 and 6000: 13.4 and 12.1 ms) and is above 10 ms from 10000 in both. Run-to-run spread at the tail is about 2x, as on Windows.

  • Same caveats as above: mocks do no TLS, WebSocket, JSON or codec work and nothing uses the inference pool or a model, so this is the cost of orchestration and scheduling only. The capacity benchmark (above) is the number that includes the websocket, the pacer and the pool.

  • Task leak check covers tokio::spawned tasks, not spawn_blocking or other pools.

Finding: the first run showed exactly 15.6 ms of overhead on every turn, all between TTS first byte and the transport. The output pacer slept until an already-due deadline, and on Windows such a sleep returns only at the next timer tick (~15.6 ms; tokio rounds deadlines up to its 1 ms wheel and the driver parks until the OS timer fires; the exact mechanism was not traced). Rule: never sleep on a due deadline on the hot path — use brisvox_core::clock::wait_until, which returns at once when deadline <= now. Applied to the pacer, the simulated input transport, fixture playback and the mock providers' outboxes. Regression tests: brisvox-sim/tests/pacer.rs (a counting clock: paused time cannot show the delay; plus a due frame doomed by a queued interrupt is neither played nor logged as played).

Resampling

brisvox_audio::StreamResampler: rubato Fft (exact rational ratios) for every pair except downsampling to 8 kHz, which uses rubato's windowed sinc (see the sinc section below). One instance per session and direction, PCM16 in/out, flush() drains the end of an utterance (partial chunk + filter tail). Results: bench/results/resampler.md (brisvox-bench resample, one warm instance) and crates/brisvox-audio/tests/resample.rs, tests/sinc_design.rs, tests/resample_memory.rs.

The trade-off is passband vs delay. rubato's FFT filter is flat only if its block is long enough at the LOWER of the two rates. Measured gain at 8 kHz <-> 16/24 kHz (telephony needs flat to 3.4 kHz):

FFT blockdelay2.4 kHz3.0 kHz3.4 kHz3.6 kHz
5 ms (40 samples)2.5 ms-2.1 dB-18 dB-45 dB-66 dB
10 ms (80)5 ms0.0-0.1 dB-10 dB-29 dB
20 ms (160)10 ms0.00.00.0-2.0 dB

Chosen: a block of 160 samples at the lower rate (rounded up to the rates' exact rational step): flat (< 0.2 dB) to 0.85 x the lower Nyquist, enforced by the sweep test.

  • Filter delay = half a block: 10 ms when the lower rate is 8 kHz, 5 ms at 16 kHz, 3.3 ms at 24 kHz (6.7 ms for 22.05 kHz <-> 24/48 kHz, 10 ms for 22.05 kHz <-> 16 kHz). A test checks the reported delay against the actual position of a click in the output.
  • CPU 0.7-3.2 µs per 20 ms frame (median 1.9; one warm instance). Inline on the async path. No allocation after construction (counting-allocator test).
  • Cache cost with many instances (one per session, cycled round-robin, release, 2026-10-08, wall-clock, one machine): FFT 48 kHz -> 16 kHz 1.4-2.4x per frame over three runs (the bench note of ~1.5-2x); sinc 24 kHz -> 8 kHz 1.0-1.1x.
  • SNR 89-100 dB at 1 kHz and at 0.85 x Nyquist; a full-scale tone above the output Nyquist leaves nothing above the 16-bit floor.
  • Buffering: output per whole chunk (= one block, buffering()): nothing extra for input aligned to it, up to one block otherwise.
  • Memory and construction (release for timings, 2026-10-08): 22-31 KB retained per instance and 10-20 µs to construct (FFT pairs). Sinc pairs are far larger, see below. History: the first version used 2.5 ms blocks (and quoted "2.5 ms delay"); the review's gain sweep showed it cut telephony speech above ~2.4 kHz, so delay was traded back for a correct passband.

Downsampling to 8 kHz uses a windowed-sinc filter instead. The FFT cannot do better than 10 ms at 8 kHz output with a flat passband (above). A windowed sinc (rubato Async::new_sinc, BlackmanHarris2 window, cubic interpolation, 128 oversampling phases) can, at a length per source rate. Length sweep: tests/sinc_design.rs::probe_sinc_length_sweep (cargo test --release -p brisvox-audio --test sinc_design -- --ignored --nocapture). Quality bar: |gain| <= 0.2 dB at 3.4 kHz, >= -2 dB at 3.6 kHz, >= 55 dB rejection of a 4.4 kHz tone (which aliases to 3.6 kHz). The sinc is checked against that bar, and 6 kHz (aliasing to 2 kHz) too, by sinc_pairs_meet_the_passband_and_stopband_bar.

sourcesinc lengthdelay (FFT: 10 ms)gain 3.4 kHzgain 3.6 kHz4.4 kHz aliasCPU per 20 ms
16 kHz1926.0 ms-0.05 dB-1.70 dB-140 dB5.5 µs
22.05 kHz2245.0 ms-0.07 dB-1.39 dB-127 dB7.2 µs
24 kHz2244.6 ms-0.12 dB-1.61 dB-102 dB6.2 µs
44.1 kHz3844.25 ms-0.03 dB-0.63 dB-64 dB10.2 µs
48 kHz5125.25 ms-0.00 dB-0.17 dB-82 dB12.4 µs

(Sweep numbers are f32 rubato output for 1 s tones; the FFT row for comparison: gain 3.6 kHz -2.03 dB, 4.4 kHz alias -157 to -171 dB at 16/24/48 kHz.) The lengths are the shortest that meet the bar, except 48 kHz: 384 meets it at -55.6 dB (no margin), so 512 is used. Shorter lengths fail: at 16 kHz, 160 gives -3.6 dB at 3.6 kHz; at 22.05 and 24 kHz, 192 gives -2.5 and -2.7 dB; at 44.1 kHz, 320 gives -51.5 dB of 4.4 kHz alias. Note the rejection is far below the FFT's (-157 dB) for 44.1 and 48 kHz: the sinc meets the 55 dB bar, it is not equal to the FFT.

Sinc costs (release, 2026-10-08, the sinc pairs only; probe_construction_and_cache_cost):

  • Memory: the filter table is 128 x sinc_len x 4 bytes and is per instance (rubato does not share it). Retained per instance (tests/resample_memory.rs): 105 KB (16 kHz), 122 KB (22.05/24 kHz), 207 KB (44.1 kHz), 274 KB (48 kHz) (KB = 1000 bytes). 500 sessions at 24 kHz -> 8 kHz hold ~60 MB. The FFT pairs keep 22-31 KB.
  • Construction (median of 30, two runs): 0.6 ms (16 kHz), 0.7 ms (24 kHz), 1.5-1.6 ms (48 kHz); the FFT pairs take 10-20 µs. Construction must run off the async workers (StreamResampler::new docs): 1.5 ms is a stall for a tokio worker (see CONTRIBUTING.md).
  • Cycling 500 instances: 1.0-1.1x per frame (24 kHz -> 8 kHz, three runs), so the table's cache footprint has no measurable cost here.

Upsampling from 8 kHz gains only 2 ms with sinc (sinc length 128: delay 8.0 ms, -0.26 dB at 3.4 kHz, 10.6 µs per 20 ms vs the FFT's 0.9 µs, probe_upsampling_sinc_versus_fft). That is below the 3 ms bar for ~12x the CPU and a filter that misses the 0.2 dB passband bar, so it stays on the FFT. A telephony call (8 kHz in -> 16 kHz STT: 10 ms; 24 kHz TTS -> 8 kHz out: 4.6 ms) now spends ~15 ms in resampling instead of 20.

Output pacing

brisvox_audio::OutputPacer (production) releases audio downstream at real time, one frame duration after the previous one, on the injected Clock. The reference pacer in brisvox-sim is the behavioural spec. crates/brisvox-audio/tests/pacer.rs (15 tests, paused time, a CountingClock that counts timer calls and can make timers fire late) pins the production rules, including counterparts of the reference's regression tests.

  • Absolute schedule: deadline = previous deadline + previous frame's own duration, so sleep overshoot does not accumulate. Tested three ways: 10 000 x 20 ms frames through a one-slot consumer that stalls 40 ms stay exactly on their grid after the stall (the one-slot inbox absorbs that stall, so the pacer does not lag here); a pacer that is itself late (its input arrives 50 ms late) releases the three overdue frames at once and is back on the grid at the next grid point; a 100 ms sink stall (lag up to 140 ms, within max_lag) releases the late frames together and re-acquires the grid from the next frame on. Rescheduling from the actual release instant instead fails the last two tests.
  • Lag: a frame up to max_lag (default 60 ms) late is released at once and keeps the grid. Beyond max_lag the pacer resyncs to the clock, counts one underrun and restarts the grid (300 ms stall: exactly one underrun, then consecutive 20 ms frames). PacerMetrics reports frames played, underruns and the worst lag. A frame is counted only once it is delivered. Worst lag is measured at hand-off, against the frame's grid point, after the push returns: it includes the timer's overshoot (a timer that fires 15 ms late on Windows makes that frame 15 ms late, also when the frame arrived early) and any time the push was blocked downstream. It can therefore exceed max_lag; only the underrun count uses the lag at arrival. Tests: a timer that fires 15 ms late with frames queued early (worst lag exactly 15 ms, no underrun); the pacer waking 3 ms late after the clock is advanced past its deadline in one step (worst lag 3 ms, the next frame on the absolute grid). Both fail if the lag is taken at arrival or the schedule is rebuilt from the wake instant.
  • Due deadlines never touch the timer: every test asserts zero sleep_until calls on an already-due deadline, including the turn-end hold (a TtsEnded that arrives after the last frame's deadline goes out at once; mutant that sleeps through the timer fails). Tokio rounds every sleep up to its timer tick, ~15.6 ms on Windows . A due frame doomed by an interrupt queued just before it is processed is neither released nor counted (test with a clock hook that enqueues the interrupt at the frame's first now()).
  • One timer per session: the 10 000-frame test counts all sleep_until calls: 2 in total, the pacer's single pinned Sleep (re-armed with reset) and the sink's one stall.
  • Interrupt flush: a wait is abandoned as soon as an interrupt for a newer epoch is queued (EpochWatch), the doomed frame is neither released nor counted, and the schedule resets so the next turn starts at once. A push already blocked on a full downstream inbox is abandoned the same way: the framework drops the frame and counts it in stale_dropped, and the pacer then neither counts it nor advances the schedule (test: interrupt at phase + 85 ms while frame 4 is blocked; the sink log is exactly 0..=3 then the new turn, and 5 frames are counted). Mid-interval interrupt: no old frame after it. Interrupt while the sink holds a dispatched frame: exactly that frame plays after it (log order asserted exactly), and the new turn follows it.
  • Turn ends: a TtsEnded is held until the turn's last frame has played out, then forwarded downstream, and the schedule restarts. A natural silence of 5 s before the next turn is not an underrun (test). The reference also reports TtsEnded upstream; the production pacer does not yet, because no production stage consumes that upstream report, and the stage that owns the turn will add it when it exists.
  • First frame at once (the default): the pacing clock starts at the first audio frame after an idle period (session start, an interrupt, a finished turn). That frame leaves at its arrival, with no timer, and the 20 ms grid runs from there (tests: 256 seeds; a first answer after 2 s of idle; the first frame after an interrupt).
  • Phase (opt-in, OutputPacer::with_first_frame_phase(true); the former default): the session's first frame is released phase_offset(seed) after its arrival, a whole millisecond in [0, 20 ms) from splitmix64 on the seed. Same seed, same offset. Two sessions run concurrently with seeds 7 and 8 wake at 7 ms and 2 ms. The phase applies only to the session's first frame. The other tests of this file run with it on, because they pin the grid through it.
  • Frame durations: pacing uses each frame's own duration (10, 20 and 40 ms tested).
  • Known equivalence: the no-sleep fast path in wait also checks the interrupt queue, which mirrors the framework's own check in push. Removing the fast-path check changes no observable output, since push drops the same frame; the tests pin the observable outcome.
  • Not here: transport-side buffers are cleared by the transport on interrupt; only the frame already handed downstream can still play.

Inference pool

brisvox_inference::InferencePool: model calls (VAD, turn detection, heavy DSP) run on its own OS threads, never on tokio workers. Bounded queue that rejects when full (PoolSaturated: shed load rather than build latency), per-request timeout on the Clock (an unstarted job of a timed-out request is skipped), panic isolation per job, lock-free metrics plus per-job wait/run timing.

Load test (bench/results/inference-pool.md, brisvox-bench infer-load --seconds 4 --costs-us 100,200,250,280): 500 sessions x one job per 32 ms window (one Silero VAD window) on a 4-thread pool; jobs busy-spin their cost. Two arrival modes: paced (one thread spin-paces jobs evenly, open loop: measures the pool) and timers (each session a tokio task on a timer, closed loop: what an engine stage would do, but on Windows its timers fire in ~15.6 ms batches, so jobs arrive in bursts of ~200). Windows, shared desktop:

job costoffered loadpaced wait p99timers wait p99outcome
100 µs0.390.11 ms4.2 msall served
200 µs0.780.62 ms11.4 msall served
250 µs0.981.14 ms14.4 msall served
280 µs1.0973 ms34 mspaced: queue full (1024), 8% rejected; timers: 9% windows missed

Reading it:

  • The pool itself adds ~10-20 µs per job (cross-thread wake + oneshot) and queues fairly up to the edge: wait stays around a millisecond up to 98% load. Beyond 100% it falls off a cliff — the queue fills, waits jump to tens of ms, and jobs are rejected (open loop) or windows missed (closed loop). Never unbounded memory.
  • The timers rows measure the load generator's burstiness (wait p99 ~ burst x cost / threads), not the pool: real VAD jobs are triggered by arriving audio, which is smoother. Linux timers (1 ms) are much less bursty.
  • Plan for <= 70% pool load. Assuming ~1 ms per window for Silero VAD on one CPU thread (UNVERIFIED when this plan was written), per-window VAD costs ~3% of a core per session, so a 4-thread pool carries ~90 sessions with headroom (and the tokio workers share those cores). Hundreds of calls per container need cross-session batching, an energy pre-gate before the model, or more cores — an input for the VAD and Smart Turn work (docs/RISKS.md).
  • Queue capacity is not a latency bound (1024 jobs of 1 ms are 0.25 s of wait on 4 threads); the per-request timeout is.

TTS segmentation

Code: brisvox_agent::segment (Segmenter, SegmenterConfig, timed_chunks). The segmenter turns LLM text deltas into chunks for TTS. It only decides where chunks end and reports nothing to the timeline: the caller records LlmFirstSentence when it receives the first chunk, which is the moment the first audio can start. timed_chunks is a test helper that feeds timestamped tokens and reports the token that released each chunk.

What is emitted when (first_min_words 3, the SegmenterConfig default; the engine's production default is one word, see "First TTS chunk" below; max_chunk_chars 250):

  • First chunk of a stream: at the earliest clause boundary (, ; : followed by whitespace, or a spaced dash -) or sentence end whose chunk already holds 3 words.
  • Later chunks: only at sentence ends (. ! ? …, runs such as ?!, closing quotes, brackets or markdown emphasis after them, as in **Done.**), and at a newline after text. Deliberate deviation from "sentence ends only": LLM lists and paragraphs need a pause at a line break, and without it 1. Buy milk\n2. Call mom becomes one run-on utterance. A line that ends in , ; : or a dash ends its chunk at the newline too (- eggs,\n- milk, is two items, Here is the list:\n1. Buy milk pauses after list:); a line that ends in a terminator is left to the terminator rules, so 1. stays with its item. A bare - bullet line is not a chunk of its own.
  • Over the limit (250 chars by default): pending text without a sentence end is cut at the last clause boundary inside the limit, else at the last whitespace inside it, else at the first whitespace after it, so the over-long token stays whole and the stream keeps flowing.
  • finish() (stream end): emits what is left and resets the segmenter for the next stream.
  • reset() (barge-in): drops pending text, which was never spoken, and the next chunk is a first chunk again.

One-token lookahead. A period, ?, !, … or clause mark is decided only when the next non-space character has arrived, because Dr. + Smith, 3. + 14 and 3. + sinif differ only in what follows. The cost is at most one token after the boundary: the next token carries that character. A newline is unambiguous and is decided at once.

Never split inside a token. Decimals (3.14, 3,14), thousands (1,000, 1.000), times (10:30), Turkish ordinals and list markers (3. sinif, 1. Buy), abbreviations (per language, case-insensitive, dotted and dotless i folded together), URLs and e-mail addresses (a period that ends one is a boundary only before a capital letter or a digit), dotted acronyms (E.U., B.C., N.Y.C.: not a boundary), ellipses before a lowercase word. A number and period mid-line followed by a capital (It is done now. 2. We left.) is a sentence end; at a line start it is a list marker and is not. Cuts are made only after punctuation or at whitespace, so emoji ZWJ sequences, flags and skin tones stay whole.

Over-limit fallbacks never cut after a declined period. The whitespace fallback (the last whitespace inside the limit, else the first after it) skips a whitespace that follows a period the rules declined (Dr., vb., E.U., 3. before sinif, … before a lowercase word): such a token is not a boundary, even when the limit forces a cut. Oğlum 3. sinif öğrencisi with a limit of 10 gives Oğlum, 3. sinif, öğrencisi, not Oğlum 3.. Whether a period is declined can depend on the next non-space char: when that char has not arrived, the fallback waits for it, unless every possible next char gives the same decision (so a word such as Yes. never waits).

Long tokens and whitespace. A token without whitespace stays whole up to 4 × max_chunk_chars (1000 chars by default). Past that, the cut is made at the nearest grapheme-safe char boundary: the one exception to "cut only at whitespace or after punctuation". Grapheme-safe means no cut inside a ZWJ sequence, a variation selector, a flag, a skin tone or a combining mark of the common scripts (Latin and Cyrillic diacritics, Hebrew points, Arabic harakat, Devanagari and Thai signs, kana voiced marks; a subset of Unicode Mn/Mc, not all of them). A single grapheme longer than the cap (for example x plus 1200 combining acutes) has no safe boundary, so it is cut at the cap itself: memory stays bounded, and the rest is not emitted one mark per chunk. Whitespace runs longer than max_chunk_chars + 1 are shortened to that length (a newline inside the excess is kept). So the pending text stays at or below 4 × max_chunk_chars + 2 chars after every push, plus at most max_chunk_chars + 1 whitespace chars while a fallback waits for the next char after an ambiguous period (5 × max_chunk_chars + 1 in all), and a long token never stalls the output for the rest of the answer. Over-limit cuts wait for the lookahead of every boundary inside the limit, so the output is the same however the deltas are split (tested with 1-3 char pieces, random splits and random token streams).

Measured on token streams (tests/segment.rs, one token every 30 ms, timed_chunks): for eight realistic answers (4 EN, 4 TR) the first chunk leaves on the token after the first eligible boundary, i.e. 30 ms after the boundary token. These are segmenter numbers, not TTS numbers: real LLM inter-token jitter adds to them.

LangFirst chunkBoundary tokenEmitted at
ENSure, I can help you with that.. (token 8)token 9 (+30 ms)
ENThe forecast is rain,, (token 4)token 5 (+30 ms)
ENHi! How can I help today?? (token 7)token 8 (+30 ms)
TRMerhaba, bugün size nasıl yardımcı olabilirim?? (token 7)token 8 (+30 ms)
TRTabii ki, hemen bakıyorum.. (token 5)token 6 (+30 ms)
TRToplam 1.000 kişi geldi.. (token 6)token 7 (+30 ms)
TROğlum 3. sinif öğrencisi.. (token 5)token 6 (+30 ms)
EN**Warning: this is bad.**.** (token 6)token 7 (+30 ms)

Segmenter push cost in the stage

SegmenterStage runs Segmenter::push_spans on a tokio worker for every LLM delta, so it falls under the 50 us rule of CONTRIBUTING.md. The cost is one scan of the pending text (at most 5 x max_chunk_chars chars) plus one allocation per emitted chunk, plus, for text with sentence ends, up to about two allocations per push while a candidate period waits for its lookahead character (the abbreviation check re-reads the token before it; measured with a counting allocator, 1.6 per push at 4-char deltas and 2.3 at 12-char deltas on 120 sentences, none for text without sentence ends beyond the chunks); the stage keeps the raw stream text it has not cut yet and ends the stream early when that passes RAW_CAP_FACTOR x max_chunk_chars bytes (a whitespace flood, see the module docs of segmenter.rs).

Measured in release (2026-10-08, Windows dev machine, deltas of 4 to 12 chars, best of 3 runs for the worst push), a_push_costs_less_than_the_worker_budget in crates/brisvox-engine/tests/segmenter_stage.rs (prints the numbers with --nocapture):

InputMedianp99Worst
120 sentences (EN / TR config)0.2 us0.7-1.1 us1-4 us
150 repetitions of a long clause without punctuation0.3-0.4 us0.9-1.1 us2-10 us
12,000 chars of space-newline pairs between two letters0.6 us0.6-1.3 us1-7 us
12,000 chars without any whitespace (one token)1.1-1.2 us2.6-2.8 us15-109 us

All far inside the 50 us rule; the worst push is the hard cut of an over-long token, which scans the whole pending text once, on a path no real LLM output takes. The test asserts, per input and language: median under 50 us (release) / 500 us (debug), p99 under 500 us / 5 ms, worst under 2 ms / 50 ms, each statistic the best of three runs (the minimum, so one scheduling hiccup on a loaded runner does not fail it). The tail bounds are loose multiples, there to catch a quadratic drain, not to prove the 50 us rule for the worst push; the median is the rule.

The gate (scripts/verify.sh) runs the tests in debug, where those bounds are an order of magnitude above the release medians, so a 50x slowdown would still pass them. The machine-independent guard is crates/brisvox-engine/tests/segmenter_alloc.rs: a counting allocator over the same four input shapes, asserting at most 4 allocations per emitted chunk (plus 3 per push for text with sentence ends, plus a constant), which a per-character allocation (verified by mutation) exceeds. The release wall-clock bounds are enforced by cargo test --release -p brisvox-engine --test segmenter_stage.

TTS text normalisation

Code: brisvox_agent::normalize; rules, tables and limits: docs/TURKISH.md. The normaliser turns written forms (%15, 1.250.000, 150 TL, 12.03.2026, 14:30, Dr.) into the words to speak, per segmenter chunk, in Turkish and English. Status: wired. TtsStage::spoken normalises every chunk on its way to the TTS provider, after the segmenter, so it adds no wait: a chunk leaves the segmenter exactly when it did before. A chunk of up to INLINE_CHUNK_CHARS (256) is normalised inline on the worker (the 50 us rule holds, see below); a longer one (the segmenter hard-cuts at up to 5 x max_chunk_chars = 1,250 chars) goes to the Offload, and an offload that fails or times out speaks the written text instead (counted as normalize_failures). The raw chunk, not the spoken form, is what the playout book records as sent text. The cost below is therefore part of the time from the segmenter's chunk to the provider's first text, not of the segmenter's own wait. (Tests: chunks_are_spoken_normalized_but_recorded_verbatim, a_long_chunk_is_normalized_on_the_offload_and_gives_the_same_text in crates/brisvox-engine/tests/tts_stage.rs.) Cost: linear in the chunk; a chunk with no digit, symbol or period is returned borrowed, one that comes out unchanged too. Measured in release (2026-10-08, dev machine, 20,000 calls each; re-run of the digit-dense shapes after the review fixes): 0.7 us for a 14-char chunk, 4 us for 250 chars without numbers, 9-10 us for a 328-char mixed Turkish/English chunk (numbers, date, time, phone), 40 us for a 1,230-char mixed chunk, and about 0.1 ms for 1,250 chars of pathological digit-dense text (1 2 3 ...). For a 256-char (INLINE_CHUNK_CHARS) digit-dense chunk the cost is 11-26 us in Turkish and 15-45 us in English; the worst shape (1 2 3 repeated, English) is 45 us, so the 50 us rule for work on a tokio worker holds for chunks up to INLINE_CHUNK_CHARS (256; the segmenter's default max_chunk_chars is 250) but with little margin on that shape, not with room to spare. Chunks above INLINE_CHUNK_CHARS are normalised off the worker, as above.

What the tests guard (crates/brisvox-agent/tests/normalize.rs; the absolute bounds depend on the build profile, so cargo test --release -p brisvox-agent --test normalize is the run that checks the numbers above):

TestBoundCatches
a_typical_chunk_costs_microseconds_not_milliseconds328-char mixed chunk, mean under 2 ms in release (200 times the measurement), 20 ms in debuga constant-factor blow-up (a Vec<char> rebuilt per token)
a_digit_dense_inline_chunk_stays_within_the_worker_budget256-char digit-dense chunks of five shapes, mean under 500 us in release (10 times the 50 us rule), 10 ms in debuga slow path on dense numbers; it does not prove the 50 us rule, the table above is the (unguarded) measurement
cost_grows_linearly_with_the_input16 times the input must cost under 64 times the time, best of 5, 18 shapes (digit runs, dotted groups, parentheses, space groups, currency, abbreviations, long letter runs, Dr.Dr.Dr., period runs)an accidental rescan (quadratic)

The 50 us claim itself and the 10 us / 40 us figures are measurements, not assertions; the bounds are deliberately loose so a noisy machine does not fail the suite.

Turkish and mixed-language call latency is measured with real speech by brisvox-bench fixtures (bench/results/fixtures-sim.md, section "3. Turkish fixtures in the benchmarks" in docs/TURKISH.md).

End-of-turn model

Smart Turn v3.2 runs once per candidate end of turn (a VAD stop), on the inference pool: features ~1.0-1.2 ms + model ~22.5-28 ms on one thread (Ryzen 9 9950X, Windows, release; median of 15; bench/results/smart-turn.md), so ~25-30 ms is added between the VAD stop and the verdict. Pipecat runs the same model on the same runtime (24.2 ms model, 2.1 ms numpy features on this machine), so brisvox saves ~1 ms of features and otherwise matches it. On the caller's thread only the 8 s ring copy runs (~2 us). Levers, measured in the Smart Turn latency work: more intra-op threads for this one job (latency vs pool capacity), and starting the analysis speculatively when VAD probability first falls (before min_silence confirms the stop) so the verdict is ready at the stop.

Smart Turn intra-op threads (measured 2026-10-08)

onnxruntime 1.28.0, Ryzen 9 9950X, median of 31 warm runs on the golden features:

intra-op threadsmedianp90CPU per call (upper bound)
125.9 ms27.1 ms~26 ms
216.8 ms18.8 ms~34 ms
412.6 ms13.6 ms~50 ms
89.9 ms10.6 ms~79 ms

Default is 2 threads (DEFAULT_INTRA_THREADS): -9 ms on every end-of-turn verdict for ~30% more CPU per analysis (~0.8% of a core per talking session instead of ~0.6%, docs/RISKS.md). Probabilities stay within the 1e-6 golden tolerance at 1, 2 and 4 threads, through warmed sessions (probabilities_match_the_reference_at_1_2_and_4_threads in crates/brisvox-inference/tests/smart_turn.rs); the default (2) and the clamp of 0 to 1 are pinned by the_thread_setting_defaults_to_2_and_is_at_least_1. Every session (first, warmed, cold start) is built by one method from the stored count. That the setting reaches ONNX Runtime is a timing fact, so it is checked by the ignored release test more_threads_cut_the_latency_of_warm_and_cold_sessions (ort_backend.rs; 2 threads must be < 0.85x of 1 thread, for a warmed and a cold-start session). Re-run 2026-10-12 (all-zero features, other builds running): 1 / 2 / 4 threads = 22.9 / 12.5 / 8.1 ms warmed, 23.0 / 12.8 / 7.9 ms cold start; with every session forced to 1 thread the test fails (26.3 / 25.5 / 26.9 ms). So the pyke-provided runtime honours with_intra_threads (not an OpenMP build that ignores it).

Early analysis: Smart Turn at the first VAD fall (measured 2026-10-08)

Silero's probability falls below the negative threshold within one or two windows (32 ms each) of the last speech, but the stop is only confirmed after min_silence (100 ms by default). With EngineConfig::early_analysis enabled the VAD worker sends SystemFrame::UserSpeechFalling once the fall has lasted fall_delay (default 64 ms), the TurnDriver snapshots the ring and runs Smart Turn at once, and at the confirmed stop it answers the machine's request with that verdict when the reuse rule holds (same fall: the stop carries the hint's time; at most max_tail = 300 ms of audio since the snapshot; no newer hint or speech start; module docs of turn_driver). Otherwise the normal analysis runs. Off by default: no hint frame exists then.

Measured with the real Silero (tract) and Smart Turn (ONNX Runtime 1.28, 2 intra-op threads), release build, Ryzen 9 9950X, the three fixtures (6 utterance ends), by crates/brisvox-engine/tests/early_analysis_realtime.rs (features silero-tract smart-turn-ort):

Agreement, verdict at the hint vs verdict at the stop of the same fall (audio time):

min_silencehint delayfallsstopspairedsame verdictmax abs dphint-to-stopwasted
100 ms0 / 32 ms76660.00796 ms1
100 ms64 ms66660.05060 ms0
200 ms64 ms66660.022160 ms0
300 ms64 ms66660.065260 ms0
500 ms64 ms66660.056443 ms0

(The fixtures are clean read sentences: 6 stops and one inner dip that a 64 ms delay filters out.) Stability of the verdict under the extra silence between hint and stop on far more points:

  • real utterance ends with the real room noise that follows (hint after 48-128 ms of it, stop 32-128 ms later): 144 of 144 pairs give the same verdict, max abs dp 0.105;
  • stress test, every utterance cut every 100 ms from a third of its length (an abrupt cut stands in for a pause inside a sentence) with digital silence appended (96 ms vs 160 ms): 190 of 211 (90.0%) agree, max abs dp 0.83. The 21 flips are 19 times "complete only at the stop" (the early verdict is the more cautious one: the turn would wait for the fallback or the STT punctuation instead of ending at once) and 2 times "complete only early". This int8 model is jumpy (docs/providers/smart-turn.md), so a borderline verdict can move with 64 ms of extra silence; the early verdict is one the model gives for the same audio minus that silence.

Latency in the production stages (AudioIn + Silero worker on the pool -> TurnDriver + Smart Turn on the same 3-thread pool, paced in real time, six runs in parallel, so the base figure includes some contention; 2 stops per fixture):

modemean stop-to-verdictanalyses startedearly hints / started / reused / ready / wasted
base23.8 ms (22.9-25.1)6-
early0.0 ms6 (all early)6 / 6 / 6 / 6 / 0

The test asserts, in early mode, that every fixture reuses at least one early verdict, that no early job fails, and that at least 5 of the 6 stops reuse theirs (one stop of slack for a loaded machine), so a hint whose dating did not survive AudioIn -> TurnDriver (every early job wasted, the normal analysis at every stop) fails it. A debug run on a loaded machine (2026-10-12) still reused 6 / 6 (adopted while running, 0 ready at the stop, 0 wasted).

"Stop-to-verdict" is the time from the VAD stop reaching the driver to the verdict being fed to the turn machine. All six verdicts were ready when the stop arrived: -23.8 ms off the VAD-stop-to-verdict path at no extra analysis (the early analysis REPLACES the one at the stop). Where it counts: the end of a turn waits for the slower of the verdict and the STT final (finalize is sent at the stop and runs concurrently), so the saving shows up in the turn end only when the final arrives within ~25 ms of the stop (a fast STT, a text-free policy such as require_final = false on a provider that answers a finalize quickly), and in full when the verdict was the slower one.

Cost: every hint is one Smart Turn job; a fall that does not become a stop wastes one (counted as early_wasted, same CPU as the analysis at the stop, ~34 ms with 2 threads). On the fixtures: 0 wasted at the default 64 ms delay, 1 per 6 stops at 0-32 ms. Natural speech pauses more than the read sentences, so max_per_turn (default 4) caps the early analyses per user turn. Budget (docs/RISKS.md: one analysis per stop is ~0.7% of a core per talking session with 2 threads, ~2 cores for 300 sessions): the measured waste of 0-17% costs ~0-0.35 cores more at 300 sessions. The cap is the guarantee, not the forecast: a speaker who hit it in every turn would need up to 4 extra jobs per turn, several times the base rate (6-10 cores at 300 sessions), so a deployment should watch early_wasted / vad_stops (and early_capped) and raise fall_delay or turn the feature off if it climbs; the base rate and the 2-core figure are unchanged when it is off.

Configuration constraints (checked by VadParams::falling_hint_possible, warned at VadSetup::with_early_analysis; EngineConfig::validate cannot see the VAD parameters):

  • fall_delay, rounded up to whole 32 ms windows, must be below the VAD min_silence (up to 96 ms at the default 100 ms); otherwise the stop is confirmed by the window that would hint and no hint is ever sent (VadStatsSnapshot::falls == 0, feature inert);
  • speech_pad <= min_silence: the stop is then always dated last speech end + speech_pad, so the hint carries exactly its time even when the pad is longer than the silence seen at the hint (the hint may be dated slightly ahead of its own window; the driver then waits for the ring to reach it, as the normal path does). With a longer pad the stop's time depends on the confirming window, so no hint is sent. Tested with pads of 0-100 ms against minimum silences of 100-300 ms.
  • Both halves must be enabled: EngineConfig::early_analysis (driver) and VadSetup::with_early_analysis (VAD). Only the first one set: no hints, early_hints == 0.

"Silence only since the snapshot" is decided by the VAD, not re-measured: windows between the negative and the positive threshold after the hint neither cancel the pending stop nor restart its silence, so a stop can be confirmed with the hint's time after band-level audio; that verdict is reused too (pinned by a_band_level_window_between_the_hint_and_the_stop_still_reuses). Counters reconcile: early_started == early_reused + early_wasted + early_failed when none is in flight (turn end, Cancel and hangup count a started analysis as wasted, and a late verdict of a discarded job changes nothing; pinned in crates/brisvox-engine/tests/early_analysis.rs by the_end_of_the_turn_discards_a_still_running_early_job, cancel_counts_a_started_early_analysis_as_wasted, hangup_counts_a_finished_early_analysis_as_wasted and hangup_discards_a_running_early_job_and_its_late_verdict_changes_nothing). The max_per_turn budget is renewed at each new user turn (the_per_turn_budget_is_renewed_for_the_next_turn), and fall_delay reaches the VAD from EngineConfig (vad_fall_hint.rs: 40 ms hints on a 60 ms dip, 80 ms does not; a delay >= min_silence gives falls == 0).

Decision: ADOPTED as an opt-in (EarlyAnalysisConfig::ENABLED, default stays off: the benefit depends on the STT final arriving fast, the waste ratio on conversational speech is not measured yet, and the sample is six stops). Recommended settings: fall_delay 64 ms with the default 100 ms min_silence.

Voice-to-voice benchmark over the websocket

brisvox-bench v2v (code: brisvox_bench::v2v; options, JSON format and definitions in bench/results/README.md) measures the V2V of the definitions above from the outside: a client connects to any server that speaks docs/WEBSOCKET_PROTOCOL.md, streams a fixture in real time and takes the arrival of the first non-silent bot audio frame after each ground-truth end of speech (fixture sidecar) minus that end of speech. Percentiles (nearest rank p50/p90/p99) are over all answered turns of N runs, also split by fixture, utterance language and turn position; turns that are not clean samples (talked_over, bot_speaking, no_response) and failed calls are counted, never silently dropped, and a talked_over or no_response turn (a reply slower than the gap or the response window) makes the result unfit to commit in live mode too, because percentiles without the slowest turns would understate the tail. After the fixture the client keeps the line open with digital silence until the last reply, so servers that count end-of-turn silence from incoming frames are measured fairly. Output: markdown (committed under bench/results/) and JSON with every sample.

  • Same client for every system. It only sees a URL, so the engine, the Pipecat baseline (behind a websocket adapter) and a mock are measured identically. Live mode takes the URL from the command line or BRISVOX_BENCH_URL; the harness holds no provider keys, and a URL with a token is redacted everywhere it could be printed.

  • Pipecat baseline: bench/pipecat_baseline/ is a Pipecat 1.12.0 bot (pinned, Python 3.13) behind a brisvox-protocol adapter. It reads the same agent file as brisvox-server (bench/pipecat_baseline/agent.toml: Deepgram nova-3, OpenAI Responses gpt-6-luna, Cartesia sonic-3.6, the same voice, Silero v6.2.3 and the same Smart Turn v3.2 file); its README has the brisvox -> Pipecat settings table, the known differences and the exact live commands. Its --fake mode (no keys) is the Pipecat-side self-check: 423-457 ms over a ~400 ms floor (Windows, 2026-10-13). Its --mock-providers mode runs the real pipeline over mocks with the brisvox mocks' latencies: the capacity comparison is "Capacity benchmark" below. The live-provider V2V comparison is the live phase.

  • Sim mode measures the engine: every call runs the production assembly brisvox_engine::run_call behind the real websocket transport, in process, with its heavy work on a real inference pool; only the providers are brisvox_sim mocks with fixed latencies (brisvox_bench::v2v::engine_target). Not production: the VAD is the simulator's energy detector (run by the production VAD worker) and the Smart Turn model always answers "complete". Each turn therefore has a known floor, stated in every report:

    • end of turn = VAD stop + STT final (the turn driver holds the end for the final), or the fixed silence of --engine-turn fixed:MS if that is longer (counted from the backdated stop);
    • latency floor = end of turn + LLM first token + TTS first byte: 300 + 150 + 350 + 150 = 950 ms with the defaults. The answer (Okay, got it.) comes in one token and its first clause (Okay,) is a first TTS chunk of its own (one word), decided inside the token. Before the first-chunk tuning the first chunk needed three words, waited one token gap (30 ms) for the response's end, and the floor was 980 ms.

    V2V minus the floor is the internal overhead of this document, measured at the client and including the transport: the engine's own time (VAD windows, pool hops, stage queues, the 20 ms frame grid, the output pacer's phase) plus the websocket and the harness. --target mock-bot keeps the original mock bot (energy end-of-turn after endpoint of silence, then delay, floor endpoint + delay) as a self-check: its excess over the floor is the harness and transport share alone.

    Measured before the pacer phase was turned off by default (the engine row carries it on its first turn per call), 2026-10-13 (engine) and 2026-10-12 (mock bot) (bench/results/v2v-sim.md, v2v-mock-bot.md; 10 runs of each of the three fixtures, 60 latency turns, release build, Windows dev machine shared with other builds, so trends only: the comparisons of the Pipecat and LiveKit comparisons need a Linux run, which is still to be recorded):

    targetfloorp50p90p99 (= max)excess over the floor p50 / p90 / p99
    engine over mock providers950 ms1010 ms1030 ms1058 ms60 / 80 / 108 ms
    mock bot (harness + transport)800 ms828 ms846 ms858 ms28 / 46 / 58 ms

    The engine's share is the difference of the two excess columns, about 32 / 34 / 50 ms (p50 / p90 / p99) on top of the harness and transport, and the engine's own timeline shows what it is made of ("Engine stage times and memory" below): the stages themselves cost well under a millisecond; the rest is the mock providers' sleeps overshooting on the Windows timer (about 8 ms each for the LLM's first token and the TTS's first byte at p50, 17 ms at p95), the VAD windows (32 ms) and the 20 ms frame grid with the output pacer's phase at both ends. The largest client send lateness was 26 ms (Windows timer), which lands in both columns. The 2026-10-12 engine run (980 ms floor: the first chunk then needed three words) measured 1036 / 1062 / 1081 ms.

  • Harness fidelity. Frames go out at absolute deadlines (begin + k * 20 ms, a frame once its last sample has been spoken); the largest send lateness is in every report. A late final speech frame adds its lateness to that turn's V2V (Windows timer tick ~15.6 ms; Linux ~1 ms), so comparisons use Linux runs.

  • The measured frame is the one that arrives: transport buffering at the server's output (the pacer's phase offset of up to one frame until it was removed from the default) is part of V2V, as the definition requires.

Live comparison with real providers: brisvox vs the Pipecat baseline (measured 2026-10-13, Windows)

Measured before the pacer phase was turned off by default: brisvox's first turn of each call includes the pacer phase (0-19 ms, mean 9.5 ms); the numbers were not re-measured.

The first V2V comparison on real providers, from this Windows machine over the public internet (raw reports with the environment banner, every turn and the failure counts: bench/results/v2v-live-{brisvox,pipecat}-windows-2026-10-13.{md,json}, Turkish: ...-tr-windows-...; the reports are generated by scripts/bench_live_report.py from the bench's own reports and the systems' logs). Both systems ran the same agent file (bench/pipecat_baseline/agent_live_en.toml): Deepgram nova-3, openai/gpt-6-luna through OpenRouter (Chat Completions, streaming, reasoning_effort none, one-sentence prompt), Cartesia sonic-3.6, adaptive turns with the same Smart Turn v3.2 file and Silero v6.2.3, one system at a time, brisvox-bench v2v --min-gap-ms 9000 (the default 4 s gap is shorter than a real answer: the first reply would be cut by the second utterance).

English, V2V (ms)turnsp50p90p95max
brisvox, first 3 calls (as specified)61580210221022102
Pipecat, first 3 calls (as specified)62173258825882588
brisvox, 15 calls pooled301686220329274287
Pipecat, 15 calls pooled302029237025442588
brisvox, Turkish, 3 calls61994303130313031
Pipecat, Turkish, 3 calls62519347734773477

The 15 calls are three blocks per system (3 + 6 + 6 calls), alternating brisvox and Pipecat; the first block is the run as specified, the others were added because the LLM provider's variance is as large as the effect. All turns were answered, no call failed, no no_response, talked_over or bot_speaking turn on either side.

Provider latencies as each system's own timers saw them (English, 30 turns each, p50 / p90 / p95 ms; brisvox: the turn finished timeline of the server, Pipecat: its service TTFB timers):

stagebrisvoxPipecat
STT final after the end of speech285 / 319 / 320420 / 460 / 471 (TTFB 320 / 360 / 371 from the VAD stop, plus its 100 ms)
LLM first token (OpenRouter included)741 / 1423 / 2229 (max 3178)1064 / 1328 / 1454 (max 1884)
TTS first audio (Cartesia)212 / 231 / 250211 / 229 / 244

What the 343 ms p50 difference (1686 vs 2029) is made of:

  • STT final, about 135 ms in brisvox's favour. brisvox hands Deepgram a Finalize at the VAD stop ; Pipecat waits for the provider's own endpointing (300 ms of silence) and its final. This is a framework behaviour, not provider variance.
  • LLM first token, about 320 ms in brisvox's favour, mostly not attributable to the frameworks. Both call the same OpenRouter model with the same body. Interleaved A/B runs of the request (raw httpx against Pipecat's OpenAILLMService client, 14 pairs, warm connection) gave the same median (1.03 s and 1.06 s): Pipecat's client adds nothing over a plain HTTP/1.1 client, and the provider's time swung between 0.63 s and 3 s for identical requests within the day. A third arm over HTTP/2 (what reqwest negotiates for brisvox) was about 170 ms lower at the median in a second, noisy A/B, a plausible part of the gap; the rest is within the provider's variance. The p95 of the LLM is higher for brisvox (a few slow responses, 3.2 s and 2.2 s, that landed in brisvox's blocks): the tail of the V2V columns is the provider's, not a framework's.
  • TTS first audio: no difference. Same Cartesia endpoint, same 24 kHz websocket.
  • The framework's own share. brisvox's server-side response latency equals its STT final plus its LLM up to the first speakable chunk plus its TTS first byte to within a few hundred microseconds (the engine hand-offs; "of which inside the server": 0 ms in the report). What is left of V2V after the three providers (about 220 ms at p50) is client side: the VAD's end of speech against the fixture's ground truth, the 20 ms frame grid, the websocket. Pipecat's residual in the same definition (V2V minus the 100 ms VAD stop, STT, LLM first token and TTS first byte) is 308 ms at p50, brisvox's with the identical definition 400 ms: brisvox's is larger because it also contains the wait for the first speakable chunk (195 ms at p50 after the first token), which in Pipecat is hidden in its own sentence aggregation. The frameworks' overheads are close; the measured gap comes from the STT finalisation and from the LLM client/provider path.
  • Pipecat restarted the LLM in 5 of 30 English and 4 of 6 Turkish turns. Its turn-stop strategy ended the turn at a mid-sentence pause and started the LLM, and the user's resumed speech cancelled it. The V2V of those turns is still measured from the end of the utterance, but the user was cut off once; brisvox made exactly one LLM request per turn.

Caveats, read before quoting any number:

  • Network variance and sample size. 30 turns per system and 6 for Turkish, 2 turns per call, one machine, one afternoon; p90 and p95 are the 3rd and 2nd largest samples. OpenRouter sits in the path of both systems and its first-token time varied 5x for the same request. Do not read the p95 columns as tail estimates.
  • Windows, not Linux. The timer tick adds up to ~15 ms of send lateness (in each raw report) and the machine was shared with other work (a Docker/WSL benchmark in another session).
  • IPv6. This machine's IPv6 route drops packets. Pipecat 1.12.0 as shipped connects its websockets one address at a time: the first Cartesia handshake of a call timed out after 10 s and the first turn got no audio (observed in the first attempt). The baseline bot therefore enables asyncio's happy-eyeballs racing (bot.py --no-happy-eyeballs restores Pipecat as shipped); the numbers above are with it on. On a host with working IPv6 this does not matter.
  • Reasoning. openai/gpt-6-luna thinks by default (about +1 s to the first token, ~20 reasoning tokens for a one-line answer); both systems send reasoning_effort: none (agent file key [llm.openai_compatible].reasoning_effort, added for this run).
  • Different timers. The STT and LLM columns are each system's own measurements with their own start events (VAD-backdated end of speech vs the VAD stop frame, request written vs LLM service start); only V2V is measured identically, by the same client. Turkish transcription quality was not judged, only the latency of an answer.
  • Method notes. A first pass with the bench's default 4 s gap was discarded (replies were cut by the next utterance); one Pipecat block was re-run because a stale bot instance had answered and no metrics were logged. About USD 0.003 of OpenRouter credit was used in all.

Live comparison with 5 concurrent calls, 100 turns per system, Deepgram Aura voice (measured 2026-10-14, Windows)

Measured before the pacer phase was turned off by default: brisvox's first turn of each call includes the pacer phase (0-19 ms, mean 9.5 ms); the numbers were not re-measured.

The larger rerun of the comparison above (the 30-turn run stays as the earlier run, with Cartesia, one call at a time). Raw reports with every turn, the failure counts and the routing check: bench/results/v2v-live-{brisvox,pipecat}-c5-windows-2026-10-14.{md,json}, produced by scripts/bench_live_report.py from the bench's reports and the systems' logs. English only.

Method:

  • Blocks. 10 blocks per system, each brisvox-bench v2v --concurrency 5 --runs 5 (5 calls at once, 2 turns per call, --min-gap-ms 9000), strictly alternating b1 p1 b2 p2 ... so both systems saw the same provider conditions; one warm-up block per system first (not counted). Both servers ran side by side and idle between their blocks; the client waited for each block to end. 100 turns per system, 200 plus 20 warm-up turns in all. Every call plays the same fixture, so the 5 calls speak at nearly the same moment and the providers see 5 simultaneous requests per block.
  • Same providers, same route. Deepgram nova-3 (STT), OpenRouter openai/gpt-6-luna (Chat Completions, streaming, max_tokens 120, reasoning_effort none, the lowest this model accepts: its supported_efforts are max, xhigh, high, medium, low, none), upstream pinned with {"provider": {"order": ["openai"], "allow_fallbacks": false}} (openai is the model vendor's own standard-tier endpoint), Deepgram Aura aura-2-thalia-en (TTS). Agent file bench/pipecat_baseline/agent_live_en_aura.toml, both systems. Smart Turn v3.2 (the same file) and Silero v6.2.3. Cartesia was the first choice and was dropped: its plan on this account allows 2 concurrent generations (below).
  • Aura on both sides. brisvox: its deepgram_aura adapter, wss://api.deepgram.com/v1/speak, encoding=linear16&sample_rate=24000&model=aura-2-thalia-en, a Speak per text chunk and one Flush per turn. Pipecat: DeepgramTTSService, the same URL and parameters, a Speak per sentence and a Flush when the LLM response ends. The reply is one sentence, so both send one Speak and one Flush and Aura starts synthesising at the flush. Differences that remain: brisvox's text chunking and its stall deadline; Pipecat's TTS TTFB timer starts at its Speak. Neither sends speed.
  • The upstream was checked per response. brisvox logs llm upstream (the provider and model fields of the first stream chunk), the Pipecat baseline PCUPSTREAM. brisvox: 100 of 100 requests reported upstream OpenAI, model openai/gpt-6-luna. Pipecat: 102 responses, all OpenAI and the same model; it made 167 LLM requests in all (below). With a nonexistent slug pinned, both systems got OpenRouter's HTTP 404 on every request (checked on the Cartesia attempt), so the pin is really sent by both and is never silently replaced by a fallback.

Voice-to-voice (V2V, ms; same client, same definition; nearest rank over the 100 answered turns):

English, 5 concurrent callsturnsp50p90p95p99maxmean
brisvox100184622092876519952351937
Pipecat100197229804348614175292343

All 200 turns were answered; no failed call, no_response, talked_over or bot_speaking turn, no error or warning line, no provider refusal on either side. The p50 of each block ranged 1566 to 1935 ms for brisvox and 1845 to 2167 ms for Pipecat (listed in the reports).

Provider latencies as each system's own timers saw them (100 turns each; p50 / p90 / p95 / p99 ms; brisvox: the turn finished timeline of the server, Pipecat: its service TTFB timers):

stagebrisvoxPipecat
STT final after the end of speech300 / 337 / 363 / 376455 / 499 / 505 / 521 (TTFB 355 / 399 / 405 / 421 from the VAD stop, plus its 100 ms)
LLM first token (OpenRouter included)931 / 1311 / 1984 / 4282889 / 1381 / 3483 / 3892
TTS first audio (Deepgram Aura)220 / 395 / 416 / 432251 / 259 / 265 / 270
V2V minus (STT final + LLM first token + TTS first audio; Pipecat: plus the 100 ms VAD stop)300 / 556 / 681398 / 1143 / 1328

What the numbers say, and what they do not:

  • Medians are close, tails are not. The p50 gap is 126 ms (1846 vs 1972); at p95 brisvox is 1.5 s lower (2876 vs 4348). LLM first-token p50 is the same within the provider's noise (931 vs 889 ms); the tail of the LLM column is the provider's (slow responses of 3 to 4.5 s land in both). The STT stage is a framework difference as before: brisvox's Finalize at the VAD stop gives a final 155 ms after the end of speech earlier at p50 than Pipecat's endpointing wait.
  • Pipecat restarted the LLM in 56 of 100 turns (65 requests were cancelled before their first chunk; the 2026-10-13 run had 5 of 30; 39 of the restarts were on the first turn of a call, 17 on the second). The pattern is consistent with a turn ended at a pause inside the utterance whose LLM request was then cancelled by the resumed speech (no bot audio was produced for the cancelled requests); the cause was not investigated. A configuration difference is a candidate: the same nominal 100 ms VAD stop is 3 Silero windows (96 ms) in Pipecat and 4 (128 ms) in brisvox ("Unloaded floor" above), and this run did not equalise it (--vad-stop-ms 128). Its V2V p50 is 2351 ms (n = 56) for the restarted turns and 1791 ms (n = 44) for the turns without a restart, close to brisvox's overall median (a selection effect too: the restarted turns are mostly first turns of a call). Why the restarts are more frequent than at one call at a time was not investigated (the baseline runs one Python process with Pipecat's default of one Smart Turn thread per call; five pipelines share one core's worth of GIL): unverified, not tuned. brisvox made exactly one LLM request per turn.
  • TTS first audio is not a difference. Aura's first audio is 220 vs 251 ms at p50; brisvox has a few slower first audios (p90 395 ms, max 720 ms), Pipecat's TTFB is flat. The two timers start at different events (see the method).
  • The residual (V2V minus the three provider stages) is 300 ms at p50 for brisvox and 398 ms for Pipecat in the common definition; client side, the VAD against the fixture's ground truth, the 20 ms frame grid and the websocket are in it for both (the server's own overhead for brisvox is 0 ms to within the engine hand-offs, as before).

Caveats, read before quoting any number:

  • Windows, one shared machine over the public internet, largest client send lateness 29.9 ms; the machine was shared with another session (no build ran during the blocks). Not a Linux run (docs/LATENCY.md asks for Linux numbers for target claims).
  • 100 turns per system: p99 is the 2nd largest of 100 values, not a tail estimate with confidence; the p95 columns move with two or three slow provider responses.
  • Five calls playing the same fixture speak in lockstep; real traffic is spread out. This stresses the providers' concurrent request handling more than a random mix would.
  • brisvox runs one server process, the Pipecat baseline one Python process (the capacity benchmark uses 4 workers for Pipecat; here, 5 calls, one is what the baseline README documents).
  • Cost: about USD 1 in all by list price, mostly Deepgram STT and Aura (OpenRouter well under USD 0.05); not read from the invoices.
  • Voice quality was not judged; Aura aura-2-thalia-en is not the Cartesia voice of the earlier run, so the TTS column is not comparable with that run.

Earlier attempt on Cartesia: stopped by its concurrency limit (2026-10-14)

The larger rerun (100 turns per system, blocks of 5 concurrent calls x 2 turns, brisvox and Pipecat alternating, both pinned to the same OpenRouter upstream) was stopped after one warm-up block per system: the Cartesia plan of this account allows 2 concurrent generations ("You have reached your concurrency limit of 2"), and five calls that answer at the same moment exceed it. brisvox's Cartesia adapter re-opens a refused context after 300 ms (up to four times), so its 10 turns were all answered but four of them carry 0.7 to 2.6 s of Cartesia waiting (tts_ttfb); Pipecat as shipped surfaced 12 error frames and answered 7 of 10 turns (one whole call got no audio). The two systems no longer do the same work at this concurrency, so no table was published for Cartesia (the run above uses Deepgram Aura instead): raw numbers and the routing checks are in bench/results/v2v-live-c5-cartesia-limit-windows-2026-10-14.md. A Cartesia rerun would need a plan with a concurrency of at least 5, or --concurrency 2.

What this work added, usable for any later live run:

  • brisvox-bench v2v --url ... --concurrency N plays the plan's calls N at a time (live mode only; reports carry settings.concurrency).
  • Pinned LLM route on both systems. Agent file keys [llm.openai_compatible] openrouter_order and openrouter_allow_fallbacks send OpenRouter's provider routing object ({"order": ["openai"], "allow_fallbacks": false}); the Pipecat baseline maps the same keys into extra_body of its OpenAI client. Model openai/gpt-6-luna, reasoning_effort none (the lowest the model accepts: max, xhigh, high, medium, low, none), upstream openai (the model vendor's own standard-tier endpoint). Verified on the wire: both systems log which upstream the first stream chunk names (brisvox llm upstream with upstream_provider, the baseline PCUPSTREAM), and with a slug that cannot be served both get OpenRouter's HTTP 404 instead of a fallback.

Engine stage times and memory (measured 2026-10-13)

brisvox-bench v2v --sim also reports the engine's own per-stage times (engine_stages; code brisvox_bench::v2v::stages): every call runs on a TimelineRecorder of its own, and when it ends its turns are collected. The stamps are taken where the work happens, on the engine's clock, so the transport and the harness are not in them. Stages and how the turns feed them are in bench/results/README.md; the barge-in calls (the caller talks over a long answer) give the clear, the memory calls (8 at once) the memory. The engine overhead budget of docs/PIPELINE.md section 8 applies at p95. With --strict a stage over its budget fails the run (no JSON is written; BenchReport::budget_problems); without it the miss is a warning.

Release build, Windows dev machine shared with other builds, 60 latency turns and 30 barge-ins (bench/results/v2v-sim.md):

stagefrom -> top50p95maxprovider time in itbudget p95
vad_stop_to_finalizeVadStop -> SttFinalize0.006 ms0.015 ms0.017 ms0-
turn_end_to_llm_requestTurnEnd -> LlmRequest0.051 ms0.089 ms0.212 ms03 ms: met
llm_request_to_first_tokenLlmRequest -> LlmFirstToken358.4 ms366.6 ms367.7 ms350 ms-
first_token_to_tts_textLlmFirstToken -> TtsRequest0.025 ms0.051 ms0.089 ms0-
tts_request_to_first_byteTtsRequest -> TtsFirstByte158.4 ms166.7 ms167.1 ms150 ms-
tts_audio_to_transportTtsFirstByte -> FirstAudioOut0.035 ms0.064 ms0.103 ms02 ms: met
interrupt_to_clearInterrupted -> AudioOutStopped0.012 ms0.022 ms0.669 ms01 ms: met
turn_end_to_first_audioTurnEnd -> FirstAudioOut517.7 ms533.2 ms533.4 ms500 ms-
  • All three budgets are met, by one to two orders of magnitude: the end of turn reaches the LLM in 0.09 ms at p95, the first TTS audio reaches the transport in 0.06 ms, a confirmed barge-in's Clear is applied in 0.02 ms (the worst of 30, 0.67 ms, is one scheduling delay).
  • The mocks' sleeps are not the engine's. The two provider spans overshoot their fixed 350 ms and 150 ms by 8.4 ms at p50 and 16.6 ms at p95 each: the Windows timer's precision (one ~15.6 ms tick at worst). turn_end_to_first_audio (17.7 ms over its 500 ms at p50) is those two overshoots plus the engine stages above, about 1 ms.
  • The span stamps. VadStop is the VAD worker's decision (not the backdated UserSpeechEnd), SttFinalize the Finalize handed to the provider session (both marked with TimelineRecorder::mark_open_turn); AudioOutStopped is marked by the turn driver when its fast-path Clear returns, that is applied in the transport.
  • Memory. The process (engine, transport and harness) holds 10.2 MiB with the server idle and cold, 22.1 MiB idle after the latency and barge-in calls (+11.9 MiB kept after the first calls: allocator caches and lazily built state) and peaks at 29.7 MiB with 8 calls alive at once: about 1.0 MiB per call (peak over the warm idle reading, per call; measured on calls alive together, because one call at a time reuses what the previous one freed). The Smart Turn ring (8 s of 16 kHz PCM16, 256 KB) and its analysis snapshot (another 256 KB) are about half of it; the capacity benchmark measures it at hundreds of calls.
  • Linux numbers are still to be recorded for this table; on Linux the timer overshoot of the mocks shrinks to about a millisecond.

Simulator Budget numbers (measured 2026-10-12)

The Budget profile of brisvox_sim::suite (table in the suite module docs) was set from what the production pipeline (EngineFactory, engine_suite.rs) and the hybrid chains (hybrid_*.rs, speculation_hybrid.rs) measure on a paused clock with zero-latency transport and the suite's mock latencies. Paused time: these are pipeline-structure numbers (turn policy, pacer phase, frame grid), not CPU cost; the engine's own overhead is measured in real time by the V2V bench. Measured by temporarily logging every Profile::duration/at_least value over all engine tests (the hook is not in the tree):

checkreference (Exact)measured, production + hybridsBudget
one turn: first bot audio1480 ms1480, 1495 (adaptive, prod.), 1650, 1665 (fixed 450 ms, prod.); since the phase was turned off, prod. is 1480 and 16501480 to 1700 ms
speech start / end marks200 / 1200 ms200 / 1200 mswithin 20 ms
end-of-turn delay280 ms280 (adaptive), 450 (fixed)280 to 470 ms
response latency280 ms280 (adaptive), 450 (fixed)280 to 470 ms
s01 first audio after the end of speech580 ms (min)1008 to 1079 msat least 580 ms
barge-in: interrupt at the output2420 ms2420 ms2400 to 2440 ms
barge-in / s02: time to silence000 to 50 ms (kept for a slow consumer)
s05 LLM first token behind a 2 s stall2000 ms2000 ms2000 to 2020 ms
playout log vs frames at the outputequal0 (677 checks)within 20 ms
fixtures: first audio after each utterance endat most 600 ms280 to 305 (adaptive), 450 to 475 (fixed)at most 600 ms
  • The +15 ms of the production first audio over the hybrids (1495 against 1480) was the transport pacer's per-session phase offset: the first frame waited for the session's next 20 ms tick. Now the first frame leaves at once and the production pipeline measures 1480 ms like the hybrids (the phase is an opt-in of the pacer; see "The pacer phase" below).
  • The widest range is the fixed 450 ms turn end (170 ms over the reference's 280 ms backdated VAD stop); the adaptive policy with a confident model ends the turn at the stop like the reference.
  • Every range keeps the reference's exact value (seam.rs, the_budget_profile_accepts_the_ reference_pipeline) and one 20 ms frame of margin over the largest measured value.

Hot-path micro-benches (measured 2026-10-12)

crates/brisvox-engine/tests/hot_path_cost.rs, release build (cargo test --release -p brisvox-engine --test hot_path_cost -- --nocapture), Windows dev machine shared with other builds; single calls, best of three runs per statistic. Every case that runs on a tokio worker is far inside the 50 us rule; the test asserts the median against 50 us in release (10 ms in the debug gate).

CaseMedianp99WorstOn a worker
Segmenter::push_spans (4-12 byte delta)0.1 us0.4 us2.4 usyes
ResponseAssembler::push (text delta)0.0 us0.1 us0.4 usyes
ResponseAssembler::finish (2 KB, one call)1.6 us1.6 us3.6 usyes
ResponseAssembler::finish (4 KB = inline_finish_bytes, one call)2.5 us2.6 us4.7 usyes
PlayedText::heard (200 words)4.5 us4.7 us11.5 usyes
TurnMachine::handle (a turn's inputs)0.0 us0.1 us0.3 usyes
StreamResampler 8 kHz -> 16 kHz, 20 ms frame0.6 us0.7 us8.4 usyes
StreamResampler 48 kHz -> 16 kHz, 20 ms frame1.8 us1.8 us4.3 usyes
OutputFramer 24 kHz -> 16 kHz, 20 ms chunk1.2 us1.3 us2.8 usyes
OutputFramer 24 kHz -> 16 kHz, 100 ms chunk5.8 us6.0 us8.5 usyes
OutputFramer 24 kHz -> 8 kHz, 100 ms chunk (the stage's piece)30.5 us33.0 us40.5 usyes
OutputFramer 24 kHz -> 16 kHz, 500 ms chunk (the stage cuts it in 100 ms pieces)28.8 us31.3 us42.8 usno
RequestBuilder::build (build_window), 20 turns1.3 us1.4 us1.4 usyes
same, 380 messages (below INLINE_MAX_MESSAGES)2.3 us2.5 us4.8 usyes
same, 4 x 3.7 KB results (below INLINE_MAX_TURN_BYTES)2.8 us3.7 us4.9 usyes
Context::clone, 380 messages4.8 us4.9 us6.8 usyes
is_heavy, 380 messages0.3 us0.3 us0.3 usyes

Nothing moved to the pool: the closest case is the 24 kHz to 8 kHz conversion of a 100 ms piece (30 us), which the TTS stage already bounds by cutting provider chunks into such pieces with a yield between them. The whole-context limit of is_heavy (INLINE_MAX_CONTEXT_BYTES, 256 KiB) is set from the clone cost: a context copy shares message text (Arc<str>), so it costs per message, not per byte, and 380 messages copy in 5 us; the byte limit catches the JSON values (tool arguments, provider data) that a copy does duplicate.

8 kHz mu-law call output path (measured 2026-10-13)

crates/brisvox-engine/tests/ws_mulaw_call.rs, release build (cargo test --release -p brisvox-engine --test ws_mulaw_call the_per_frame -- --nocapture), Windows dev machine shared with other builds, median of single calls, best of three runs. One 40 ms chunk of 24 kHz PCM16 through OutputFramer (24 kHz to 8 kHz) into two 160-sample frames, then the transport's mu-law encode of a frame:

Step, per 20 ms frameMedian
resample + frame6.65 us
mu-law encode0.03 us
together6.68 us (budget 50 us)

The test asserts 50 us only in release; scripts/verify.sh is a debug run, so the gate holds the 10 ms gross-regression bound there (the same policy as hot_path_cost.rs). Re-run the command above and update this table when the resampler or the framer changes; a release perf gate in verify.sh was not added (a release build of the workspace doubles the gate's time and RAM).

Real-time scheduler gaps under load (20 calls, realtime_load.rs): release worst 1.67-2.61 ms on the shared machine (at most two polls over 2 ms in a 14 s run): the 2 ms acceptance bound is not met on this evidence (a quiet-machine run is still to be recorded), see docs/TESTING.md.

First TTS chunk

The first TTS chunk of a response ends at the earliest clause boundary or sentence end once it holds first_min_words words (SegmenterStageConfig::FIRST_MIN_WORDS_EN / _TR). A voice reply typically opens with a one-word clause (Sure, Okay, Peki,) and then a long sentence: with two or three words the first chunk waits for that whole sentence, streamed token by token. Earlier the defaults were three (English) and two (Turkish): the chaos suite's LLM, which streams Sure, here is a fairly long answer to your question. word by word 30 ms apart, was spoken only when the sentence was complete (TTS request 300 ms after the first token on the paused clock).

Measured on the production pipeline (crates/brisvox-engine/tests/first_chunk_policy.rs): the chaos suite's 100 unaffected reference calls (seeds 2000..2099, two turns each, played alone; LLM first token log-normal with a 350 ms median, 30 ms per token; TTS first byte log-normal 150 ms; adaptive end of turn), first audio after the end of speech:

first chunk afterp50p95maxturns over the 1.5 s SLO
1 word969 ms1262 ms1366 ms0 of 200
2 words1137 ms1485 ms1592 ms9 of 200
3 words1137 ms1485 ms1592 ms9 of 200

The default is now one word in both languages. English is measured: 168 ms off the median of this answer shape, and the 100-call chaos test passes with the production default and the unchanged 1.5 s SLO (the with_first_chunk_words(1) override is gone). The TTS keeps prosody across chunks because every chunk of a round goes into the same provider context; whether a one-word first chunk sounds natural with each real TTS is checked with the live tests. Turkish is an assumption, not a measurement: no Turkish answer is measured here, and one word is assumed because a Turkish reply opens the same way (Peki, Tamam, and a long sentence). A Turkish reference set (or the live Turkish TTS tests) still has to confirm it, prosody included. SessionBuilder::with_first_chunk_words still overrides it per call.

Live prosody and speculation checks (measured 2026-10-13)

One-word first chunks (Okay, / Tamam, + the rest 150 ms later, one context, real providers): first audio 120-220 ms on Cartesia and ElevenLabs (the same as one chunk), no pacer underrun, no pause beyond the natural comma pause on Cartesia; ElevenLabs Turkish adds 210-230 ms; on Deepgram Aura-2 the one-word chunk gives no first-audio gain (413-418 ms vs 257-271 ms for one chunk). FIRST_MIN_WORDS is unchanged. With Deepgram nova-3 the partial at the VAD stop equals the final in 2 of 17 turns (interims arrive once per second, the finalize round trip is 290-330 ms): on_partial = false for Deepgram, EN and TR. Data: bench/results/live-checks-2026-10-13.md, bench/results/speculation-live-deepgram-2026-10-13.md.

The out-of-process agent protocol (docs/AGENT_PROTOCOL.md) adds one hop when the agent reacts to the call: an engine event leaves the call, crosses the WebSocket to the agent process, the agent answers with a command naming the event (re), and the server parses the command. The link measures every such hop itself, from the moment its event pump took the event from the call's event ring to the moment the reader parsed the command: LinkStats::hops, hop_mean(), hop_max_us and a bucketed histogram (hop_quantile(q) gives the bucket's upper bound; buckets 50, 100, 200, 300, 500, 750 us, 1, 1.5, 2, 3, 5, 10, 20, 50, 100 ms).

Measured by crates/brisvox-agent-link/tests/hop_latency.rs (500 hops; the reference agent in Rust answers each user.text at once with a command; real sockets on 127.0.0.1; Windows 11, debug build, cargo nextest run -p brisvox-agent-link --test hop_latency --no-capture):

patternmeanp50p95p99max
back to back (the next event leaves when the agent answered)0.08-0.11 ms<= 0.1 ms<= 0.3 ms<= 0.3 ms0.23-0.37 ms
paced, one event every 2 ms (threads idle between events; Windows timer: ~15 ms steps)0.38-0.43 ms<= 0.5 ms<= 0.75 ms<= 0.75 ms0.71-0.97 ms

Release build (measured 2026-10-13), the same test, cargo nextest run --release -p brisvox-agent-link --test hop_latency --no-capture (back to back, 500 hops, Windows 11):

buildmeanp50p95p99max
release0.033 ms<= 0.05 ms<= 0.1 ms<= 0.2 ms0.221 ms

The hop is dominated by two loopback socket writes and the scheduler. The debug table above predates the agent link's reconnect work (the link's tasks were restructured for reconnecting); only the release run was repeated.

So on loopback the hop costs well under a millisecond, far below one 20 ms audio frame and the provider latencies; an agent elsewhere adds its network round trip and its own processing on top. A release build only lowers these numbers. The commands themselves act at the engine's next decision point: a say starts as soon as the turn driver handles its wake-up (the bot idle), a context.update at the next response.

Speculative LLM start (measured 2026-10-13)

The question: should EngineConfig::speculation (start the LLM at a candidate end of turn, buffer its output, commit it if the turn ends with the same words) be on by default? Decision: no, it stays opt-in (reasons below). This section is the decision text. The numbers behind it:

What was run.

  • brisvox-bench speculation (bench/results/speculation-fixtures.md): the production engine (EngineFactory) over the three real-speech fixtures with mock providers on a paused clock, 20 seeds per fixture, log-normal latencies around STT final 150 / LLM first token 350 / TTS first byte 150 ms (the same seeds in every row, so rows differ only by the configuration), played twice: with the LLM first token at 350 ms (after the miss is known) and at 100 ms (before it). Deterministic.
  • brisvox-bench v2v --sim --speculation ... (bench/results/v2v-speculation.md, one file pair per row in bench/results/v2v-speculation/): the same engine behind the websocket, real time, fixed provider latencies, 16 calls x 2 turns per row. Windows numbers are trends (about 8 ms of timer overshoot per mock sleep), and p90/p99 over 32 turns are coarse.
  • Turn policies: adaptive (Smart Turn model that answers "complete", the engine default), and fixed silences of 450 ms (= the finals gate, VAD stop 300 + STT final 150) and 700 ms.
  • Speculation modes: off; on with on_partial and the STT's last interim lagging the final by a word (the mock's own interims: the worst case, every partial misses); on with the interim converged to the final (the best case: every partial hits); on final only (on_partial off: candidates only at the STT final).
  • The benchmark needed three honest-measurement fixes: the mock LLM now answers by request (MockLlm::set_responder; with the queue, a discarded speculative request consumed the next turn's answer), the mock STT can converge its last interim to the final (MockStt::set_converged_interims), and it can time the interims from the speech instead of from the first frame after the previous Finalize (set_interims_from_speech; before, the second utterance of every call had its interims during the silence before it, so it never had a partial at the VAD stop). The V2V engine target uses both new STT settings and the responder for every row, speculation off included.

Paused clock, fixtures (120 turns per row, V2V in ms, server side).

LLM first token 350 ms (bench/results/speculation-fixtures.md has the same rows with the fast LLM, the counters and the token columns):

policyspeculationV2V p50p90p99delta p50turns committedrun hit ratehead start msextra LLM requests per turn
adaptiveoff92311061168----0.00
adaptiveon, lagging interims92511131244+20%0%-1.00
adaptiveon, converged interims7829111013-141100%100%1540.00
adaptiveon, final only92311061168+0---0.00
fixed 450 msoff96311061183----0.00
fixed 450 mson, lagging interims92511131244-3872%42%381.00
fixed 450 mson, converged interims7829111013-181100%100%1810.00
fixed 450 mson, final only92311061168-4072%100%380.00
fixed 700 msoff120213311433----0.00
fixed 700 mson, lagging interims92511131244-277100%50%2661.00
fixed 700 mson, converged interims8579541013-345100%100%4200.00
fixed 700 mson, final only92511061168-277100%100%2660.00

Two hit rates, not to be mixed up. turns committed is per turn: committed speculations over answered turns. run hit rate is per run: committed over (committed + discarded runs). They agree only with one candidate per turn. Fixed 700 ms with lagging interims starts two runs on every turn (one from the partial at the VAD stop, superseded when the final arrives, and one at the final that commits): 100% of the turns commit, 50% of the runs hit, and one request per turn is wasted. The extra requests are started - committed per turn, read from the engine's counters.

Same, with a fast LLM (first token 100 ms, before the STT final; paused clock, 120 turns per row).

policyspeculationV2V p50delta p50extra LLM requests per turnwasted output tokens per turnwasted input tokens per turn (reported)
adaptiveoff677-0.000.000.0
adaptiveon, lagging interims678+11.001.9517.4
adaptiveon, converged interims572-1050.000.000.0
fixed 700 msoff950-0.000.000.0
fixed 700 mson, lagging interims844-1061.001.9517.4
fixed 700 mson, final only844-1060.000.000.0

Real time over the websocket (32 turns per row, V2V in ms, client side).

policyspeculationV2V p50p90p99delta p50run hit ratehead start msextra LLM requests per turn
adaptiveoff100510261047----
adaptiveon, lagging interims100310201036-30%-1.00
adaptiveon, converged interims849875887-156100%1550.00
fixed 700 msoff126312821297----
fixed 700 mson, lagging interims101110381055-25250%2531.00
fixed 700 mson, converged interims903921938-361100%4100.00
fixed 700 mson, final only100910321061-255100%2610.00
fixed 450 msoff101710421046----
fixed 450 mson, lagging interims100610201030-1144%161.00
fixed 450 mson, converged interims845869882-172100%1680.00
fixed 450 mson, final only100210281043-14100%150.00

"Extra LLM requests per turn" is the number of speculative runs that were not committed (started - committed, per turn): each one is a request with a full prompt that was sent for nothing. The engine also counts the output tokens a discarded run had buffered (wasted_output_tokens) and the input tokens the provider reported for it. Measured, with two results. With the 350 ms LLM both are 0: the first token comes after the miss is known (the STT final, 150 ms) and the run is cancelled before it streams or reports usage; the real-time table above is that case only, so its "0 wasted tokens" says nothing about a faster LLM. With the 100 ms LLM the first token precedes the final, a discarded run streams into its buffer and the counters are nonzero (1.95 output tokens and 17.4 reported input tokens per turn in the table above; the scale is the mock's short scripted answers and short prompts, so read it as nonzero, not as a price). The cost of a miss is its request plus whatever the run streamed before it was cancelled.

Reading the tables.

  • Adaptive (the default policy). A complete verdict ends the turn at the STT final, so the only candidate that helps is the one from the partial at the VAD stop (on_partial). If the partial equals the final it saves one STT finalize round trip: -141 ms p50 on the paused clock, -156 ms in real time (about 15% of the 920-1000 ms V2V), at no cost. If the partial lags the final it saves nothing (+2 / -3 ms) and costs one extra request on every turn. final only never starts a run under this policy (identical to off).
  • Fixed silence longer than the gate (700 ms). The final arrives at 450 ms but the turn ends at 700 ms. A candidate at the final uses the 250 ms in between: -277 ms (paused) / -255 ms (real time) with no request wasted when the caller does not resume, and without any partial (final only). The partial adds a little more only if it converged (-345 / -361 ms) and doubles the requests if it lags.
  • Fixed silence equal to the gate (450 ms). The final-based candidate arrives with the turn end: -40 / -14 ms. Only the partial helps (-181 / -172 ms), and only when converged.
  • No latency is lost on a miss. The adaptive miss row is within 3 ms of off at p50 (p99 is 76 ms higher on 120 paused-clock turns, 11 ms lower on 32 real-time turns: noise). A discarded run is cancelled and the real request starts at the turn end, as without speculation.
  • The head start of a committed run is bounded by what the turn waits for after the request: the STT final (about 150 ms) for the partial, the silence beyond the final for the final-based run.

Cost per call. The cost is the extra requests, read from the counters: a call of T answered turns whose requests carry P prompt tokens sends E x T extra requests, with E = (started - committed) / turns the "extra LLM requests per turn" column, i.e. E x T x P extra input tokens, +E x 100% of the call's LLM input tokens before prompt caching (the stable prefix of a wasted request is a cache hit at the provider's cached rate, which lowers the money, not the count). E is a property of the policy and the STT, from the tables: 0.00 with converged interims or with speculation at the final under a fixed silence longer than the gate; 1.00 with lagging interims in every policy measured (adaptive: one run per turn, all discarded; fixed 700 ms: two runs per turn, one discarded, although every turn commits). For T = 10 and P = 1,500: 15,000 input tokens per call without speculation, +0 at E = 0, +15,000 (double) at E = 1.00. With a single candidate per turn (the adaptive policy, where only the partial can start a run) E = 1 - h for h the turn hit rate, so +4,500 tokens at h = 70%; that shortcut does not hold when a turn can start two runs. Output tokens: about 0 when the miss is known before the first token (the STT final, 100-300 ms, usually precedes the first token, 200-500 ms) and nonzero otherwise (the 100 ms LLM row above); the engine counts them in wasted_output_tokens. Each miss is also one more concurrent request against the provider's rate limit for up to the STT final's duration. The money is E x T x P x the provider's input price, left symbolic on purpose (prices change; this document does not quote them).

Why it stays off.

  1. The gain under the default policy is at most the STT finalize round trip and exists only when the partial at the VAD stop already equals the final. Whether it does is a property of the STT provider and of the pause the caller makes, not of the engine; the fixtures bracket it between 0% (cost with no gain) and 100%, and nothing in the repository measures a live STT's hit rate (Deepgram documents that the words at the end of an interim can be truncated, and that interims come typically every second, docs/providers/deepgram.md).
  2. The 100% bound is not an upper bound for real calls: a pause of 300 ms or more inside a sentence (VAD stop, then the caller resumes) wastes a request however good the STT is. The fixtures have no such pause.
  3. Default-on would double the LLM input tokens of every deployment whose STT lags, with no change in behaviour that anyone chose; that is a cost regression, not a latency feature.
  4. A discarded speculative request consumes a queued answer of the scripted mock LLM, so every test that scripts a default pipeline by queue would have to move to a responder first.

What to do instead.

  • Fixed silence of 600 ms or more: enable speculation with on_partial = false (speculation.enabled = true, speculation.on_partial = false in the server configuration). It costs no request when the caller does not resume and returns the silence beyond the final.
  • Adaptive: enable on_partial per deployment once its STT's hit rate is measured (Deepgram measured 2026-10-13: 2 of 17 turns, so NOT for Deepgram, see the live checks below; AssemblyAI, Gladia: not measured yet, needs keys). Rule of thumb used here, for the adaptive policy (one candidate per turn): worth it from a turn hit rate of about 70%, i.e. at most +30% input tokens (E = 0.30) for about -150 ms. A hit rate below about 40% is a net loss (a judgement call, not a measurement).
  • The stable-partial gate is below. Still open: offering the partial when the Smart Turn verdict is already "complete", which would raise the hit rate without more requests.

Stable-partial gate (measured 2026-10-13).

The follow-up of the section above: do not start a request from a partial that the caller may still change. SpeculationConfig::partial_stable_for (server configuration speculation.partial_stable_ms, validated not above max_age; default 0 = no gate, the behaviour is unchanged and speculation stays opt-in) delays the partial candidate of a VAD stop. It is offered partial_stable_for after the stop (the partial text is the final segments heard plus the latest interim, normalised; a change of it after the stop restarts the wait), and only if

  • the caller stayed silent: a VadStart cancels the wait, so a caller who goes on costs no request at all, not even a withdrawn one (without the gate the partial is offered at the stop and withdrawn at the resumption);
  • the text did not change: an interim that changes it restarts the wait (the STT is still moving);
  • no final came first: the STT final of the stop supersedes the waiting partial. A final is the STT's own stable text ("the STT marks it stable"), so final-based candidates are never held. No STT adapter exposes any other stability flag today (the transcript frame carries only is_final), so the time is the only knob.

The adaptive policy ends the turn a few milliseconds after the stop and holds it for the final; the gated partial is offered then too (offer_stable_partial; it was dropped at first because the turn was no longer UserPaused, which only the real-time run showed).

Resumed speech (brisvox-bench speculation, scenario resumed after P ms): every utterance of the three fixtures is cut in two at the middle of its audio, the halves P ms apart (zeros), the transcript split after half of the words; the turn is the whole utterance (fixed silence 900 ms, longer than the pause plus the quiet the cut lands next to), the answers are the same. The VAD stops 300 ms into the pause and the engine sees the resumption about 40 ms after the speech restarts, i.e. (P - 260) ms after the stop: 90 / 140 / 340 ms for P = 350 / 400 / 600. Paused clock, 120 turns per row, LLM first token 350 ms (bench/results/speculation-fixtures.md has all rows, the fast LLM and the counters). Extra LLM requests per turn (started - committed):

pauseconverged: no gate / 100 ms / 200 mslagging: no gate / 100 ms / 200 msfinal only
350 ms1.00 / 0.50 / 0.042.04 / 1.48 / 0.320.04
400 ms1.00 / 1.00 / 0.302.28 / 2.21 / 0.580.28
600 ms1.00 / 1.00 / 1.002.98 / 2.90 / 1.480.97

Withdrawn runs follow the same pattern (400 ms, converged: 120 of 120 turns withdrawn without the gate, 36 with 200 ms). The latency of the resumed calls does not suffer: V2V p50 is 1402 ms with speculation off and 1044-1101 ms with it on in every cell (converged 1044 / 1056 / 1057, lagging 1101 / 1079 / 1070 at 400 ms), except lagging interims at 600 ms without a gate or with 100 ms, where nothing commits and it is +32 ms. The turn ends at the last stop anyway. What is left at 350 and 400 ms is the first half's STT final when it arrived before the resumption (log-normal around 150 ms): it is a candidate of its own and the gate does not hold it. At 600 ms the resumption comes after the final in nearly every turn, so the gate saves nothing beyond what on_partial = false already saves (0.97 extra requests per turn is the final's candidate).

What is asserted where: the eval test (the_gate_saves_the_request_of_a_caller_who_resumes_...) asserts that at 350 ms the gated converged mode wastes no more requests and withdrawn runs than final only (the partial adds none), that the gated lagging mode wastes at most a third of the ungated one, and at 400 ms that the gate at least halves both. The proof that a caller who resumes costs no request of any kind is the driver test a_caller_who_resumes_inside_the_gate_costs_no_candidate_at_all (no final involved); in the eval the final-based candidates remain, because they are not gated.

Continuous speech: what the gate costs (same runs, scenario continuous, V2V p50 delta against off, mean head start of a committed run in brackets):

policyconverged: no gate / 100 / 200 mslagging: no gate / 100 / 200 msfinal only
adaptive-141 (154) / -41 (54) / -5 (51, 12% of turns)+2 / +2 / -5+0
fixed 450 ms-181 (181) / -81 (81) / -45 (40)-38 / -38 / -45-40
fixed 700 ms-345 (420) / -309 (320) / -279 (273)-277 / -277 / -279-277

Extra requests per turn in the lagging column: 1.00 / 1.00 / 0.12 in all three policies. Over the websocket in real time (32 turns per row, bench/results/v2v-speculation-gate.md): adaptive, converged interims -165 ms (no gate), -53 ms (100 ms), -3 ms (200 ms); fixed 700 ms, converged -306 ms with a 100 ms gate (head start 303 ms), lagging -262 ms and 1.00 extra requests per turn without a gate, -260 ms and 0.00 with a 200 ms gate.

Reading it.

  • The gate costs what it waits: a converged partial loses min(gate, STT final latency) of its head start (150 ms median here), so a gate at or above the finalize round trip is final-only: the partial is superseded by the final in about 88% of the turns and the rest start a run that is still a hit. Nothing else is lost: a lagging partial never gained anything.
  • It saves exactly the requests of resumptions that come inside the wait: with the VAD of the benchmarks (300 ms stop delay) a 200 ms gate covers pauses up to about 450 ms, a 100 ms gate only pauses up to about 350 ms. Longer pauses are answered by the final's candidate, which only a different mechanism could hold (see the follow-up).
  • On the adaptive policy the whole prize is the finalize round trip, so every millisecond of gate is taken from it: it is only worth using where the STT's finalize time is much longer than the gate (a slow finalize leaves room for a gate of half of it) or where callers pause mid-sentence a lot and the hit rate of the partial is high. Where the finalize is as fast as here, the choice is between no gate (a bet on the caller not pausing) and on_partial = false (no request from a partial at all); a gate of 200 ms is just on_partial = false with extra steps.
  • Defaults stay conservative: gate 0 and speculation off, as recommended above. The recommendation of that section is unchanged (fixed silence of 600 ms or more: on_partial = false; adaptive: on_partial once the live hit rate is known); a deployment that wants on_partial and has callers who pause mid-sentence can set partial_stable_ms to about half of its STT's measured finalize time.
  • Not done, and the next lever if the 600 ms column matters: hold the final-based candidate for a short time after the stop too (the STT final is stable text, but a caller who is merely breathing resumes after it), and offer the partial when the Smart Turn verdict is already "complete".

Reading the engine stages with speculation on. A committed speculation requests the LLM before the turn ends, so turn_end_to_llm_request has no sample for those turns and turn_end_to_first_audio is shorter than the provider time it lists; the report says so under the floors.

Capacity benchmark, Linux with equal container limits (measured 2026-10-13)

Measured before the pacer phase was turned off by default: the production pacer still delayed the first frame of every call by its phase (0-19 ms), as in every capacity table of this document dated 2026-10-13 and 2026-10-14. The effect on the maximum is not measurable (A/B in "Pacer phase: Linux measurements", 350 and 300 calls without the phase, 325 and 350 with it); the p95 columns at low load carry about 16-18 ms of it. The Pipecat and LiveKit rows have no such offset.

These are the framework-only capacity figures: quote them second, labelled as such. Linux, equal limits for both systems, mock providers (no keys, and no models: an energy VAD and a fixed Smart Turn answer, the framework cost only, same caveats as the Windows section below). The capacity with the real Silero VAD and Smart Turn models is far lower and is the figure to quote first: "Capacity with the models" below. The Windows numbers in the section after that stay as the secondary, trend-only data.

Setup (WSL2 Linux kernel 6.6, Docker, mock providers). WSL2 Ubuntu on a 32-core Windows 11 PC, Linux kernel 6.6.87.2-microsoft-standard-WSL2, Docker 29.8.0, release build of brisvox-bench built in rust:1.99.0-slim-bookworm, Pipecat 1.12.0 on Python 3.13 (python:3.13-slim-bookworm, the pinned lock). Each server runs in a container with --cpus=4 --cpuset-cpus=0-3 --memory=4g --memory-swap=4g --network host --ulimit nofile=1048576:1048576; the load generator runs on the host pinned to other cores (taskset -c 4-11, 8 tokio workers) and never used more than 1.7 cores; its largest send lateness over all steps was 37 ms (4 ms at the capacity steps) and no frame went out more than 100 ms late, so no step was generator limited. Commands: bench/capacity_linux.sh, one fresh container per run, 3 runs per configuration, bench/capacity_summary.py for the medians. brisvox is brisvox-bench engine-server with 4 tokio workers (the cgroup quota) and 2 pool threads (half the CPUs, as PoolConfig::default), the production queue of 1024 and the priority line, up to 4096 calls; Pipecat is bot.py --mock-providers --workers 4 (4 uvicorn processes, one event loop each). SLO: p95 V2V <= 950 + 250 = 1200 ms, no failed call, no lost turn. brisvox's ramp was 1, 50, 200, 400, 800, 1200, 1600, 2000, 2400, 3000 bisected to 100 calls; Pipecat's 1, 20, 40, 80, 120, ... bisected to 10 calls.

system (4 CPUs, 4 GiB)runs: max concurrent calls within the SLOmedianfirst step that failedp95 V2V at the maxserver CPU at the maxCPU per callRSS peak at the max
brisvox2100, 2100, 220021002200 or 2300 (p95 1250-1278 ms)1142-1188 ms2.9-3.0 cores1.4 mcores1.84-1.88 GB
Pipecat 1.12.0, 4 processes, defaults70, 70, 707080 (p95 1256-1376 ms; once 32 turns talked over)1064-1097 ms2.86-2.88 cores41 mcores1.58-1.60 GB
Pipecat 1.12.0, 4 processes, OMP_NUM_THREADS=OPENBLAS_NUM_THREADS=MKL_NUM_THREADS=1 ("tuned")70, 90, 909080 or 100 (p95 2.6 s and 9.4 s, or 163 of 400 turns without an answer)1036-1060 ms2.0-2.45 cores27-29 mcores1.57-1.89 GB

With the same 4-CPU container brisvox carried 23 times the calls of tuned Pipecat (2100 against 90) and 30 times those of Pipecat with its defaults (70), on 1.4 against 27-41 millicores per call (19 to 29 times less CPU per call). The one-call V2V is the floor plus 35 ms for brisvox (p50 985 ms) and plus 58 ms for Pipecat (1008 ms). The ramps of the first runs, at the same load (p95 V2V of the answered turns in ms, server CPU in cores):

concurrent callsbrisvoxPipecat, defaultsPipecat, tuned
1996, 0.021028, 0.081027, 0.05
201041, 1.031039, 0.67
401069, 1.951105, 1.25
501001, 0.14
701097, 2.881036, 2.03
801377, 2.90 (fail)2613, 2.15 (fail)
2001001, 0.38
8001002, 1.25
16001019, 2.43
20001151, 2.94
21001142, 2.98
22001278, 3.13 (fail)

Files: bench/results/capacity-{brisvox,pipecat,pipecat-tuned}-linux-2026-10-13.{md,json} (the summaries: a per-run table and the median), ...-run{1,2,3}.{md,json} (the ramps with the environment, the command and every step) and the ablation below.

What the numbers say, and how far to trust them:

  • A ratio of two systems measured by one client, not an absolute capacity. Both run over mock providers with the same fixed latencies, no model inference and no TLS, on a WSL2 virtual machine on a shared desktop. A deployment with the Silero and Smart Turn models and real providers needs more CPU per call than any row here, and the ranking can move with where a system spends its time (neither runs a model here).
  • Repeat spread. brisvox 2100, 2100, 2200 (bisection granularity 100); Pipecat defaults 70 three times; tuned 70, 90, 90 (granularity 10). The runs repeat to one step, in contrast to the Windows desktop, where Pipecat with four processes moved between 110 and 160.
  • How each fails. Pipecat at 80 calls fails at 2.9 of its 4 cores: the kernel spreads the connections over the four processes without balancing them, so the busiest event loop saturates first (not checked per process); p95 climbs to 1.3-2.6 s and turns are talked over or lost, in one tuned run p95 was 9.4 s at 100 calls. brisvox's p95 stays at 1.00 s up to 1200 calls, 1.02 s at 1600 and 1.15 s at 2000, then passes the 1200 ms line between 2100 and 2200 calls at 3.0-3.1 of its 4 cores (the pool and the CFS quota periods are the likely limit; not isolated). At 2400 calls 94 of 9600 turns got no answer and p95 was 1.8 s.
  • Pipecat's thread oversubscription is a configuration effect, reported both ways. With the library defaults numpy's BLAS threads (one pool per process, 4 processes on 4 cores) made Pipecat use 41 millicores per call; pinning them to one thread brought that to 27-29 and the capacity from 70 to 90 (median). The tuned row is the fair baseline for the claim ("brisvox: 23 times"); the defaults row is what python bot.py does out of the box. A single Pipecat process (the default Pipecat setup) and other tunings (a balancer in front, a Smart Turn executor per process) were not run on Linux.
  • RSS is the peak of the process (tree) during the step and carries over from earlier steps (neither server returns memory), so it is not a per-call figure. For scale: brisvox 19 MB with one call and 1.9 GB at 2100 calls (about 0.9 MB per call), Pipecat 580 MB with one call (four processes, a prewarmed pipeline each) and 1.6-1.9 GB at 70-90 calls.
  • Not measured here: native hardware without the WSL2 layer, the models, real providers, other fixtures, interrupting callers, other CPU limits (2 or 8 CPUs), more than one container.

Three defects of the first Linux attempts, found and fixed in this pass (they changed the numbers, so they are recorded):

  1. The file-descriptor limit. Docker gives a container a soft RLIMIT_NOFILE of 1024 and the server does not raise it. The first brisvox run (before --ulimit nofile) stopped at exactly 1017 concurrent calls whatever the CPU left: at 1100 calls 166 calls (83 slots x 2) failed with "connect timed out" while accept() returned EMFILE, at 1200 calls 366 (183 x 2), identically in three runs. A production container with the default has the same cap: docs/DEPLOY.md, "Sizing". The ablation is capacity-brisvox-nofile1024-linux-2026-10-13.{md,json} (NOFILE=1024 bench/capacity_linux.sh: 1000 pass, 1100 fail). This is a cap by configuration, not by the engine, and not part of the table above. Follow-up, done: the server raises its soft limit itself (to the hard limit, at startup).
  2. The resource probe double-counted on Linux. brisvox-bench capacity --pid summed the CPU time and resident memory of every entry of the process tree, and on Linux sysinfo lists each thread as a process of its own that reports the whole process's CPU time and RSS. A one-process server showed "7 processes", about twice its CPU (6.3 "cores" in a 4-CPU container) and an RSS above its memory limit. capacity/probe.rs now skips thread entries (thread_kind()); after the fix the server reads as 1 process and its RSS agrees with the cgroup (memory.peak 605 MiB against 637 MB read for 800 calls). The pass/fail columns were not affected, but the first set of runs was discarded and repeated. Windows has no thread entries, so the Windows files are unchanged.
  3. The script itself: the generator needed --fixtures-dir (the default path is the build machine's), --network host (no userland proxy in the measurement), more generator workers and cores, the nofile limit, a repeat count and a bench build capped at 4 jobs; fixed in bench/capacity_linux.sh.

Reproducing it: SYSTEMS=brisvox RUNS=3 STEPS=1,50,200,400,800,1200,1600,2000,2400,3000 RESOLUTION=100 bench/capacity_linux.sh; SYSTEMS=pipecat RUNS=3 STEPS=1,20,40,80,120,160,240,320 bench/capacity_linux.sh; the tuned row adds NAME_SUFFIX=-tuned SERVER_ENV="-e OMP_NUM_THREADS=1 -e OPENBLAS_NUM_THREADS=1 -e MKL_NUM_THREADS=1"; then python3 bench/capacity_summary.py bench/results DATE. On a native Linux host the same script applies; expect different absolute numbers.

Capacity with the models (measured 2026-10-13)

Measured before the pacer phase was turned off by default (phase on for brisvox); the 2026-10-14 A/B with the phase off is in "Pacer phase: Linux measurements" below: the same layout reached 350 and 300 calls.

Final, quiet host (2026-10-13 23:00; quote these). Same container and SLO as below, nothing else running on the machine, brisvox runs interleaved by layout:

systemrunsmedianat the max
brisvox, Smart Turn executor (default)325, 325, 325325p95 957-1185 ms, 2.5-2.6 cores, RSS ~425 MB
brisvox, shared pool (--turn-threads 0)300, 275, 300300p95 985-1125 ms, 2.3-2.5 cores, RSS ~380 MB
Pipecat 1.12.0 with its models, tuned20, 15, 2020p95 843-960 ms, ~1 core, RSS 3.0-4.3 GB (PSS 3.0-4.2 GB, see below); fails at 20-25 (memory: connection resets at the 4 GiB limit)

brisvox with the models: about 16 times the calls of Pipecat in about a tenth of the memory. The busy-host runs below (200 and 250) were depressed by builds sharing the CPUs.

LiveKit Agents (measured 2026-10-14; bench/livekit_baseline/README.md). LiveKit Agents 1.8.7 (Silero VAD and the local audio turn detector v1-mini, the session defaults; a process per job) over the same mock providers and in the same container (--cpus=4 --memory=4g), against a LiveKit server 1.13.9 in a container of its own (not in the budget: 0.44-0.47 cores, 276-294 MB at the maximum), with the load generator as 22-25 WebRTC room participants (Python SDK, 8 processes), the same fixture, ramp and SLO (p95 V2V <= 1200 ms, no failed call, no lost turn), 3 runs:

LiveKit Agents 1.8.7runsmedianat the max
tuned (turn-detector unlikely_threshold 0.05, load_threshold 1.0)22, 23, 2523p95 1171-1192 ms, 2.1-2.2 cores (about 90 mcores per call), 2.9-3.3 GB (PSS)
defaultsnone0p95 2.81-2.89 s at one call
  • The defaults fail at one call: the turn detector gives the two fixture utterances an end-of-turn probability (0.32 and 0.35) just under its English threshold (0.36) on about 45 % of the turns, so the session waits max_delay (2.5 s) and V2V is 2.8 s on those turns. The tuned variant lowers the threshold so the verdict does not hold the turn; the model still runs on every turn, so its cost is in the numbers.
  • The one-call V2V is 1130-1140 ms (floor 950 ms): about 100 ms of the extra is the WebRTC input path, so 60-70 ms of the 250 ms allowance is left at one call. The maximum is where p95 crosses 1200 ms or calls get no agent (all failed calls were second-round calls, a burst of job starts and exits; probable cause not verified), at 2.1-2.2 of 4 cores and 2.9-3.3 of 4 GiB.
  • Memory is counted as PSS: the job processes are forked from a preloaded server and share the model pages, a plain sum of VmRSS would be 18 GB for 22 calls. CPU is the container's cgroup total (finished job processes included).
  • Against the rows above: brisvox 200-325, Pipecat (tuned) 20, LiveKit (tuned) 23 calls; about 90 mcores per call for LiveKit against 8-9 (brisvox) and 70 (Pipecat) with the models. The WebRTC path, the SFU and the process per call are the design under comparison; the threats to validity (second-round failures, p95 close to the limit, host noise, unchanged UDP buffer) are in the README of the baseline.

Memory counted alike: PSS for all three (measured 2026-10-14). The memory figures above were counted differently: brisvox is one process (its RSS), Pipecat's four worker processes were summed as RSS (shared library and model pages once per process) and LiveKit's job processes as PSS. brisvox-bench capacity --pid now also reads Pss from /proc/<pid>/smaps_rollup for every process of the tree (root is needed to read another user's processes; capacity_linux.sh stops otherwise) and records it next to the RSS sum: pss_peak_bytes and pss_before_bytes per step in the JSON, a "target PSS before / peak (MB)" column in the markdown (parser tested in capacity/probe.rs). Pipecat's tuned ramp with the models was run again, 3 runs, MODELS=1 SYSTEMS=pipecat NAME_SUFFIX=-q-tuned, the steps (1, 5, 10, 15, 20, 25, 30, 40) and resolution (5), container, mocks and environment variables of 2026-10-13, each run after a quiet-host gate (no cargo/rustc/link.exe on Windows, WSL 1-minute load below 1.0; the gate result is in each run's note): capacity-pipecat-q-tuned-models-linux-2026-10-14*, a brisvox cross-check in capacity-brisvox-q-pss-crosscheck-models-linux-2026-10-14* (not part of the chart series).

max calls (runs)RSS sum at the maxPSS at the maxPSS per call (baseline included)
Pipecat 1.12.0, tuned, 4 workers (6 processes incl. launcher/parent)20, 20, 15 (median 20; 2026-10-13: 20, 15, 20)4344, 4136, 3230 MB4190, 3981, 3073 MB210, 199, 205 MB
LiveKit Agents 1.8.7, tuned (2026-10-14 runs, 47-53 processes)22, 23, 2518012, 18692, 20232 MB2935, 3023, 3269 MBabout 130 MB
brisvox cross-check (1 process, default layout, one run)350432 MB429 MB1.2 MB
  • Pipecat: the RSS sum overstates the PSS by about 150 MB at every step (3.5-4 % at the max; one call 887 vs 736 MB, 20 calls 4344 vs 4190 MB in run 1), so the old comparison was fair to about 4 % for Pipecat; the large gap was LiveKit's (a plain sum 6 times its PSS). The Pipecat gap does not grow with the calls, so what its four workers share is a fixed ~150 MB (not investigated further).
  • brisvox: one process shares nothing, RSS and PSS differ by 3 MB (0.7 %) in the cross-check at 350 calls. The cross-check found 350 where the 2026-10-13 runs gave 325 (one run of a later build, the earlier runs failed at 350 and were bisected to 325, this one passed 350 and failed at 375); the quoted 325 and ~425 MB stay. The memory chart uses PSS for Pipecat and LiveKit and the single process' RSS (= PSS within 1 %) for brisvox.
  • Pipecat's capacity run for this section reproduces the 2026-10-13 result: the same multiset of runs (20, 20, 15 vs 20, 15, 20), median 20; the capacity chart now reads the 2026-10-14 files (the method is identical; the only change is the extra PSS reading by the sampler on the host, outside the container's CPU limit). Runs 1 and 2 failed at 25 calls (8 and 16 of 50 calls failed with connection resets; the PSS at that step was 4.3 GB against the container's 4.29 GB limit, as in the 2026-10-13 runs), run 3 failed at 20 (5 of 40 calls; PSS 4.3 GB).
  • A first launch of the series as a normal user read no PSS (the - columns); it was stopped and repeated as root. Those partial files were not kept.

Linux, equal containers (2026-10-13, the figures to quote). MODELS=1 bench/capacity_linux.sh, WSL2 kernel 6.6, Docker 29, --cpus=4 --cpuset-cpus=0-3 --memory=4g, mock providers, both systems with their real models (brisvox: Silero on tract + Smart Turn v3.2 on ORT, 4 tokio workers, 2 pool threads; Pipecat 1.12.0: its Silero VAD + LocalSmartTurnAnalyzerV3, 4 processes, OMP/OPENBLAS/MKL_NUM_THREADS=1), SLO p95 V2V <= 1200 ms, 3 runs each:

systemruns (max calls within the SLO)medianat the maxper call
brisvox250, 200, 125200p95 831-915 ms, 1.6-2.0 cores, RSS 300-390 MB~8-9 mcores, ~1.5 MB
Pipecat (tuned)20, 20, 520p95 810-912 ms, 1.2-1.4 cores, RSS 3.6 GB~70 mcores, ~180 MB
  • brisvox fails before its CPU is used up (2.0-2.4 of 4 cores at the failing steps): the 2-thread inference pool saturates (Smart Turn ~25 ms per analysis on ORT with 2 intra-op threads) -> the Smart Turn threading section below.
  • Pipecat is memory-bound: 110-140 MB per call with its models; at 25-30 calls the container reaches 4 GiB, workers are killed and restarted (connection resets, CPU reading drops to ~0; 4 restarts in runs 1 and 2).
  • The low runs (brisvox run 3: 125, Pipecat run 3: 5) coincided with heavy cargo builds on the shared Windows host (WSL2 shares its CPUs); re-measure on a quiet or native Linux host before quoting single numbers. Files: bench/results/capacity-{brisvox,pipecat-tuned}-models-linux-2026-10-13*.

The Windows numbers below are the first, provisional measurement.

Measured so far: Windows only, provisional. The Linux run could not be started from the session that wrote it (the sandbox refused to run wsl/docker; see "Reproducing" below), so the figures here are the Windows x86_64 development machine (32 logical cores), a release build, the engine server with 4 tokio workers and 2 pool threads like the 4-CPU container, but without a CPU limit (the server may use any core; it used at most 3.1 of them). They show the shape (the cost per call and what saturates) and not the Linux capacity; docs/LATENCY.md's rule that comparisons come from Linux stands.

run (3 repeats, ramp 1, 25, 50, 100, 200, 300, ... bisected to 25)max calls within the SLOfirst failing stepp95 V2V at the maxserver CPU at the maxRSS peak at the max
2 pool threads (the 4-CPU default)200, 200, 200225 (p95 1.46-1.80 s)1009, 885, 885 ms2.40, 2.26, 1.99 cores280 MB
4 pool threads (one run, ablation)175200 (p95 1.76 s)889 ms3.21 cores348 MB

Files: bench/results/capacity-brisvox-models-windows-2026-10-13-run{1,2,3}.{md,json}, capacity-brisvox-models-pool4-windows-2026-10-13.{md,json}, and *.server.log next to each (the pool's per-period stats, the Smart Turn / VAD split and the run's totals).

What the numbers say:

  • About 12 millicores per call with the models (1.18 cores at 100 calls, 2.40 at 200), against the 1.4 millicores of the no-model Linux run: the two models are most of a call's CPU. By the pool's own accounting at 200 calls: ~50 pool jobs per second per call (the VAD windows, batched per 20 ms tick) at ~105 microseconds each, and about one Smart Turn analysis per call per 9 s (17-23 analyses/s at 200 calls) at 24-31 ms each of wall time on the pool thread. So the pool (2 threads) was busy 44-52% at 200 calls with Smart Turn about 55-60% of its run time and the VAD windows and feature extraction the rest; the failing steps were pool saturation (busy 99%, waits of 17 ms per job in a failing step), not the websocket, the transport or the tokio workers.
  • Smart Turn is the larger model cost per call, the VAD the larger count. One Silero window costs ~0.1 ms, 50 times a second per call, about 0.5% of a core per call; one Smart Turn analysis is 25 ms (more than 200 windows) and runs about 0.11 times a second per call, the same order, and it also holds a pool thread for its whole duration, which is what delays other calls' VAD windows when analyses bunch up.
  • More pool threads did not help here (4 threads: 175 calls, and more CPU per call, 18 against 12 millicores). The likely cause is ONNX Runtime's intra-op worker threads, which spin while waiting for work (2 per Smart Turn session, 4 sessions) and burn CPU the stub never used; disabling spinning (session.intra_op.allow_spinning=0) was the first thing to try; a later A/B tried it (section below): it saves CPU but lowers the Windows capacity, so it is not the cause.
  • Expected on the 4-CPU container, not measured: 4 cores at 12 millicores per call is about 330 calls by CPU alone and the 2-thread pool saturated at 200-225 on this machine, so the honest order of magnitude is a few hundred calls, not 2100. The Linux run since gave a median of 200 (Linux table at the top of this section).

Pipecat with its models. bot.py --mock-providers --mock-models real runs Pipecat's Silero VAD (brisvox's pinned silero_vad.onnx when it is in BRISVOX_MODELS_DIR) and its LocalSmartTurnAnalyzerV3 (the CPU model bundled with Pipecat, --smart-turn-threads 1), over the same mock providers. MODELS=1 SYSTEMS=pipecat NAME_SUFFIX=-tuned SERVER_ENV="-e OMP_NUM_THREADS=1 -e OPENBLAS_NUM_THREADS=1 -e MKL_NUM_THREADS=1" bench/capacity_linux.sh runs it in the equal 4-CPU container. Measured since on Linux: median 20 calls, memory-bound (Linux table at the top of this section).

Reproducing it (Linux). MODELS=1 RUNS=3 SYSTEMS=brisvox STEPS=1,25,50,100,200,300,400,600,800,1000,1400 RESOLUTION=25 bench/capacity_linux.sh (builds --features models in rust:1.99.0-slim-trixie: the prebuilt static ONNX Runtime needs glibc >= 2.38; mounts models/ read-only; leaves capacity-brisvox-models-linux-<date>-run<N>.{md,json,server.log}), then the same with SYSTEMS=pipecat NAME_SUFFIX=-tuned ... STEPS=1,5,10,20,30,40,50,60,70,80,100 RESOLUTION=5, then python3 bench/capacity_summary.py bench/results <date>. Windows: brisvox-bench capacity --sim --models models --pool-threads 2 --server-workers 4 --stats-every-ms 10000 ... with a build of cargo build --release -p brisvox-bench --features models.

Unloaded floor: brisvox vs Pipecat with the same mocks (measured 2026-10-14)

Question: with the same mock providers (STT final 150 ms after the VAD stop, LLM first token 350 ms, TTS first byte 150 ms) the one-call step of the Linux capacity runs with the models shows Pipecat 1.12 at p50 787 ms and brisvox at 805 ms (capacity-pipecat-q-tuned-models-linux-2026-10-13-run1.json, capacity-brisvox-q-turnexec-r1-models-linux-2026-10-13-run1.json, steps[0].v2v). Where do the 18 ms go?

Answer. Two effects of a few tens of milliseconds that nearly cancel, neither of them a stage of the engine: (1) the VAD stop is 40 ms later in brisvox on the second turn of the fixture (20 ms on the first): the same nominal 100 ms hangover is 4 Silero windows (128 ms) in brisvox and 3 windows (96 ms) in Pipecat; (2) Pipecat's output path is 21 ms slower than brisvox's (its streaming resampler gives no audio for the first 40 ms TTS chunk, so the first frame waits for the second chunk). Net: brisvox +40 - 21 = +18 ms on the second turn. The capacity step has 2 calls x 2 turns, and the nearest-rank p50 of 4 samples is the second smallest, which is the second turn of each call, so its 805.3 vs 787.1 ms is exactly this row.

Method. WSL2 (kernel 6.6), Docker, the containers of the capacity runs (--cpus=4 --cpuset-cpus=0-3, --network host, the real models, Silero and Smart Turn v3.2, on both sides), the en_two_utterances fixture, one call at a time, 15 calls (30 turns) per configuration, the servers up together and the calls interleaved, the client (brisvox-bench v2v --url) pinned to other cores. brisvox's stage times are its own turn timeline (BRISVOX_BENCH_TURN_LOG=1 on brisvox-bench engine-server prints one turn-log line per turn: every timeline event in ms relative to the end of speech); Pipecat's come from bench/pipecat_baseline/stage_probe.py (an observer on the real pipeline, the same bot.py). The VAD stop in audio time is the number of input frames seen when Pipecat reported the stop (logged), and for brisvox the fixture's Silero probabilities run through its stop rule (checked against the live runs below).

Means over 15 calls, second turn (the fixture's end of speech at 13.43 s of the stream):

stagebrisvoxPipecatbrisvox - Pipecat
end of speech -> VAD stop decision (audio time, 20 ms frames)150 ms110 ms+40
VAD stop -> STT final (mock 150 ms + timer)151.4151.2+0.2
STT final -> end of turn (the Smart Turn verdict is already in)0.00.2-0.2
end of turn -> LLM first token (mock 350 ms)351.4351.0+0.4
LLM first token -> TTS first audio (mock 150 ms)151.1151.10.0
TTS first audio -> first frame handed to the websocket0.121.1-21.0
websocket -> client arrival (V2V minus the rows above)1.83.0-1.2
V2V at the client805.8787.6+18.2

First turn of each call (end of speech at 6.67 s): brisvox 818.1, Pipecat 808.3 (+9.8). The VAD row is +20 ms (150 vs 130), the output row -21 ms, and brisvox pays its pacer phase (below), a per-call offset of 0-19 ms on the first bot frame of the call (mean 10.9 ms over these 15 calls). Over both turns brisvox is 811.9 and Pipecat 798.0 (+13.9).

Why 4 windows against 3. Silero runs on 512-sample windows (32 ms) in both. brisvox's VadDetector confirms the stop at the first silent window whose end is at least min_silence (100 ms) after the end of the last speech window: 3 windows are 96 ms, not enough, so 4 windows = 128 ms. Pipecat counts round(stop_secs / 0.032) = round(3.125) = 3 windows = 96 ms. The same model file, the same threshold (0.5), the same 20 ms input frames. The fixture's speech probability falls from 0.99 to 0.003 within one window, so the difference is exactly one window, which the 20 ms frame grid turns into 20 ms on the first utterance and 40 ms on the second (32 ms on average over arbitrary audio). Pipecat's volume gate (min_volume 0.6) changes nothing on this fixture (the same stops with it off). Confirmed in both directions on Linux, 15 calls each:

configurationfirst turnsecond turn
Pipecat stop_secs 0.1 (as mapped: 3 windows)808.3787.6
Pipecat --vad-stop-ms 128 (4 windows)828.8 (+20.5)828.5 (+40.9)
brisvox min_silence 100 ms (the default: 4 windows)818.1805.8
brisvox min_silence 90 ms (3 windows)797.9 (-19.4)765.7 (-40.0)

(The 90 ms row is compared with 817.3 / 805.7, the 100 ms row measured beside it.) With the same effective hangover brisvox is the faster system either way: 781.8 against 798.0 ms over both turns at 3 windows, 811.9 against 828.7 at 4 windows (16 to 17 ms on average, 22 ms on the second turn at 3 windows).

Why Pipecat's output path is 21 ms slower. stage_probe.py shows the first TTS audio frame reaching the transport and the websocket write 21.1 ms later (20.5-22.2 over 30 turns). The mock TTS sends 40 ms chunks 20 ms apart; Pipecat 1.12's create_stream_resampler (soxr, 24 kHz to the call's 16 kHz) returns 0 samples for the first 40 ms chunk and 1100 samples (69 ms) after the second, so the first frame leaves one chunk interval (+ 1 ms) later. At 8 kHz it takes four chunks (measured: 0, 0, 0, 1100 samples). brisvox hands the first 20 ms frame of the first chunk on in at most 0.1 ms. This is a property of Pipecat's resampler on this chunk shape (a provider with other chunk sizes changes it); the mocks' chunk shape is the same on both sides by design.

Where brisvox's own overhead is. After the VAD stop brisvox spends at most 0.1 ms in the engine's stages and 1.8 ms from the websocket write to the client (second turn), on top of the mocks' nominal 150 + 350 + 150 = 650 ms and three timer overshoots of about 1 ms each. The Smart Turn verdict is in before the STT final in both systems (brisvox: 0.0 ms between the final and the end of turn; Pipecat's analysis takes 26-52 ms and its stop strategy awaits it inline, still behind the 150 ms final), so the turn-decision wait is 0 on both sides in this setup (with a real STT whose final comes sooner than the model it would not be).

The pacer phase (first turn only; removed from the default, see below). OutputPacer released the first frame of a session a per-session offset after its arrival, phase_offset(seed) in [0, 20 ms) (spreads the timer wake-ups of co-started sessions); the transport session seeds it with the session id (the call number in the bench). The first-turn V2V of a call followed it one to one: over the 15 calls V2V = 807.4 ms + 0.99 x phase (r = 0.98). It cost the first answer of each call 9.5 ms on average (0 to 19) and nothing afterwards. Pipecat has no such offset.

What was not changed, and the options. None of this is a defect in the engine's stages, and each lever is a trade-off, so no default moved:

  1. VAD hangover in windows. VadParams::default().min_silence of 90 ms (3 windows; or a rule that rounds to the nearest window like Pipecat) lowers V2V by 20 to 40 ms (30 on average over the two utterances, deterministic: the 90 ms row). The cost: a pause of 96-128 ms inside speech then ends the VAD segment, which sends a Finalize to the STT, runs a Smart Turn analysis and can end the turn early when the model says "complete" (the turn machine already takes a resumption back). On the 7 fixtures (17 read utterances, fixtures/ and fixtures/large/) no pause fell in that band (a 64 ms hangover makes one extra stop; 96, 100, 128 and 160 ms none), but conversational speech has more such pauses than read speech. Use 90, not 96: the detector compares wall-clock window stamps, so 3 windows measure 95.9-96.1 ms and a 96 ms limit flips at random (measured: 11 of 15 second turns stayed at 4 windows). interruption.vad_hangover_ms (100) and the early analysis' fall_delay follow min_silence.
  2. Pacer phase. Done (below): the first frame leaves at once; the phase is an opt-in.
  3. The comparison. The mapping stop_secs = 0.1 is nominally equal and effectively 3 windows against 4. For a table that names an equal effective hangover run bot.py --vad-stop-ms 128 or brisvox at 90 ms. The 21 ms of Pipecat's resampler is part of Pipecat as shipped and stays in the comparison.

The first frame of an answer leaves at once (2026-10-14). The pacing clock now starts at the first audio frame after an idle period (session start, an interrupt, a finished turn), so the first frame of a call's first answer leaves the instant the engine hands it over, with no timer, and the 20 ms grid runs from there. The per-session phase is kept as an opt-in (OutputPacer::with_first_frame_phase, SessionConfig::first_frame_phase, both off by default): it bought spreading the timer wake-ups of sessions started in the same millisecond, which only matters for a synchronised start (a greeting at call start, a load generator); every later turn already started at once, and the grid of each turn is anchored at its own first frame, which in real traffic arrives at a random time. Considered and not done: keeping the phase as a lead on the grid (first frame at once, the following ones up to 19 ms early). It would keep the spreading, but a source delivering in real time would then be "late" by the lead on every frame (a phantom lag in PacerStats::worst_lag) and the position the transport reports (audio released) would run up to a frame ahead of the clock. Unit tests (paused clock, crates/brisvox-audio/tests/pacer.rs): the first frame of 256 seeds leaves at its arrival with no timer, a first answer after 2 s of idle is not delayed, an interrupt flushes and the next turn starts at once; the earlier pacer tests run with the phase on. No per-frame cost: one bool read on the first frame, no allocation.

Re-measured on Windows (release build, v2v --sim, en_two_utterances, 2 x 15 calls plus the memory calls per configuration, 76 first and 76 second turns each, the two configurations interleaved on one binary; BRISVOX_BENCH_PACER_PHASE=1 is the bench-only switch for "before"):

turnbefore (phase on) mean / p50after (default) mean / p50change of the mean
first turn of a call1015.8 / 1011.9 ms1001.6 / 1003.5 ms-14.2 ms
second turn (control, no phase before or after)1001.0 / 999.9 ms1002.2 / 1002.1 ms+1.2 ms

The expected change is the mean phase, 9.5 ms; the measured -14 ms is inside the noise of this machine (standard deviation 11-14 ms per turn, 15.6 ms timer tick, other work running), so read it as "the first turn no longer sits above the second" (it was +14.8 ms above, now -0.6 ms), not as a precise number. The Linux figure is the one to quote: the first-turn offset found earlier (0-19 ms, mean 9.5 ms) is gone by construction (the unit test shows the first frame at its arrival), and it is measured below.

Pacer phase: Linux measurements (2026-10-14). Both tests ran on the quiet host (the gate of bench/capacity_linux.sh before every run: no cargo/rustc/link.exe on Windows, WSL 1-minute load below 1.0; the load each run saw is in its note), in the containers of the capacity runs (--cpus=4 --cpuset-cpus=0-3 --memory=4g --network host, the real models, brisvox-bench engine-server), the client pinned to cores 4-11. The switch is the bench-only BRISVOX_BENCH_PACER_PHASE=1 (read by the engine server for every call; docker run -e), the same binary and the same host for both sides.

(a) First turn, one call at a time. The first-turn harness (brisvox-bench v2v --url, fixture en_two_utterances, --pause-ms 200): two servers up together (phase off, phase on), one warm-up call each (discarded), then 15 calls each, one at a time and interleaved (off/on and on/off alternating), gate: load1 0.31.

V2V at the clientphase off (default)phase on (before the change)on - off
first turn of a call: mean / p50805.7 / 805.8 ms817.0 / 816.3 ms+11.2 ms (standard error 1.4)
first turn: sd, min .. max0.8, 804.7 .. 807.75.4, 808.4 .. 825.0
second turn (control: no phase either way) mean805.5 ms805.8 ms+0.3 ms

With the phase off the first turn sits on the second turn (805.7 against 805.5 ms) and has the spread of any turn (sd 0.8 ms); with the phase on every call's first turn is 2.7 to 19.3 ms above that. This confirms the earlier investigation (a mean offset of 9.5 ms, 0.99 ms of V2V per ms of phase): the change is 11 ms (this sample's phases averaged somewhat above 9.5) on the first answer of every call on Linux, 805.7 ms against 817.0 ms.

(b) Capacity A/B with the models, phase on against off. MODELS=1 SYSTEMS=brisvox RUNS=1 QUIET_GATE=1, steps 1, 50, 100, 200, 250, 300, 350, 400, 500, bisected at 25, SLO p95 V2V <= 1200 ms and no failed call; run as root for PSS. Four runs in two pairs, the second pair in the reverse order (to expose a first-run or thermal bias): A = off then on, B = on then off. The question: does the synchronised start of many sessions in the same millisecond (no phase) cost anything under load?

runphasemax callsp50 / p95 / p99 at 250 callsat 300 calls (p50 / p95)at the max: p95, coresfirst failing step (p95)
A1off350805 / 807 / 808 ms808 / 818 ms925 ms, 2.50375 (1317 ms)
A2on325806 / 824 / 826 ms812 / 833 ms904 ms, 2.39350 (1416 ms)
B1on350806 / 824 / 826 ms811 / 833 ms922 ms, 2.48375 (1551 ms)
B2off300805 / 807 / 807 ms811 / 965 ms965 ms, 2.35325 (1465 ms)
  • Not measurable. Phase off: 350 and 300 calls (mean 325); phase on: 325 and 350 (mean 337.5). The runs differ by one or two bisection steps (25 calls) in both directions, which is the run-to-run spread of this host (the same layout gave 325, 325, 325 on 2026-10-13 and 350 in the PSS cross-check, failing at 375). The knee is the pool (CPU 2.4-2.6 of 4 cores at the failing steps), not the pacer's wake-ups.
  • Below the knee nothing separates the two except the phase's own cost: p50 805-808 ms both ways, p95 and p99 about 16-18 ms higher with the phase on (806-809 ms off, 816-825 ms on at steps 1-250), which is the 0-19 ms offset of the first answer of each call showing in the tail (and the (a) result under load).
  • No late frame at any step of any run (0 of 0.4-0.6 million timed frames at the larger steps; the client's largest send lateness 2-19 ms, a harness figure).
  • Wake p99 is not available: the pacer's worst_lag is a per-session counter that the bench server does not export. The pool's per-window VAD wait (the nearest figure) is in the *.server.log files.
  • The auditor's mitigation (first frame at once, a session-seeded offset applied from the second frame on) was therefore not implemented: there is no cost to remove, and it would add a tuned offset to every call's second frame. A synchronised start of very many sessions (a greeting at call start from a load generator) is the case where first_frame_phase stays the switch; the capacity ramp starts its calls over spread_ms = 4000 ms, so it does not start them in the same millisecond, and a test that does is not part of this measurement.

Files: bench/results/capacity-brisvox-phase-{off,on}-models-linux-2026-10-14-run1.{md,json,server.log} (A1, A2) and capacity-brisvox-phase-{on,off}-b-models-linux-2026-10-14-run1.* (B1, B2); the first-turn reports are in bench/results/v2v-pacer-phase-linux-2026-10-14/. Reproduce the capacity runs (root, WSL; NAME_SUFFIX is -phase-off / -phase-on):

MODELS=1 SYSTEMS=brisvox RUNS=1 QUIET_GATE=1 RESOLUTION=25 STEPS=1,50,100,200,250,300,350,400,500 \
  NAME_SUFFIX=-phase-on SERVER_ENV="-e BRISVOX_BENCH_PACER_PHASE=1" bench/capacity_linux.sh

Windows. The same test on Windows (15.6 ms timer tick, 70 turns per system, with the models): both systems at 838.5 ms. The tick rounds Pipecat's 20 ms resampler wait up to 31 ms and bunches the client's frames, so the 20/40 ms VAD steps do not show (brisvox at min_silence 96 ms moved only -6 ms there; at 32 ms -55 ms). Do not compare differences under about 30 ms on Windows.

Reproduce (Linux; BRISVOX_BENCH_VAD_MIN_SILENCE_MS=90 on the server is the bench-only override of the detector's min_silence for the 3-window row):

BRISVOX_BENCH_TURN_LOG=1 brisvox-bench engine-server --bind 127.0.0.1:8100 --models models --max-calls 8 &
brisvox-bench v2v --url ws://127.0.0.1:8100/call --fixtures en_two_utterances --runs 5 --pause-ms 200
PCTRACE_FILE=/tmp/pc.txt python bench/pipecat_baseline/stage_probe.py --mock-providers --mock-models real --port 8102 [--vad-stop-ms 128]

Capacity on Railway with the real provider path (measured 2026-10-15)

The capacity benchmarks above use in-process mock providers. On Railway (4 vCPU / 4 GB per server container) the synthetic calls of the listening test (bench/railway/README.md) go through each system's real provider clients, TLS and wire formats against the fake providers (bench/fake_providers), with Silero and Smart Turn. One run per step; full table and caveats: bench/results/railway-listening-2026-10-15.md.

brisvoxPipecat 1.12 (4 workers)
V2V p95 at the fakes' floor (~860 ms) + 15 msup to 150 callsnever (1.4 s at 5 calls)
V2V p95 under 1.5 sup to 250 callsup to 5 calls (1.7 s at 10)
first sign of trouble300 calls: CPU used up, new dials refused (503), late turns end on the fallback silence10 calls: memory at the limit; 15 calls: workers restarted, calls dropped
container memory at that point0.5 GB3.7 GB

The real provider path costs brisvox more CPU per call than the mocks (about 200 to 250 calls before the tail grows, against 325 with the mocks and the models, "Capacity with the models"); its memory stays near the mock numbers. This run also found the pool-pressure guard refusing calls during bursts of dials on an idle pool; the queue depth now has to stay over its limit for 100 ms (BRISVOX_OVERLOAD_QUEUE_CONFIRM, docs/OPERATIONS.md).

Model CPU per call: Smart Turn threading and the Silero backend (measured 2026-10-13)

Result in one line: the defaults did not change. Turning ONNX Runtime's intra-op spinning off cuts the CPU a Smart Turn analysis costs in a quiet process by 40% and changes no bit of the output, but under the capacity load each analysis occupies its pool thread 12-25% longer and the 2-thread pool saturates at 250 calls where it does not with spinning: the pool, not the CPU, is the limit. Smart Turn stays at 2 intra-op threads with ONNX Runtime's default (spinning on). The knobs now exist (OrtOptions, SmartTurnOrt::load_with_options) and the Linux run decides.

Tool. brisvox-bench model-cpu --models DIR (build with --features models; crates/brisvox-bench/src/model_cpu.rs): the process's CPU time (every thread, so ONNX Runtime's workers and their spinning are in it; sysinfo, accounted per scheduler tick, so every CPU figure is a mean over batches of 20 analyses and the p50/p99 columns are over batch means) next to the wall time of each call, configurations interleaved round by round so a noisy neighbour (a Linux benchmark was running in WSL on this machine) hits all alike. tight = analyses back to back, paced = a sleep after each, with the CPU read after the sleep, so what idle workers burn is counted; a control (the same period with nothing to do) is subtracted from every paced batch. Windows x86_64 (32 logical cores), release, onnxruntime 1.28 via ort 2.0.0-rc.13, a golden case's 8 s window as input, 30 rounds x 20 analyses per cell. Files: bench/results/model-cpu-windows-2026-10-13-run1.md (40 ms sleep) and -run2-gap100.md (100 ms).

Smart Turn, one analysis (run 1; ms):

intra-op threadsspinningCPU tightCPU paced (40 ms sleep)wall p50 / p99 (tight)wall p50 (paced)
1on / off (no workers)34.3 / 34.535.1 / 34.233.1 / 46.633.6 / 33.5
2 (the default)on41.372.319.3 / 25.620.3
2off40.543.420.7 / 26.622.2
3off46.949.117.7 / 39.318.8
4off52.753.916.0 / 28.416.9

Run 2 (quiet machine, 100 ms sleep): 2 threads with spinning 71.1 ms CPU paced (39.5 tight), spinning off 40.6 (38.7); 1 thread 32.3 (32.5); wall p50 paced 20.0 / 21.1 / 32.2 ms. So the spin burn is about 30 ms per analysis, the same at 40 and 100 ms between analyses (it is bounded per analysis, not per second), as much as the analysis itself. 1 thread is 20% cheaper than 2 with spinning off but 50% slower (33 against 22 ms), which breaks the ~10% latency bound; 3 and 4 threads cost 13% and 24% more CPU for 15% and 24% less latency.

Exactness. All 6 configurations ran the 12 golden cases: the probabilities are bit-identical across intra-op 1, 2, 3, 4 and spinning on/off (0 of 12 differ in bits from the first configuration; worst error against the reference 5.96e-8, the golden rounding; 0 verdict differences). It is also a test: probabilities_are_bit_identical_across_session_configurations (crates/brisvox-inference/tests/smart_turn.rs) compares bits across threads 1-4 x spinning on/off and against the 1-thread default the goldens were generated with, through warmed sessions.

Silero, one 32 ms window (CPU per window, mean over batches of 2000; us):

backendrun 1run 2wall p50 / p99
tract (the server's default)615953 / 85
ONNX Runtime (1 intra-op thread, feature silero)10610094 / 172

tract is 40% cheaper per window here, so the default stays (the comparison needs --features models, which now also builds the ONNX Runtime Silero for it). The two backends agree: max |p_tract - p_ort| = 1.2e-6 over 872 fixture windows, 0 windows where the 0.5 decision differs. (The 105 us of the earlier model run and the 50-60 us here are the same model under different load and boost states; the ratio is what carries.)

Capacity at the old maximum, spinning on (the default; BRISVOX_BENCH_SMART_TURN=2:1) against off (2:0), 2 runs each, interleaved on/off/on/off (capacity --sim --models models --pool-threads 2 --server-workers 4 --steps 150,200,250 --rounds 2; bench/results/capacity-t118-spin-{on,off}-run{1,2}-windows-2026-10-13.*; the variable is read by RealModels::load; Windows, no CPU limit, provisional). Per cell: run 1, run 2.

callsCPU per call on / off (millicores)p95 V2V on (ms)p95 V2V off (ms)Smart Turn ms per analysis onoffpool wait per job onoff
15012.0, 11.5 / 9.8, 9.1844, 844845, 84621.1, 19.723.6, 22.21.2, 1.0 ms1.2, 1.2 ms
20010.9, 11.7 / 10.0, 10.0847, 866856, 86420.4, 22.725.1, 25.02.3, 3.2 ms3.4, 3.8 ms
25010.9, 10.7 / 10.3, 11.2989, 10491966, 175123.3, 23.727.7, 25.85.4, 5.6 ms8.5, 7.3 ms

Spinning off does save 12-19% of the process CPU per call at 150-200 calls (the quiet-machine saving survives, diluted by the VAD windows and the rest), but a Smart Turn analysis occupies its pool thread 12-25% longer (the intra-op worker is woken by the OS for each parallel region instead of already running), the VAD windows wait longer behind it, and at 250 calls both off runs failed the SLO (p95 1.75 and 1.97 s) where both on runs passed (0.99 and 1.05 s; limit 1.2 s). The earlier table failed at 225 with spinning on and 250 passes here (the machine was quieter), so only the on/off order inside this interleaved set is evidence, not the absolute knee. The earlier hypothesis "spinning is why more pool threads did not help" is therefore not supported as a way to more capacity: spinning costs CPU, but it buys pool occupancy, and the pool is the bottleneck on a machine with spare cores. In a container where CPU is the hard limit it may go the other way.

Linux confirmation to run (bench/capacity_linux.sh passes SERVER_ENV into the container): MODELS=1 ... SERVER_ENV="-e BRISVOX_BENCH_SMART_TURN=2:0" NAME_SUFFIX=-spinoff against the plain run, interleaved, same steps. Adopt spinning off (flip DEFAULT_INTRA_OP_SPINNING) only if the 4-CPU container's capacity and CPU per call improve there. Not done: a server env knob for the model threads (the server has none today; OrtOptions is the hook if the Linux numbers want one), and a single-thread session on a bigger pool (an analysis 50% longer needs more pool threads).

Smart Turn off the VAD's critical path (measured 2026-10-13, Windows)

Result in one line: Smart Turn analyses now run on an executor of their own, and the capacity with the real models on the 4-CPU-sized server (4 tokio workers, 2 pool threads) goes from 200-250 calls to 300 within the SLO, with nothing changed at low load. The cause was structural: an analysis is 20-25 ms on an inference-pool thread, a Silero window is 60 us, and every analysis made the windows of other calls wait behind it (jobs are not preempted; the priority lines only order the queue, not a running job). A window that waits stretches the VAD stop, the Smart Turn start and with them the turn.

Design. brisvox_engine::offload::start_smart_turn_executor(threads, clock) starts a second InferencePool (threads brisvox-turn-<i>, queue 32 jobs per thread, reject newest) that runs only the analyses and the model's warm-up (SessionBuilder::with_smart_turn_offload, EngineRunner::with_smart_turn_offload). Overflow policy: a refused or timed-out analysis is a failed analysis (smart turn analysis failed, counted) and the turn ends at its fallback deadline, the degradation the turn driver already uses for pool trouble; the VAD's pool is never involved, so a flood of analyses can no longer lose a VAD stream. Epochs, cancellation (a dropped request skips an unstarted job), per-call isolation and the timeout are the pool's, unchanged. brisvox-server and the benchmark's engine target (RealModels, the engine-server child of capacity --sim --models) build it with that one function. Knob: BRISVOX_SMART_TURN_THREADS (server) and --turn-threads N (bench), default = the VAD pool's threads (half the cores, 2 on the 4-CPU container), 0 = the old layout (the analyses share the VAD pool). Without real models (the mock benchmarks' Smart Turn stub) there is no executor, so those results stand as measured. Smart Turn outputs are bit-identical (the session configuration is untouched; probabilities_are_bit_identical_across_session_configurations passes). Admission control still reads the VAD's pool: an overloaded executor degrades end-of-turn quality, it does not refuse calls.

Options weighed. (b) A lane rule inside the one pool (analyses may take at most N-1 threads) gives the same VAD guarantee but ties the analyses' throughput to the VAD pool's size and keeps warm-up sizing, metrics and the overflow policy mixed; (a) separates them and lets the thread count follow the measured analysis load. (c) Sizing: pool 2 + executor 2 is 4 pool threads on 4 cores, the VAD ones mostly idle (the analyses' ONNX Runtime intra-op workers come on top). The executor's size, measured (p95 V2V in ms, the cells of 1-3 runs; --turn-threads):

executor threads200 calls250300350note
0 (old)889, 1390, 8563514, 2057, 16573656, 31603762, 3798
11331 (p50 950)one thread is 55% busy at 200 calls; verdicts queue when turns end together
2 (default)845, 837, 841838, 840, 841840, 838, 8381592, 1341, 1266
3122217344.4-5.3 cores and 30-34 ms per analysis: oversubscribed, worse

A/B, old (--turn-threads 0) against new (default), interleaved old/new/old/new, 2 runs each (capacity --sim --models models --pool-threads 2 --server-workers 4 --keep-going --steps 200,250,300,350; Windows, no CPU limit, the generator on the same machine: provisional; files bench/results/capacity-t119-{old,new}-run{1,2}-windows-2026-10-13.*, the sizing runs capacity-t119-sizing-*). Per cell run 1, run 2:

callsp95 V2V old / new (ms)lost turns old / newCPU per call old / new (millicores)mean wait of a VAD window old / newSmart Turn ms per analysis old / new
2001390, 856 / 837, 8410 / 011.8, 11.9 / 11.8, 10.86.2, 6.3 ms / 0.94, 0.86 ms24.8, 25.0 / 23.1, 22.8
2502057, 1657 / 840, 8410 / 011.8, 11.2 / 10.4, 10.67.2, 7.1 ms / 0.96, 0.89 ms25.8, 25.2 / 22.0, 23.0
3003656, 3160 / 838, 8380 / 012.1, 11.9 / 9.9, 9.78.3, 8.4 ms / 1.03, 0.96 ms26.3, 26.1 / 22.5, 22.9
3503762, 3798 / 1341, 126697, 109 / 011.3, 10.9 / 10.9, 10.28.0, 8.2 ms / 1.06, 1.01 ms27.2, 27.0 / 23.5, 24.0

The SLO (p95 <= 1200 ms) holds up to 200 calls (old, one of two runs) against 300 (new, both runs); at 350 the new layout is CPU-bound (3.6-3.8 of the machine's cores in the server, the generator beside it) with every turn still answered, the old layout loses ~100 turns. A VAD window waited 6-8 ms on average (max 59-77 ms) behind the analyses and now waits 0.9-1.1 ms (max 7.7-9.4 ms; most of that is the thread wake-up on Windows, 0.05-0.1 ms at 1-10 calls). CPU per call is the same or up to 20% lower. An analysis takes 2-3 ms less wall time because its two intra-op workers no longer compete with the VAD jobs. The server CPU limit cannot be set on Windows: the Linux confirmation is below.

No regression at low load (1, 5, 10 calls, 6 calls per slot, 3 runs old/new interleaved; bench/results/capacity-t119-lowload-{old,new}-run{1,2,3}-windows-2026-10-13.*): p50 V2V 822-828 ms (old) against 820-827 ms (new) at every step, p95 842-862 against 844-856 ms, no lost turn. Smart Turn 18.4-19.5 ms per analysis (old; the first old run 23.6-23.8 ms, a noisy start) against 18.8-20.5 ms (new). A VAD window waits 46-89 us (old) against 50-53 us (new) at 1 call and 68-132 against 74-80 us at 5 calls. The end-of-turn path is the same code on another thread set and the end-of-turn delay did not move.

Linux confirmation to run (4-CPU/4 GiB container, the equal limits of the capacity benchmark), new layout (the default): MODELS=1 RUNS=3 SYSTEMS=brisvox NAME_SUFFIX=-turnexec STEPS=1,25,50,100,200,300,400,500,600 RESOLUTION=25 bench/capacity_linux.sh; interleaved with the old layout: MODELS=1 RUNS=3 SYSTEMS=brisvox NAME_SUFFIX=-sharedpool ENGINE_ARGS="--turn-threads 0" STEPS=1,25,50,100,200,300,400,500,600 RESOLUTION=25 bench/capacity_linux.sh (ENGINE_ARGS passes extra engine-server flags; each run's server.log has the vad wait and totals smart turn executor lines). Expected: the knee moves from ~200 toward the CPU limit (4 cores at ~10-11 millicores per call is ~350-400 calls); if the container's CPU ends the run, ENGINE_ARGS="--turn-threads 1" or 3 shows whether the thread count matters there.

Linux A/B (2026-10-13, 4-CPU / 4 GiB container, 3 runs each, interleaved by layout, host busy with builds of other work). New layout (executor, --turn-threads default): 300, 175, 250 calls, median 250; old shared pool (ENGINE_ARGS="--turn-threads 0"): 250, 175, 275, median 250. No difference within the noise: under the container's CPU quota the gain seen on Windows (no CPU limit) does not show, and both medians exceed the earlier 200 run, which says as much about the host's load as about the code. The executor stays (no VAD waits behind analyses, its own metrics); a quiet-host Linux A/B is in the Smart Turn section above. Files: bench/results/capacity-brisvox-{turnexec,sharedpool}-models-linux-2026-10-13*.

Capacity benchmark (measured 2026-10-13 on Windows; the secondary data)

Measured before the pacer phase was turned off by default (phase on); not re-measured on Windows. brisvox-bench capacity (code: brisvox_bench::capacity; options and JSON in bench/results/README.md) answers one question about a server: how many concurrent calls does it carry while p95 V2V stays within an SLO, with no failed call? It is the V2V client of the section above (run_call: real-time frames, the same V2V definition, the same fixtures and ground truth) run many times at once against any websocket URL, so brisvox and Pipecat are measured by the same code.

Method.

  • A step is N concurrent calls: slot i of N starts spread x i / N after the step began (4 s by default, so the calls do not all speak in the same instant) and plays the fixture (en_two_utterances, 2 utterances, 4 s between them, about 16 s per call) rounds times (2) one call after the other, so N calls are open from the start of the last slot until the first slot ends. The step pools the answered turns of all its calls (4N samples).
  • A step passes when no call failed (could not connect, refused, broke, got an error), no turn was lost or not clean (talked_over, bot_speaking, no_response; --max-bad-rate, 0), and p95 V2V <= floor + 250 ms (--slo-over-floor-ms). The floor is the V2V of an unloaded turn: for the mock providers the sum of the end-of-turn wait and the providers' fixed latencies, 950 ms (VAD stop 300 + STT final 150 + LLM first token 350 + TTS first byte 150), stated by the operator for any other server (--floor-ms, from a one-call v2v run). The SLO is therefore 1200 ms here. The ramp stops at the first step that does not pass; the reported capacity is the largest passing step below it, and --resolution N bisects between that step and the failing one until they are N apart.
  • A step in which the load generator sent more than 1 % of its frames (--client-late-share) more than 100 ms late (--client-lateness-ms) and that is otherwise healthy is generator limited: the numbers then describe the generator, not the server, and the ramp ends with a lower bound. One late frame (a scheduler hiccup) decides nothing; and a step with failed calls, lost turns or a p95 over the SLO stays a fail even when frames were late as well (a stalled server backs up the generator's own writes, which also reads as lateness), with the lateness named in its reasons. The generator's own CPU is sampled next to the server's, and the late-frame count and the largest send lateness are in every row.
  • One warm-up call is played first and not counted (a Python process otherwise fails its first calls on lazy imports; the Pipecat bot also builds one pipeline per process at startup).
  • The server is a process of its own, and with --pid its CPU time and resident memory are read, summed over the process tree (Pipecat's worker processes, the Windows venv launcher), every 500 ms on a thread of its own. On Linux the sampler also reads the PSS (smaps_rollup, summed over the tree) as pss_peak_bytes. CPU "cores" are CPU seconds per wall second over the step.

What runs on each side (no keys, no provider noise):

brisvoxPipecat 1.12.0
processbrisvox-bench engine-server (started by capacity --sim): the production engine (run_call, the transport's pacer, the inference pool) behind the real websocket serverbench/pipecat_baseline/bot.py --mock-providers: FastAPI/uvicorn, one real Pipecat pipeline per call; --workers N for N processes
providersbrisvox_sim mock STT / LLM / TTS at the trait boundarymock_providers.py: a real STTService, the real OpenAIResponsesHttpLLMService with only its HTTP stream replaced, a real TTSService: same fixed latencies and shapes (table in bench/pipecat_baseline/README.md; every shared number is checked against the Rust source by a test)
VAD, turnthe production VAD worker over an energy model (RMS 500, start 40, stop 300 ms), Smart Turn feature extraction real, model fixed "complete"an energy VADAnalyzer on the same 32 ms windows (10 windows to stop), Smart Turn v3 features real, model fixed "complete"
floor950 ms950 ms

What this measures, and what it does not. It is the cost of the framework: orchestration, frame passing, the VAD and turn plumbing, the output pacing, the websocket. It is not the cost of the models: the Silero VAD and the Smart Turn ONNX model are not run on either side (the bench build has no Silero), so a real deployment needs more CPU per call than any row below, and a system that spends more in the models will rank differently once they are on. A live run with real providers (the live phase) adds the network and the providers' own limits. The sim server has no admission control: a step fails when the engine degrades, not when it refuses calls.

Results, Windows, 2026-10-13: a trend, not a Linux run (the Linux numbers are in the section above). The files are bench/results/capacity-{brisvox,brisvox-pool4,brisvox-queue1024,brisvox-queue1024-calls2048,pipecat-1proc,pipecat-4proc}-windows-2026-10-13.{md,json} (each starts with the command and the "not a Linux run" banner, and the JSON carries the same in its environment block; the Pipecat files also state how the bot was started).

Correction after review: the first brisvox rows were limited by the benchmark's own queue. The sim engine server built its inference pool with a 256-job queue where production (PoolConfig::default) has 1024. The files capacity-brisvox- and capacity-brisvox-pool4- were measured with that 256 queue (their JSON states engine.pool_queue: 256), and their cliffs at 450 and 1000 calls were largely that queue filling, not the engine: with the production queue (--pool-queue 1024, now the default) the same 2-pool-thread server passes 1000 calls (the ramp 50, 200, 400, 600, 800, 1000 all pass; in the first run of it 450, 500 and 600 passed as well), and with the call limit raised from 1024 to 2048 (--max-calls) it passes 1200 and loses turns at 1500. The 256-queue files are kept as the ablation that shows the queue was the limit, and are not the capacity figure. The machine is a Windows 11 development PC with 32 logical cores, shared with other builds, running the load generator, the server and nothing else of ours; no container limit applies to either server. To give the two systems the same budget as a 4-core container, brisvox runs 4 tokio workers and 2 (or 4) pool threads, and Pipecat runs 1 process (the default setup: one event loop) and 4 processes. Release build, Python 3.13.14, Pipecat 1.12.0, fixture en_two_utterances, 2 calls per slot, one warm-up call, SLO p95 V2V <= 1200 ms, 4 generator workers. The generator's largest send lateness was 17-26 ms (the Windows timer tick) in the 256-queue and Pipecat files and up to 41 ms above 1000 calls, far below the 100 ms limit: no step was generator limited (the late-frame counts of the schema-2 files are 0; the four older files predate the counts and carry only the largest lateness).

systemservermax concurrent calls within the SLOfirst step that did not passp95 V2V at the maxserver CPU at the maxCPU per callRSS peak at the max
brisvox (2 pool threads, queue 1024, 1024 calls allowed)1 process: 4 tokio workers, 2 pool threads, production queue10001100: 152 calls refused (the sim server's own 1024-call limit), p95 1033 ms1032 ms1.88 cores1.9 mcores768 MB
brisvox (2 pool threads, queue 1024, 2048 calls allowed)same, --max-calls 20481200 (ramp 1000, 1200, 1500)1500: 1093 of 6000 turns without an answer, p99 3.6 s1041 ms2.08 cores1.7 mcores912 MB
brisvox (2 pool threads, queue 256, ablation)same as the first, benchmark's 256 queue400450: 9 of 1800 turns lost1015 ms0.69 cores1.7 mcores320 MB
brisvox (4 pool threads, queue 256, ablation)4 pool threads, 256 queue9001000: 1005 of 4000 turns lost, p95 2359 ms1029 ms1.91 cores2.1 mcores725 MB
Pipecat, 1 process1 uvicorn process (one event loop)5055: p95 1211 ms1090 ms0.93 cores18.5 mcores248 MB
Pipecat, 4 processes4 uvicorn worker processes160170: p95 1468 ms1086 ms3.49 cores21.8 mcores968 MB

At the same load (p95 V2V of the answered turns and server CPU in cores; the full ladders are in the result files):

concurrent callsbrisvox (2 pool threads; 256 queue up to 200 calls, 1024 queue from 400)Pipecat, 1 processPipecat, 4 processes
11033 ms, 0.021066 ms, 0.051068 ms, 0.05
101030 ms, 0.041064 ms, 0.19
401064 ms, 0.711061 ms, 0.78
501032 ms, 0.071090 ms, 0.93
602863 ms, 1.03, 5 of 240 turns lost
1001034 ms, 0.28
1201058 ms, 2.63
1601086 ms, 3.49
2001025 ms, 0.481986 ms, 4.00
4001017 ms, 0.68
10001032 ms, 1.88

What the numbers say, and how far to trust them:

  • Capacity. With the production inference queue brisvox carried 1000-1200 calls (Windows, 2 pool threads, mock providers) against 50 for one Pipecat process and 160 for four, with the same SLO, no failed call and no lost turn: 20-24 times one Pipecat process and 6-7.5 times four of them, on 1.9-2.1 cores against 0.9 (one process, saturated) and 3.5 (four). The first version of this sentence said 8-9 times (400-450 calls): that figure measured the benchmark server's own 256-job queue (see "Correction after review" above) and is withdrawn. (The optimization pass moved the 1200 / 1500 limit to 1800 / 2000 with a high-priority line for the VAD batches: "Optimization pass" below.) The two queue-1024 ramps agree on 1000 passing; the limit between 1200 and 1500 is one run and the repeat spread of the other rows (about one step, once more) applies to it too. The 1000 and 1200 rows are also near what one Windows generator can hold (it reported os error 10048, address in use, for a few connects at 1200 in the 1024-call run, and used up to 1.5 of its 4 worker cores), so they are lower bounds for the engine. Per call, brisvox spent 1.7-2.1 millicores at its limit and Pipecat 18.5 (one process) to 21.8 (four processes): 9 to 13 times more. The absolute figures are a lower bound for both (no models run, see above) and Windows only gives a trend. Between runs the limits moved by about one step, and once by more: brisvox with 2 pool threads and the 256 queue, 400, 450 and 400 in three runs (first failure 500, 500, 450), with 4 pool threads 700 and 900 (first failure 800, 1000); Pipecat with one process 50 and 50 (first failure 55, 55; an exploratory probe before the bot pre-warmed its pipeline, one call per slot, had failed at 40); Pipecat with four processes 110, 160 and 160 (the first run's file was overwritten by a rerun with the --note flag; it stopped at 120 with p95 3430 ms). A shared Windows desktop is the likely reason; the Linux run needs at least three repeats and medians.
  • Latency at one call. The floor plus 60 ms for brisvox (p50 1011 ms), plus 90-110 ms for Pipecat (1040-1061 ms in the runs). That includes the websocket, the client and the 32 ms VAD windows on both sides (the v2v mock-bot self-check above puts harness and transport at about 30 ms).
  • Memory (resident set of the process tree, peak). brisvox 20 MB with one call and about 0.75-0.8 MB per open call (320 MB at 400 calls, 768 MB at 1000, 912 MB at 1200). Pipecat 141 MB with one call and about 2.2 MB per call (248 MB at 50); four processes start at 572 MB and add about 2.5 MB per call (968 MB at 160).
  • How they fail. Pipecat's single event loop saturates at one core (0.93 at 50, 1.03 at 60): p95 goes from 1.09 s at 50 calls to 1.2 s at 55 and 2.9 s at 60 (the first run: 10.9 s and 61 of 240 turns lost), a cliff within 20 % of the load. Four processes do not scale four times (160, not 200): the OS spreads the connections over the workers without balancing them, so the busiest worker probably saturates first (not checked per worker); at 180 calls 46 calls failed (a websocket write timed out). brisvox fails differently: the p95 of the answered turns stays at 1.02-1.06 s while turns are lost (no audible answer within the response window). With the benchmark's old 256-job queue that began at 450 calls (9 of 1800 turns, 10 of 2000 at 500; at 600 half of them) and moving from 2 to 4 pool threads moved the cliff to about 950: that was the queue (and the pool draining it) giving way early, not the engine's CPU (0.7 of 4 cores busy). Ablation, 2 pool threads, queue 256 against 1024: 400-450 against 1000-1200 calls, so the queue size alone moved the cliff by a factor of 2.5-3 (the 4-thread, 1024-queue combination was not re-run). With the 1024 queue the failure at 1500 calls is again turns without an answer (1093 of 6000) with a p99 of 3.6 s, at 2.25 of 4 server cores: the pool and the VAD stop being decided late is the likely cause, not isolated here. The sim server has no admission control beyond its call limit (a refused call at the limit is a failed call: the 1100 step of the 1024-call run); the real server sheds new calls on pool pressure first, and with the Silero model each window costs more, so the real cliff is lower, and the pool size is the first knob to look at.
  • Not measured here. Linux is in the section above (the cliff positions move with the scheduler and the timer; here the generator and the server share a Windows desktop), the models, real providers and their rate limits, other fixtures (Turkish, tool calls), calls that interrupt, and a Pipecat tuned beyond the default (a load balancer in front of the processes, or a Smart Turn executor per process).

Readings. CPU is process CPU time over wall time between the first and last 500 ms readings inside the step; on Windows the kernel accounts CPU in scheduler ticks, so a light server (a few percent of a core) reads noisily: brisvox at 50 calls read 0.20, 0.13 and 0.07 cores in three runs. The large readings (the Pipecat rows, brisvox above 200 calls) repeat within 15 %. The generator's own CPU is in the files (up to 1.1 cores at 1000 calls).

Reproducing it on Linux with the same container limits (bench/capacity_linux.sh; run on 2026-10-13, results in the section above; the commands below are what it does by hand):

# 4 CPUs and 4 GiB for each server, pinned to cores 0-3; the load generator on cores 4-7
CPUS=4 MEM=4g bench/capacity_linux.sh
# what it does, by hand, per system:
docker run -d --name srv --cpus=4 --cpuset-cpus=0-3 --memory=4g --memory-swap=4g --ulimit nofile=1048576:1048576 \
  --network host -v "$PWD":/src:ro debian:bookworm-slim \
  /src/target/linux/release/brisvox-bench engine-server --bind 0.0.0.0:8080 \
  --fixtures-dir /src/fixtures --max-calls 1024 --pool-threads 2          # brisvox
docker run -d --name srv --cpus=4 --cpuset-cpus=0-3 --memory=4g --memory-swap=4g --ulimit nofile=1048576:1048576 \
  --network host -v "$PWD":/src:ro -v brisvox-pcvenv:/venv:ro python:3.13-slim-bookworm \
  /venv/bin/python /src/bench/pipecat_baseline/bot.py --mock-providers --host 0.0.0.0 \
  --port 8080 --workers 4                                                  # Pipecat, 4 processes
taskset -c 4-11 target/linux/release/brisvox-bench capacity --url ws://127.0.0.1:8080/ --fixtures-dir fixtures --client-workers 8 \
  --pid "$(docker inspect -f '{{.State.Pid}}' srv)" --floor-ms 950 --label NAME \
  --steps 1,5,10,20,40,80,120,160,240,320,480,640 --resolution 10 --rounds 2 \
  --out-md bench/results/capacity-NAME-linux-DATE.md --out-json bench/results/capacity-NAME-linux-DATE.json

The same limits for both: --cpus (the CFS quota; Rust's available_parallelism and so the tokio worker count follow it; --pool-threads is half the CPUs, as PoolConfig::default has it), --cpuset-cpus, --memory; the Pipecat bot gets one worker process per CPU (it is one event loop per process; a single process is also in the table). Run the two one after the other on the same host, repeat each at least three times, and compare medians. Check before the run that the generator is not the limit (its CPU column and send lateness) and that the host has CPUS + 4 free cores.

Optimization pass (measured 2026-10-13 on Windows)

Full write-up with the profiling method, the counters and every file: bench/results/2026-10-13-optimization.md. Windows, mock providers, energy VAD and a fixed Smart Turn model, 4 tokio workers and 2 pool threads: a trend, not a Linux run.

  • What limited the capacity run. The 1500-call step of the first capacity ramp (1093 of 6000 turns lost) was not CPU: the pool was 30-43 % busy. Smart Turn analyses (about 400 a second at 1.5 ms each in the sim, 22.5 ms with the model) shared one FIFO queue with the ~75 000 VAD batches a second (one per call and 20 ms frame); when the turns of a few hundred calls ended together the batches waited behind the analyses, the 1024-job queue filled and rejected them, and a rejected batch loses the call's VAD stream. Without Smart Turn (--engine-turn fixed:300) the same step passes with the pool 3-4 % busy.
  • The fix. The pool has two bounded lines (Priority::High for the VAD batch, Normal for the rest); a worker takes a waiting high job first. The largest step within the SLO moved from 1200 to 1800 calls (p95 1168 ms), and past it the server degrades through the p95 (2000 calls: 1224 ms, every turn answered) instead of losing turns (1500 calls: 18 % lost). brisvox-server sizes the queue max(1024, 2 x max_sessions).
  • What limits it now. The four tokio workers (82 % busy, 250 000-350 000 polls a second, a third of their time in the kernel's loopback send / receive); 8 workers pass 2200 calls. Per call the CPU did not change (1.7 mcores): the pass moved the cliff.
  • V2V at one call is unchanged (p50 1009.4 -> 1006.9 ms, p95 1028.0 -> 1034.2 ms, run noise).
  • New timeline events FirstChunk (segmenter) and TtsFirstText (TTS stage), once per response; the V2V stage first_chunk_to_tts_text (the hop between the two stages) is 0.007 ms p50.

Optimization pass follow-ups

  • A refused VAD batch no longer loses the call's VAD. The pool refusing a batch (or a queue timeout before the job started) means the job never ran, so the worker keeps the stream and the audio, waits 10 ms and offers them again together with the frames that arrived meanwhile (VadStatsSnapshot::retries). Overload becomes delay of the stop/start events, not a lost turn and a rebuilt stream plus a synthesized stop. Tests: vad_worker a_saturated_pool_delays_the_batch_and_keeps_the_stream_and_the_turn (speech still starts at the first, refused frame, rebuilds 0) and audio_kept_for_a_retry_is_bounded_and_shed_like_a_tee_overflow. Still counted toward the fail-open streak (5 refusals over 1 s of audio). Not re-measured at 1800-2000 calls on this machine (RAM shared with other builds): the capacity ramp on Linux is the job for that.
  • Incremental Smart Turn features: not adopted. The Whisper extractor normalizes the waveform with the mean and variance of the whole 8 s window before any frame is cut (bit-exact with numpy float32), so sliding the window by even 100 ms changes every sample, hence every column. Test a_slid_window_changes_every_shared_column_so_columns_cannot_be_reused slides the window by 10 hops and finds 0 of 63 200 shared values identical (largest difference 0.052, against the 1e-6 that already moves the model). Reusing columns would break the bit-exact goldens (the int8 model moves by 0.13 for 1e-6 on the features), and a mathematically equivalent rewrite (FFT of the raw frame, scaled afterwards) is not bit-exact either. The upside is small: features are ~1.0-1.2 ms against the 22.5-28 ms model ("Smart Turn"), so at most ~4 % of an analysis. The current extractor stays.
  • brisvox-bench engine-server sizes the pool queue like brisvox-server: without --pool-queue it is max(1024, 2 x max-calls); an explicit value is used as given, and the report shows the capacity actually used.

Targets (hypotheses until the benches measure them)

  • Internal overhead p99 < 10 ms at 300 concurrent simulated sessions on 4 cores.
  • V2V median lower than Pipecat with identical providers and equivalent turn settings, with speculation off; further reduction with speculation on. Publish exact numbers, not claims. (Over mock providers: 60 ms against 90-110 ms above the 950 ms floor at one call, "Capacity benchmark"; with live providers: the live phase.)
  • Capacity at p95 V2V <= floor + 250 ms: more calls per container than Pipecat with the same CPU limit. Linux, equal 4-CPU/4 GiB containers (2026-10-13, WSL2, 3 runs each): brisvox 2100 (2100-2200) against 90 (70-90) for Pipecat with four tuned processes and 70 with its defaults ("Capacity benchmark, Linux"). Windows evidence (trend only): 1000-1200 against 50 (one process) and 110-160 (four); an earlier 400-450 was the benchmark's own 256-job queue.

Testing strategy

LayerWhatWhere
Unitcodecs, segmenter, state machine transitions, schema validationeach crate
Propertyframe ordering, epoch dropping, tool-call assembly from random delta splitscore/agent (proptest)
Simulationfull sessions under paused time with mock providers and scripted usersbrisvox-sim
Contractevery provider adapter vs shared trait tests + local protocol mock serverproviders
Chaosdisconnects, stalls, slow consumers, panics across many sessionsbrisvox-sim
Load10–500 concurrent sessions, overhead/CPU/memory (brisvox-bench sim-load; see LATENCY.md)brisvox-bench
Livereal providers, BRISVOX_LIVE_TESTS=1, keys in .envproviders, bench
BenchmarkV2V vs Pipecat, capacitybrisvox-bench, bench/

Rules

  • Every bug fixed gets a regression test (preferably a simulation scenario).
  • Tests are deterministic: seeded RNG, injected Clock, no real network unless live.
  • No sleeping in tests on wall time; use paused tokio time. Exception: the load harness's own smoke test (brisvox-bench/tests/sim_load.rs, ~8 s real time) — it asserts counts and leaks, never timing values. Timer-tick delays are invisible in paused time; guard them with deterministic counters (e.g. a counting Clock, brisvox-sim/tests/pacer.rs).
  • Simulated time: build the runtime with start_paused(true) (or #[tokio::test(start_paused = true)]) and create SimClock::new() inside it. Timers then fire instantly in virtual time, in a reproducible order. Reference: clock::tests::ten_minute_session_runs_fast_and_deterministically (10 simulated minutes, 30 000 paced frames, two runs compared, < 1 s wall). Only current-thread runtimes can be paused; timer granularity is 1 ms; a SimClock created outside a paused runtime silently follows real time.
  • Fixture provenance and licenses are recorded below.

Fixture provenance

Real-speech fixtures live in fixtures/ (built by scripts/fixtures/build_fixtures.py, loaded by brisvox_sim::fixture::Fixture, played into a call with SimSession::run_fixture).

ItemValue
SourceGoogle FLEURS, dev split, en_us and tr_tr (research and alternatives: docs/FIXTURES_SOURCES.md)
URL / pinhttps://huggingface.co/datasets/google/fleurs, revision 70bb2e84b976b7e960aa89f1c648e09c59f894dd (fetched 2026-10-07)
LicenseCC BY 4.0 (audio); transcripts from FLORES (CC BY-SA 4.0) -> fixtures/ is shared under CC BY-SA 4.0, see fixtures/NOTICE
Accessanonymous HTTPS; the script reads only a prefix of each dev archive (en 16 MB, tr 64 MB), cached in target/fixture-cache/
ChecksumsSHA-256 of every source clip in fixtures/sources.lock (small set) and fixtures/large.sources.lock (larger set), both tracked; a rebuild must select exactly the locked clips with the locked checksums or it fails (--update-lock re-pins deliberately); the build is byte-reproducible
Processingfloat32 -> PCM16, peak-normalized to -6 dBFS, edge silence trimmed (10 ms energy gate), clips with internal pauses > 250 ms or < 1.5 s of speech skipped, concatenated with known silence gaps
Committed seten_two_utterances, tr_two_utterances, en_tr_mixed (1.3 MB, 16 kHz mono)
Larger set--large -> fixtures/large/ (git-ignored), 4 fixtures x 3 utterances, pinned by fixtures/large.sources.lock

Ground truth: each sidecar fixtures/<name>.json lists speech segments by sample (start, end), language, transcript and source clip. One clip = one speech region (no internal pause longer than the VAD hangover), so VAD start/stop can be checked exactly: the energy VAD matches every committed fixture within 10 ms (test fixtures::energy_vad_tracks_the_ground_truth...).

Benchmarks: brisvox-bench fixtures plays all three committed fixtures, Turkish and mixed included, as concurrent simulated calls and reports voice-to-voice per fixture and per utterance language (bench/results/fixtures-sim.md, docs/TURKISH.md section 3). The gaps between utterances are widened with digital silence so the caller does not talk over the answer.

Caveat: the segment boundaries are where the build script's 10 ms energy gate first/last fires on the clip, not human-labelled speech onsets. For the energy VAD the check is therefore partly circular (similar detector, same signal); it proves frame accuracy and hangover handling, not that the boundary is where a listener hears speech end. Model VADs are measured against the same boundaries, and differences of a few tens of ms at soft word edges are expected.

Quality gate (scripts/verify.sh)

  1. cargo fmt --all -- --check
  2. cargo clippy --workspace --all-targets --all-features -- -D warnings (workspace lints in the root Cargo.toml; test exemptions in clippy.toml)
  3. RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features: an unresolved, private or redundant intra-doc link fails the gate. Link an item that is not in scope by its path ([TtsEvent::Word](crate::TtsEvent::Word)); do not repeat a path that resolves by itself.
  4. tests: cargo nextest run + doc tests if nextest is installed, else cargo test --workspace
  5. cargo deny check (licenses, advisories, bans, sources) if cargo-deny is installed and deny.toml exists

CI (.github/workflows/ci.yml) runs the same script on ubuntu-latest and windows-latest with the pinned toolchain, Swatinem/rust-cache, and prebuilt cargo-nextest + cargo-deny (taiki-e/install-action). nextest (.config/nextest.toml) runs each test in its own process and kills a test that runs longer than 3 minutes, so a hang fails fast. CI also checks Cargo.lock is current (cargo fetch --locked), runs weekly for new RUSTSEC advisories (cargo-deny needs network access), pins third-party actions to commit SHAs, and saves the build cache only from the default branch. First real run pending a git remote.

Lint exemptions apply only to #[test] fns and #[cfg(test)] modules. Files under benches/, examples/, build.rs, and non-test helpers in tests/ must carry //! docs and, if they use unwrap/indexing, a file-level #![allow(...)].

Toolchain for --all-features

The gate builds with --all-features, which includes brisvox-transport/webrtc. That feature compiles C code: libopus (opusic-sys, built by the cmake crate) and aws-lc-sys (DTLS crypto of str0m, via dimpl/rcgen, also built with cmake). So the gate needs a C compiler and cmake (Linux: apt install cmake build-essential; macOS: Xcode tools and brew install cmake; Windows: MSVC build tools; the cmake crate finds the cmake bundled with Visual Studio, otherwise install cmake). scripts/verify.sh checks for cmake first and says so. The default build (cargo build, no features) and the websocket/twilio features need neither. Not yet verified on a clean Linux box: whether aws-lc-sys additionally wants NASM (it needs it on Windows x86_64 only when the prebuilt objects are unavailable; it did not here). CI images (ubuntu-latest, windows-latest) ship cmake and a C compiler.

Lessons (keep tests honest)

  • Paused-time tests that wait for frames must wrap the wait in tokio::time::timeout: a missing frame otherwise hangs the whole test binary (a verify run timed out once).
  • System-lane priority lets lifecycle frames overtake queued data; race tests must yield between data and lifecycle frames, or drive units directly.
  • Mutation-check every regression/property test: re-introduce the bug, confirm the test fails.
  • The gate runs on Linux and Windows (.github/workflows/ci.yml); a test that passed on one desktop only is a bug in the test (T-132). Reproduce Linux failures in a rust:1.99.0 container (WSL2 + Docker on the Windows machine) with cargo nextest run --workspace --all-features; for racy ones add --stress-count N and CPU contention (docker run --cpuset-cpus=0,1, busy loops). The causes found: model goldens that are platform-specific (Smart Turn: only compared where the reference's kernels run, docs/providers/smart-turn.md; Silero: 1e-7 on Windows, 5e-6 elsewhere with a proof that no golden window is within 10x of a threshold); kernel socket buffers (Linux autotunes loopback buffers to MiB, Windows holds ~0.5 MiB: bound by the platform's limit, not a fixed count); tokio's cooperative budget and a current-thread runtime that only polls its IO driver when the task yields (a test that expects a message to be seen between two always-ready sends must let the runtime run); the scheduler's lazily allocated defer list (one allocation the first time a task yields: warm it before counting); work started "together" that is only ordered by thread start-up (wait for the first job to run before filling the queue behind it).
  • Tests that fail only on a busy runner (T-132.1; reproduce with docker run --cpuset-cpus=0,1 plus busy loops, and --stress-count): (1) "a client that does not read" tests with real sockets set a tiny SO_RCVBUF on the client they own (TcpSocket::set_recv_buffer_size before connect, see websocket.rs, rtvi.rs): the kernel's autotuned buffers grow and drain while the test runs, which let one more reply through (17 for 16 slots) only under load; where the client is the adapter under test (providers) the script is sized from the machine's buffer maxima (brisvox_sim::platform::loopback_buffer_bytes) instead. A test that blocks a writer waits until the writer made no progress, not until a queue happened to fill. (2) Real-time CAPACITY tests (realtime_load, early_analysis_realtime's latency test) assert how the machine copes, not what the engine does: they keep their strict assertions, run alone (nextest threads-required = "num-test-threads") and are retried twice in the ci profile (.config/nextest.toml, scripts/verify.sh uses it when CI=true); a retried pass shows as FLAKY in the log. Correctness assertions are never retried: add a test to that filterset only after showing that it fails on a loaded machine and that the engine's behaviour was right. (3) Statistics of a model on synthetic inputs (the early-analysis cut sweep) belong to the platform's kernels like the goldens do: strict where the reference runs, the safe direction pinned everywhere. (4) A raw-protocol test peer behaves like the real peer or the test is racy: the SIP Carrier of hosting.rs acknowledged the 200 once, and rsipstack can miss an ACK that arrives within 100 us of its 200 (13 of 600 runs under CPU load, then a 10 s wait); a real UAC re-ACKs every retransmitted 200, so the carrier repeats its ACK. And a test that reads a mock's record right after the adapter's stream ended must wait for the last message (eventually).

Session simulation

  • brisvox_sim::scenario: a Scenario (speak(text, duration), silence(duration)) is the caller's script. play_input sends one 20 ms PCM16 frame per frame period on the paused clock (speech = square wave, silence = zeros) into a pipeline; speech_segments() is the ground truth; utterances() scripts MockStt; end scripts with with_tail(>= hangover) or the last utterance never stops. play_input takes a CancellationToken. SimTransportOut is the epoch-strict output transport: it records every frame it gets (time, kind, epoch, audio length; bounded by with_record_limit, default 100k) and marks FirstAudioOut, and on an interrupt AudioOutStopped for the turn it was playing.
  • brisvox_sim::vad::EnergyVad: RMS threshold VAD (start after 40 ms of speech, stop after 300 ms hangover by default), timestamps backdated to the first speech frame / end of the last.
  • The user-input side (transport in, VAD, STT, turn manager) is epoch-agnostic (KeepAll): barge-in audio must never be purged (guarded by a test). Epochs begin at the turn manager, which also records the VAD/STT/turn timeline marks.
  • brisvox_sim::reference + brisvox_sim::session::SimSession: a whole call. Reference stages (a stand-in for the production engine): SttStage (Finalize after forwarding the VAD stop; UtteranceFinal control on speech_final or session failure; reconnect with backoff), TurnManager (epochs, turn end once the VAD stopped and every finalize is answered, barge-in when the bot is active -> Interrupt, timeline marks), LlmStage (history; after a barge-in the assistant message is cut to the played words), TtsStage (context = epoch, priority cancel, word log, TtsEnded even on failure), OutputPacer (real-time playout; waits abandoned as soon as an interrupt is queued; played audio log; TtsEnded upstream when a turn's audio is out). SimSession::run(scenario, linger) returns the transport records, finished turn timelines and probes (history, words, played). Use a fresh SimSession per run (mocks are shared state).
  • First finding of the simulator: a pacer sleeping through a 40 ms chunk delayed barge-in by up to a chunk; fixed with ProcessorContext::epoch_watch.
  • brisvox_sim::assertions (on SessionRun): check_bot_audio_within(after, max), check_no_stale_audio_after_interrupt(), check_context_matches_played_text() (every assistant message equals the words that started playing in its turn), played_text(epoch), audio_of(epoch), first_bot_audio_after(t), and fingerprint() for determinism. The scenario suite (crates/brisvox-sim/tests/scenarios.rs, 11 calls: happy path with realistic seeded latency, barge-in, double utterance, long silence, provider stall, LLM error, STT disconnect, tool call with text, back-to-back turns, interrupt during TTS first-byte wait, TTS disconnect mid-answer) runs every scenario twice and requires identical fingerprints plus the stale-audio and played-text invariants. New bugs found while building it are fixed in the reference stages and keep their scenario as a regression test.

Chaos

crates/brisvox-sim/tests/chaos.rs runs 100 simulated calls concurrently in one paused-time runtime. Every third call is unaffected; the rest get one seeded fault each: STT disconnect or stall, LLM 503 / disconnect mid-answer / 3 s stall, TTS disconnect or stall, a mid-turn barge-in, a slow transport consumer (SimSession::transport_delay, 30 ms per 20 ms frame), or a hang-up (the call future is dropped mid-answer). A warm-up round runs first and its tasks must wind down before the baselines are taken. Checks:

  • no call panics or fails a stage (outcome == Ok); every round is bounded in virtual time;
  • every call keeps the invariants: no stale audio after an interrupt, the LLM context holds only played text, no transport records dropped;
  • every fault fired: disconnects show up as reconnects (MockStt/MockTts::connects; the stages may hide them completely), every other fault changes the call versus the same call without it; barge-ins interrupt; the slow consumer really spaces frames >= 30 ms;
  • recovery: whatever failed in the first turn, the second question is answered;
  • isolation: each unaffected call equals the same call run alone (fingerprint: output timing, history, words, metrics) and answers each turn within 1.5 s of its end of speech;
  • no tasks outlive the round (hung-up calls included; 100 ms virtual grace) and live heap bytes return to the pre-round level within 4 KiB (a counting GlobalAlloc in this test binary only). Measured: +556 B after 100 calls. Writing these checks found two faults that had silently been no-ops (a disconnect after the transcript was complete; a single-delta LLM answer). Limitation: paused time cannot show blocking or CPU-heavy work on the runtime; real-time behaviour under load is brisvox-bench sim-load .

Reusable suite and the sim seam

The scenario, played-text, fixture and chaos bodies live in brisvox_sim::suite as async fn name<F: SessionFactory>(factory: &F, profile: Profile) -> brisvox_sim::Result<()> (tests/scenarios.rs, played_text.rs, fixtures.rs, chaos.rs, session.rs are thin wrappers over ReferenceFactory + Profile::Exact). SessionFactory::build(SessionParts) returns a BuiltSession { pipeline, probes, input: InputSink, output: OutputLog }; SimSession::run_with builds, starts, binds the input, plays the caller and collects a SessionRun.

  • Profile::Exact asserts the reference's closed-form timings (1480 ms first audio, 660 ms of audio, 280 ms end-of-turn); Profile::Budget asserts ranges (table in the suite module docs). Structural invariants (outcome, determinism, no stale audio, context == played text, roles, turn and interrupt counts, recovery, isolation, no leaked tasks, flat heap) are identical in both. Production fingerprints are compared only to production fingerprints.
  • Interrupt counts come from Interrupt frames recorded at an output tap (OutputLog::tap), never from client clears. OutputLog also holds wire-arrival records (record_wire) for pipelines with a transport; LoopbackConfig::mark_acks (Manual, Auto { delay }, Never) lets the loopback client echo marks for them.
  • The suite can fail (tests/faulty.rs): a wrapping factory injects one fault into the reference chain and the test pins which profile rejects it. Measured, not assumed: +100 ms end-of-turn silence (first audio 1580 ms) and +40 ms barge-in confirmation (interrupt at 2460 ms) fail Exact and pass Budget (Budget is tight: +20 ms, interrupt at 2440 ms); a 30 ms per-frame transport delay fails Exact's time to silence and passes Budget; 150 ms per frame (260 ms to silence), a dropped Interrupt at the output tap and old-turn audio after the interrupt fail both; an LLM that is slow to its first token and keeps one unheard word of the answer when the caller barges in before any audio fails the streaming check in both. Each was mutation-checked (suite body switched to Profile::Budget, Budget range widened, check_no_stale_audio... removed, interrupts == 1 weakened: the matching test fails). Nothing-heard-means-nothing-kept has no word of slack in either profile (the Budget word slack covers a word at the cut only); a Budget pipeline that drops a barge-in's response whole leaves the reply to the second utterance as its first assistant message, which the check treats as empty.
  • Loopback mark echoes (MarkAcks::Auto): the transport tests pin that a zero-delay echo reaches the engine at the instant the client received its mark even with audio due later behind it (the adapter must not serve its delivery queue first), that a hand-sent echo under Auto is delivered at once and counted once, and that the auto echo still fires for other marks. The tie-break when an echo and a delivery fall due at the same instant is pinned only by the unit test of next_wake: the two go to different channels, so no timestamp can show the order.
  • Refactor proof: before the shared-suite refactor every fingerprint and output record of every sim test (1709 dumped lines, tests run single-threaded) was captured and compared byte for byte after the refactor (identical). The capture hook was temporary and is not in the tree.

Hybrid gate 3 accommodations

crates/brisvox-engine/tests/hybrid_llm.rs runs the shared suite with the engine's input chain, SttStage, TurnDriver and LlmRunner in front of the reference TTS/pacer/output. A gate-3 pass is conditional on three accommodations, and the production pipeline (and any later gate) must not inherit them silently:

  • llm.retries = 0. The suite's s06 scripts one failing response followed by the NEXT turn's answer; with the production default (one retry) the retry would consume that answer. So a pass of all_scenarios_* does not prove s06 under the default configuration. The retry itself is tested in tests/llm_runner.rs, and every_scenario_but_s06_passes_with_the_default_retry_configuration runs s01..s05 and s07..s11 with retries = 1.
  • The played-text suite runs with a 300 ms fixed silence ("Quick") instead of the 450 ms used for s01..s11: its barge-ins come 0.4 s after the first utterance.
  • InterruptionConfig::OFF for the basic suite (its barge-in budget is that of an immediate barge-in; the guards deliberately delay it).

The runner's failure paths (a stuck round task, a truncation source that never answers, a refused offload, a panicking provider, a request that cannot be built, Cancel, the record bound, oversized provider_data, a summarizer that is slow or never answers) are tested in tests/llm_runner.rs and tests/llm_window.rs. The stuck-task test blocks a real worker thread, so it runs on a multi-thread runtime in real time and waits for the blocked request without the runtime's timers (while a worker is blocked and the others sleep, nothing drives the timers).

The shared suite on the production pipeline

crates/brisvox-engine/tests/engine_suite.rs runs brisvox_sim::suite under Profile::Budget on brisvox_engine::sim::EngineFactory (every stage of build_session_pipeline over a loopback TransportSession, paused SimClock): s01..s11 with fixed 450 ms silence and the recommended guards, with every guard off, with the adaptive policy (fixed-answer model) and with the time_in tool; s08 with the registered tool (s08_with_a_registered_tool: call, the tool's own result, second round, both spoken); every scenario but s06 with the default retry; the basic suite (fixed and adaptive); the played-text suite; the three fixtures (en, tr, mixed; first audio at most 600 ms after each utterance end, measured 280 to 475 ms). Every suite function runs each call twice and requires identical fingerprints (production against production). run_call.rs covers the library entry point (stop, hang-up, cancel ends the pump too, no task left behind, clear hook, early-analysis switch, warm-up of every stage); warm_pool.rs the per-thread model warm-up on a real pool. The Exact profile runs on the reference only.

Divergences of the production pipeline from the reference

Each is either a property of the production design or an accommodation of the suite's scripts; none is silent:

  1. Output through a real transport. Audio goes through TransportOut, the session's pacer and the loopback client: a Clear drops queued audio, end marks are echoed (auto, no delay), positions are reported per 20 ms frame. Until the phase was turned off the pacer had a per-session phase offset (0 to 20 ms) on the session's first frame, so first audio came up to one frame after the reference's (measured +15 ms: 1495 ms with the adaptive policy against 1480 ms); now the first frame leaves at once, like the reference's (the offset is an opt-in, with_first_frame_phase).
  2. Output log on wire time. The tap in front of TransportOut records every frame but audio as it passes (so Interrupt counts are the pipeline's, not the client's clears); audio is recorded when the client received it (OutputLog::record_audio). A tap before the transport would see audio up to a second early (the pacer queue), which the played-text oracle cannot use. Audio received at the same instant as an Interrupt is filed before it in the output log (the tap stamps the interrupt with the same paused-clock time), so the output-log stale check only sees stale audio a tick or more later; check_no_stale_audio_after_interrupt also walks the wire records in the client's arrival order (no audio of turn e after a Clear to a later turn), which catches a stale frame right behind the clear in the same instant (tests/faulty.rs, stale_audio_right_behind_the_clear_on_the_wire_fails_both_profiles, mutation-checked).
  3. Context vs heard text: up to one word fewer, never more (Budget only). The book counts a released frame whole and subtracts lead_margin (one 20 ms frame in the factory); the oracle plays a frame continuously from its arrival. The engine's heard position is therefore 0 to 20 ms behind the oracle's, and a word that started in that window is left out of the context. Measured: s02 with the adaptive policy kept "...there was a voice" while "agent" had started (it passes with a lead margin of 0 to 15 ms). Profile::context_holds_played applies the BUDGET_WORD_SLACK of 1 word only to turns a barge-in ended (an Interrupt to the next epoch reached the output, SessionRun::cut_by_barge_in): such a turn's context may lack one heard word, or be left out if at most one word was heard. A turn that played to its end must match exactly, and the context never holds more than was heard. Under Exact (the reference) the check stays exact. engine_suite.rs (with_no_lead_margin_the_context_is_exactly_the_heard_prefix_after_every_barge_in) runs s02, s10 and the basic barge-in with lead_margin = 0 and the exact check, so a regression that loses the last heard word at every barge-in cannot hide behind the slack (mutation: a 40 ms margin fails it).
  4. Multi-round turns. A tool turn has two assistant messages in one epoch; the context check joins the assistant texts between two user messages (SessionRun::said_per_turn). The probe history now carries the assistant's tool calls (provider_messages maps them).
  5. s08 runs the tool loop. Budget accepts the reference's shape (tool ignored) or the full loop (call on the first round's message, its result, the second round's scripted text, both spoken); s08_with_a_registered_tool requires the loop and the tool's own result (an unregistered tool's unknown_tool error result fails it: mutation-checked).
  6. llm.retries = 0 in the factory (inherited gate-3 accommodation): s06 scripts the next turn's answer after the failure. every_scenario_but_s06_passes_with_the_default_retry_ configuration covers the rest with the default (one retry).
  7. Played-text suite at 300 ms fixed silence (inherited): its barge-ins come 0.4 s after the first utterance; with 450 ms the caller would resume the first turn.
  8. Basic suite with every guard off (inherited): its barge-in budget is an immediate barge-in; the recommended guards wait for words (measured: the interrupt at 3 s instead of 2.42 s). s01..s11 run with the guards.
  9. Turn end. Fixed 450 ms silence (end-of-turn delay 450 ms against the reference's 280 ms, first audio 1665 ms) or the adaptive policy with a fixed model (280 ms, as the reference). vad_hangover is set from the simulator's VAD (stop_after), since its stop is backdated.
  10. Rates. SessionParts::input_rate/output_rate are not used: the loopback call runs at the engine's fallback rates (16 kHz both ways); the mock TTS delivers 24 kHz and TtsStage resamples. SessionParts::transport_delay (slow consumer) is a slow reader of the loopback client (one-event client queue, the delay after each audio frame, frames stamped when read), so the chaos suite's 30 ms minimum frame gap holds for both pipelines.
  11. VAD. The simulator's energy threshold runs as an EnergyModel behind the production VAD worker (batching, gap handling, Offload::Inline); speech marks measured equal to the reference's (200 ms and 1200 ms).
  12. Played-text streaming check. Under Budget "everything played" means every word the TTS spoke for the turn had started at the barge-in (a production segmenter holds the last sentence of a stalled stream back, so less than the delivered text may be spoken).

The Budget ranges were tightened from measurements (docs/LATENCY.md, "Simulator Budget numbers"); tests/faulty.rs follows them (a 20 ms slower barge-in confirmation passes Budget, a 40 ms one fails both profiles on "interrupt at the output", pinned by a_40_ms_slower_barge_in_fails_both_profiles; a 150 ms-per-frame transport now fails one turn's first audio in both profiles; a 30 ms one passes Budget's first audio and time to silence).

Chaos, isolation, leak, allocation and blocking on the production pipeline

  • Chaos (crates/brisvox-engine/tests/engine_chaos.rs): the shared 100-call chaos suite (brisvox_sim::suite::chaos) on EngineFactory, Budget profile, its own counting allocator. Every check of the reference run holds: outcome Ok, no stale audio (tap and wire), context == played (the exact heard prefix after a TTS disconnect; 6 calls cut mid-answer with text heard), every fault had an effect, recovery on the next turn, the 34 unaffected calls identical to the same call alone and answering within the 1.5 s SLO, no task left behind, heap +604 B after the round (limit 4 KiB). About 35-40 s in a debug build (nextest override: 120 s slow period). The slow consumer is a slow reader of the loopback client (LoopbackClient::split): a one-event client queue, transport_delay after each audio frame, frames stamped when read; the suite's minimum frame gap is now 30 ms for both pipelines (mutation: ignoring the delay fails "fault had no effect"). Accommodations: the adaptive end of turn with a fixed model (280 ms, as the reference; for the shared SLO set by the reference); a factory that keeps no handles (without_handle_log: 166 calls' books and contexts are 2 MB the heap check would count). The first TTS chunk is the production default (one word): the with_first_chunk_words(1) override is gone (with the old default of three, the 10-word first sentence the chaos LLM streams word by word, 30 ms apart, was spoken only when complete, and a call drawing a slow first token answered in 1.59 s, seed 2009; first_chunk_policy.rs below).
  • Allocation per frame (counting allocators, thread-local, current-thread runtimes): stt_turn_alloc.rs 0 allocations over 200 frames of steady speech and 200 of steady silence through AudioIn (VAD tee and worker) -> SttStage (into the provider channel) -> TurnDriver, with and without the Smart Turn ring. The VAD worker runs on the inline offload there. On the inference pool (production) every batch job costs 3 allocations on the submitting tokio thread (the reply channel, the abandonment flag, the boxed job; pinned exactly by a_pool_job_costs_a_fixed_number_of_allocations_on_the_submitting_thread), and the worker forms about one batch per 20 ms frame, so the production AudioIn -> VAD path costs about 3 allocations per frame (bounded, not zero). The frames carry shared buffers, as the transports make them: a Bytes made from a Vec allocates its shared header at its first clone, which is the STT stage's (one allocation per frame, measured, the buffer's not the engine's). audio_in_alloc.rs (0 per frame, every rate) and tts_alloc.rs (per provider chunk, none per frame) as before. Per-chunk and per-word costs: TTS two allocations per provider chunk and one Box per word frame; segmenter at most 4 per emitted chunk plus 3 per push with sentence ends (segmenter_alloc.rs); an LLM text delta appends to the assembler's String (amortised doubling). request_alloc.rs: a request build allocates the same (1427 allocations, 1.19 MB, the galloping clip of 12 results) for 12 x 20 KB and 12 x 60 KB results; a copy of the results before clipping (mutation: to_string of every message) adds 480 KB and fails. It replaces the wall-clock ratio of llm_window.rs as the deterministic guard; that test now takes the best of 15 interleaved runs.
  • Real time under load (realtime_load.rs, feature silero-tract): 20 calls at once over loopback transports, multi-thread runtime (4 workers), system clock, a real pool (8 threads), Silero on tract, a Smart Turn model that sleeps 15 ms per analysis, every LLM finish forced onto the pool (inline_finish_bytes = 1). Every VAD window, analysis and finish ran on a pool thread (thread names), none on a worker; every call answered both turns; no pool rejection. Scheduler-gap probe (one yielding task per worker, longest time between two of its polls): debug 43-55 ms worst (bound 250 ms: a shared machine, unoptimised code), release 1.67, 1.84, 2.27 and 2.61 ms worst over four runs with at most two polls over 2 ms per 14 s run, on the shared machine with other builds running. The strict 2 ms bound is asserted by the ignored release test only with BRISVOX_QUIET_MACHINE=1; otherwise it allows three gaps over 2 ms and none over 5 ms. Open: two of the four runs miss the 2 ms acceptance bound, and the quiet machine run has not been recorded yet. Single gaps look like OS preemption (a blocking stage would repeat every frame), but the probe does not record which stage ran during a gap, so this is not proven. Pool in release: wait max 18-36 ms, run max 18-20 ms (the 15 ms model).
  • Pool saturation and timeouts in a live call (warm_pool.rs, a_saturated_pool_degrades_the_call_but_leaves_it_alive): a one-thread pool kept busy with a full queue during the caller's speech; the VAD counts refusals or timeouts, every frame still reaches STT, the VAD recovers after the pool frees up, the call stops cleanly. The runner's and the request builder's offload failures: llm_window.rs (a_timed_out_build_falls_back_off_the_worker_even_while_the_context_lock_is_held, a ticker on a current-thread runtime; the old inline fallback stalls it and fails).
  • Warm-up (warm_pool.rs): a busy pool is not warmed and both claims cool down; a warm job that outlives its timeout keeps the claim until it ends (mutation: jobs without a share of the claim fail it); the Smart Turn warm-up fans out over half the pool (four jobs side by side on eight threads); VAD claims are per model id. brisvox-inference: the claim cool-down (pool.rs), concurrent SmartTurnOrt::warm calls create the target in all (smart_turn.rs).
  • Stats (run_call.rs, the_calls_counters_are_visible_through_stats_and_tracing): Call::stats().stages and the call stats event on brisvox::engine, captured by a minimal tracing subscriber in the test.
  • Carry-overs: the stale end mark after a Clear (transport.rs, transport_out.rs; the latter fails with the plain send), the margin fade up to the clear (playout.rs, two stepped-clock tests including a context round followed by a cut filler; both fail without the clear stamp).
  • Micro-benches (hot_path_cost.rs): numbers in docs/LATENCY.md, "Hot-path micro-benches".

How the production pipeline is proven (summary)

The production pipeline (brisvox-engine) was built against the untouched reference pipeline of brisvox-sim in four hybrid gates, each a group of engine stages in front of the reference stages, then as a whole (docs/PIPELINE.md section 9). Every gate runs the shared suite (brisvox_sim::suite) on a paused clock through a real TransportSession over the loopback transport.

ProfileAssertsRuns on
Profile::Exactthe reference's closed-form timings (1480 ms first audio, 280 ms end of turn, 660 ms of audio) plus every structural invariantthe reference only
Profile::Budgetranges (table in the suite module docs, set from measurements: docs/LATENCY.md, "Simulator Budget numbers") plus the same structural invariantsevery hybrid gate and the production pipeline

Structural invariants are identical in both: outcome Ok, determinism (a call twice gives one fingerprint), no stale audio after an interrupt, context == played text, roles, turn and interrupt counts, recovery, isolation, no leaked tasks, a flat heap. Production fingerprints are compared only to production fingerprints.

GateEngine stagesReference stagesTestAccommodations
1pump, AudioIn, VAD workerSTT, turn manager, LLM, TTS, pacer, outputhybrid_input.rsnone
2+ SttStage, TurnDriverLLM, TTS, pacer, outputhybrid_turn.rsfixed silence and adaptive (mock model), with and without guards
3+ LlmRunnerTTS, pacer, outputhybrid_llm.rssee "Hybrid gate 3 accommodations"
4+ SegmenterStage, TtsStage, TransportOutthe session harness onlyhybrid_output.rsnone
allbuild_session_pipeline / run_call (EngineFactory)noneengine_suite.rs, engine_chaos.rs, run_call.rssee "Divergences of the production pipeline from the reference"

Pre-warming has its own checks: hybrid_prewarm.rs (every warm-up and connect finishes before the first VAD stop, failing and hanging warm-ups), warm_pool.rs (model warm-up on every pool thread) and, on the production assembly, prewarm_first_turn.rs: with an STT connect of 300 ms, a TTS connect of 2.5 s and an LLM warm() of 1 s, a caller who speaks after 3 s gets the first answer exactly as fast as the second (within the 20 ms frame grid), one connect per session for the call; with a 0.5 s lead the same check sees the connect on the first turn (about 390 ms slower), so it can fail.

first_chunk_policy.rs plays the chaos suite's 100 unaffected reference calls alone (brisvox_sim::suite::chaos::unaffected_first_audio) under the production first-chunk default and under three words, prints the first-audio distribution and asserts that the default keeps every turn within the 1.5 s SLO. With three words 9 of 200 turns are over it, so the test fails on the old default (checked).

The engine's per-stage times and memory per call are measured in real time by brisvox-bench v2v --sim (docs/LATENCY.md, "Engine stage times and memory"); crates/brisvox-bench/tests/v2v.rs checks that every latency turn gives each stage, that the barge-in calls give the clear, and that the memory calls run at once and clean.

Provider contract suite

crates/brisvox-sim/src/contract.rs holds generic checks that every STT, LLM and TTS adapter must pass: stt_contract, llm_contract and tts_contract return a ContractReport (one CheckResult per check, passed(), failures()). The suite talks to a provider only through the provider traits and a harness trait per kind (SttHarness, LlmHarness, TtsHarness) that scripts the behaviour and injects faults. The sim mocks implement the harnesses directly; crates/brisvox-sim/tests/contract.rs runs all three suites against them (with a 2 s deadline from CONTRACT_TIMEOUT) and against broken doubles.

Bounds: a hang is a reported failure. Every provider call (connect, stream, warm) and every awaited event is bounded on the injected clock by 3 x io_timeout; each check as a whole is bounded by CHECK_BOUND (60 s). A call that never returns fails the check that made it, and the failure names the call. Nothing waits on a real clock, so the suite runs in paused time under simulation.

Checks:

  • STT: streaming (the first event is the interim before any Finalize, then the final with from_finalize and speech_final); cancel (after the hang-up the events close within CANCEL_PROMPT = 500 ms and nothing follows; transcripts already handed over before the hang-up are read and discarded after a 50 ms settle, because the caller cannot tell them from in-flight ones); reconnect (a mid-session drop surfaces as Disconnected, the stream then ends, and a new session works); error_mapping (refusals 401/429/500/503 map to Rejected { status }, a protocol violation to Protocol, a drop to Disconnected); timeout (see "Timeout checks" below); warm (warm() succeeds, twice, and the next connect is not slower than a cold one plus 50 ms); no_task_leak (tasks return to the baseline after a finished and after an abandoned session, the latter dropped while the provider is mid-way through a long response).

  • LLM: streaming (text deltas join to the text, tool-call fragments join to the JSON, the first fragment carries id and name, Done is last with ToolCalls, then the stream ends); cancel (first a control run proves that LlmHarness::events_sent is live: a response of three deltas plus Done must count at least 4; then the response must still be streaming at the drop, the counter must have advanced by at least the two deltas read, and at most CANCEL_SLACK_EVENTS = 2 more response events may be produced within CANCEL_PROMPT after it); reconnect, error_mapping, timeout, warm, no_task_leak as above. The no_task_leak hang-up scripts the long CANCEL_RESPONSE_EVENTS response, so an adapter that keeps pulling the response after the drop outlives LEAK_BOUND and is seen there as well as in cancel.

  • TTS: audio_and_words (PCM16 audio and words in order, word offsets non-decreasing and the audio lasts until the last word starts; the first word need not be at offset 0, because providers report alignment after leading silence; nothing for the context after its Flushed within a 500 ms quiet window); flush (words continue across text pieces, offsets strictly increase, the audio covers the words, nothing after Flushed); cancel (the first audio is read, then, after a 50 ms settle, whatever is buffered in the event channel is read and not counted, because output already handed to the caller is not the provider's to take back; then the cancel is sent and the priority cancel channel gets a Cancelled ack within CANCEL_PROMPT, at most CANCEL_SLACK_EVENTS = 2 events of the cancelled context arrive after the cancel, nothing follows the ack within the quiet window, and the session still serves the next context); reconnect, error_mapping, timeout, warm, no_task_leak as above.

  • Timeout checks (all three kinds; a stall is Fault::Stall / Fault::ConnectStall, "slow" is half the deadline, 1 s for the 2 s CONTRACT_TIMEOUT):

    1. a connect stalled for an hour yields Core(Timeout);
    2. a slow connect inside the deadline must succeed (LLM: and its response arrive whole);
    3. a first event stalled for an hour yields Core(Timeout);
    4. a first event that is slow inside the deadline must be delivered (TTS: the whole utterance);
    5. an idle gap after the first event (before_event: 1, an hour long) yields Core(Timeout), so a deadline armed only for the first byte is caught. Every Timeout is judged both ways: not later than the deadline plus TIMEOUT_SLACK (100 ms) and not earlier than the deadline minus TIMEOUT_SLACK. An adapter with a hard-coded shorter deadline fails 2 or 4 (and the lower bound), one that fails fast fails the lower bound. The timer starts when the stall begins: after the connect call starts, after the input that triggers the event is sent, or after the previous event was read.
  • Task leaks: the baseline is taken once the runtime's alive-task count has been stable for LEAK_BOUND (2 s), so tasks of earlier checks that are still winding down are not counted against the check. Limitation: the count is runtime-wide, so a task of another check that exits inside the window can offset one leaked task there. Tracking the session's own tasks needs a per-adapter hook and is not done.

  • Harness assumption: one frame is one event. Timeout check 5 ("idle gap after the first event", Fault::Stall { before_event: 1 }) stalls the second frame the harness mock sends, so a frame must map to one event. A service that puts audio and alignment (words) into one message (ElevenLabs) makes a frame two events (Audio, Word); its harness mock therefore sends the alignment of a word as its own message ahead of its audio. The suite then does not run the real message shape through the cancel, timeout and reconnect checks; that adapter's own protocol tests (tests/elevenlabs.rs) cover those behaviours with audio and alignment in one message.

  • Providers without word times. TtsHarness::emits_words() (default true) lets a TTS adapter whose service has no timestamps at all (Deepgram Aura) say so. The suite then checks that audio arrives, Flushed closes the context and no Word event is made up, and skips the comparison of words with the audio (audio_and_words, flush); every other check is unchanged. tests/deepgram_aura.rs runs the suite twice, with FlushPolicy::PerText and with the default PerTurn, and both must pass in full. Under PerTurn a bare text owes nothing, so the adapter probes the connection with a WebSocket Ping while unflushed text is on the server: a silent server is a Timeout at the deadline (the timeout check stalls after a bare text), a live one answers and a slow LLM is not cut off (tests/deepgram_aura.rs, the probe tests). Broken::DeclaresNoWords in tests/contract.rs (a double that emits words but declares none) proves that the emits_words() == false branches can fail.

  • Request-scoped providers. TtsHarness::faults_at_connect() (default true) lets a TTS adapter that opens nothing at connect (one HTTP request per text piece, the OpenAI-compatible speech adapter) say so. Fault::Reject and Fault::ConnectStall then apply to the first request of the session, and error_mapping / timeout demand the same errors as events after the first text (Rejected { status }, Core(Timeout) within the deadline) instead of from connect; the other faults and every other check are unchanged. tests/openai_speech.rs runs the suite against a mock HTTP/1.1 server on its own thread (chunked PCM, one chunk per word) and adds protocol tests: streaming before the body ends, joined pieces, connection reuse, cancel (in flight, before the headers, queued text), odd-sized chunks, WAV headers split byte by byte, refused pieces, cut bodies, idle timeout, dropped session and shutdown.

Each check must be able to fail, and tests/contract.rs checks that it does. The broken doubles and the failure each must produce:

  • Leaky (ignores the hang-up): STT cancel and no_task_leak; LLM cancel (generation goes on after the drop) and no_task_leak (the task outlives the long response's hang-up); TTS no_task_leak.
  • MisMapped (refusals lose their status, drops become protocol errors): error_mapping.
  • WarmFails, WarmHangs (warm() errors or never returns): exactly warm.
  • WarmSlows (the connect after warm() takes 200 ms longer): exactly warm.
  • ConnectHangs (connect/stream never return): every check still reports, and timeout and warm fail.
  • StaleSession (after a drop, the next connect hands out a closed session): reconnect.
  • NoInterims (STT): streaming. EmitsAfterHangUp (STT keeps talking for 5 s after the hang-up): cancel. LosesFirstDelta (LLM): streaming. KeepAlive (the LLM task lingers 3600 s after Done; the linger is longer than the suite, so tasks of earlier checks cannot exit inside the leak window and mask it): no_task_leak.
  • DeadCancel (the cancel receiver is dropped, so the send fails loudly): exactly cancel. IgnoresCancel (the cancel is accepted into a channel nothing reads, so the barge-in is lost): exactly cancel. DelayedCancel (cancels reach the provider 1 s late): exactly cancel. AckThenKeepsStreaming (the ack is sent at once, the synthesizer keeps streaming the cancelled context): exactly cancel. DiesAfterCancel (the session closes with the ack, so the next context cannot be served): exactly cancel. Stragglers(n) (n audio events of the cancelled context arrive between the cancel and its ack): n = 2 passes, n = 3 fails exactly cancel. Mutation-checked: removing the follow-up context from the check makes the DiesAfterCancel test fail, and removing the quiet window and the follow-up together makes the AckThenKeepsStreaming test fail; the quiet window alone is covered by the follow-up, which also rejects stray events.
  • Truncated (TTS sends only the first audio chunk): audio_and_words. LateAudio (the last audio of a context arrives after its Flushed): audio_and_words and flush.
  • NoIdleTimeout (the deadline covers the first event only, a later stall is waited out for good): exactly timeout, for all three kinds. A mock whose own deadline is 500 ms or 1.5 s instead of the configured 2 s fails exactly timeout (too short: cuts off the slow case, or times out early).
  • FrozenCounter (LLM harness whose events_sent never advances): exactly cancel.
  • LeadingSilence (every word reported 80 ms late) is conformant and passes the whole TTS suite. A TTS mock that is not paced (a burst of audio queued in the channel when the first chunk is read, as a fast synthesizer produces) also passes the whole suite, cancel included.
  • The mocks without with_timeout fail exactly timeout. With a connect latency above the deadline they report Core(Timeout) at the deadline (mock_connect_latency_above_the_deadline_is_a_timeout).

The TTS mock is paced in most contract tests (one 40 ms chunk per 40 ms), like a real synthesizer. The suite does not depend on it: the cancel check excludes what is already buffered, and a_synthesizer_faster_than_real_time_passes_the_tts_suite runs the unpaced mock.

Mock support added for the suite (all opt-in; existing behaviour and tests are unchanged): with_timeout(Duration) gives a mock an adapter-side deadline (a slow connect, or an event owed for longer than the deadline, becomes Core(Timeout)); StreamFault::ConnectStall and LlmFault::ConnectStall make a connect slow; clear_script() resets queued state; StreamFault::ProtocolError injects a protocol violation; MockLlm::events_sent() counts the response events handed to streams. One behaviour change: when the caller drops an STT session's input, the interims still owed are discarded (finals requested by a Finalize are still delivered), so a hang-up does not deliver a stale interim (tests/mock_stt.rs: hanging_up_discards_the_interims_still_owed and hanging_up_before_the_first_interim_yields_no_transcript_at_all, with interim latency, so that interims are owed at the hang-up).

Plugging in an adapter (as done for Deepgram, OpenAI and Cartesia). Each adapter's test crate depends on brisvox-sim (dev-dependency) and implements the matching harness:

  1. Start a local mock server that speaks the provider's wire protocol (WebSocket for Deepgram and Cartesia, HTTP/SSE for OpenAI). The adapter connects to it with a deadline of CONTRACT_TIMEOUT.
  2. Implement SttHarness/LlmHarness/TtsHarness for a wrapper holding the adapter and the server handle: provider() returns the adapter (Arc), clock() the shared clock, reset() clears the server's script, script() makes the server send the given transcripts or deltas, and inject(Fault) makes the server refuse the next connect with a status, drop the connection after N events, send a protocol violation, delay event N, or delay the connect (Fault::ConnectStall). LlmHarness::events_sent() returns how many response events the server has sent so far (cumulative): the cancel check reads it to tell whether generation went on after a drop.
  3. Write a test: let report = stt_contract(&harness).await; assert!(report.passed(), "{report:#?}"); under real time (SystemClock) with the server's own clock, or under paused time when the server runs in-process on the same runtime.
  4. Add the adapter's own negative tests the way tests/contract.rs does (a double that breaks one behaviour must fail exactly the check it targets) so the suite is known to bite.

First worked example: crates/brisvox-providers/tests/openai/. Its mock is a hand-written HTTP/1.1 chunked SSE server over a loopback TcpListener (mock.rs); contract.rs is the harness (real SystemClock, 400 ms deadline, Connection: close per response so the leak checks see a quiet runtime) plus a negative test (an adapter with 3x the deadline fails timeout); protocol.rs holds the wire-level tests; live.rs is the opt-in live test.

Limitation: the checks observe behaviour, not wire bytes. Wire-level facts (endpoints, message shapes, auth headers) stay in each adapter's own tests and docs/providers/.

Tool calling through the engine, scripted and live

crates/brisvox-engine/tests/tool_scenarios/ runs one set of scenarios (parallel calls, a chain, a booking that the model repeats, invalid arguments, a slow tool with its filler, a tool timeout, a barge-in during the tools, and the speech-to-speech stage) against the whole engine path (LlmRunner + ToolEngine) over five scripted providers (the simulator's MockLlm plain and with interleaved argument fragments, and the real OpenAI-compatible, Anthropic and Gemini adapters over loopback servers that stream each vendor's parallel-call format) and, with BRISVOX_LIVE_TESTS=1, over the real providers with loose assertions (the loose assertions are also run against the scripts, so they are known to hold before a key is involved). The pattern to copy for a new live-capable behaviour: one scenario function over a Provider, an exact branch for the script and a loose branch for a real model. Catalogue, results and the command for the live runs: docs/TOOLS.md, "Verification: parallel and chained calls".

Listen under load: talk to the bot while N other calls run

scripts/listen_under_load.py answers "how does MY call sound when the box is busy?". It starts, confined to the same CPU cores (default 4; Linux sched_setaffinity, Windows ProcessorAffinity):

  • brisvox-server with your agent file (real providers) and the WebRTC listener: your own call, in a browser at http://localhost:8000 (media on this machine's LAN address: browsers ignore loopback ICE candidates);
  • brisvox-bench engine-server --models: the production engine over mock providers with the real Silero VAD and Smart Turn models, so every background call costs what a real call costs on this machine (the providers' own work happens elsewhere anyway);

and holds N background calls per step with the capacity client pinned to other cores, printing their p50/p95 voice-to-voice, answered turns and engine CPU while you talk. Enter moves to the next step (--auto runs unattended). Keys come from the environment only.

cargo build --release -p brisvox-server --features provider-deepgram,provider-openai-compatible,provider-cartesia,webrtc,smart-turn-ort
cargo build --release -p brisvox-bench --features models
set -a; . ./.env; set +a
python scripts/listen_under_load.py --agent-file my-agent.toml --calls 25,100,200,300 --cores 4

Smoke run 2026-10-13 (Windows, --auto, no own call): 10 calls p95 836 ms, 40 calls p95 848 ms, every turn answered, engine 0.12 / 0.34 cores.

Architecture

Status: design target. Update this file whenever code diverges (code wins, then fix the doc).

Goals

  1. Lower voice-to-voice latency than Pipecat with identical providers (see LATENCY.md).
  2. Hundreds of concurrent sessions in one process, with per-session isolation.
  3. Correctness by construction: epochs, exhaustive frame handling, deterministic simulation.

Crates

crates/
  brisvox-core        Frame enum, Processor trait, Pipeline, Clock, epochs, TurnTimeline, errors
  brisvox-audio       codecs (μ-law/A-law/PCM16), resampling, buffers, output pacing
  brisvox-inference   dedicated CPU pool, ONNX Runtime models (Silero VAD, Smart Turn)
  brisvox-agent       turn-taking state machine, interruption policy, context, tool engine,
                      TTS segmenter, speculative generation, flows
  brisvox-providers   STT / LLM / TTS traits + adapters (cargo features per provider)
  brisvox-transport   transport trait + websocket, twilio, telnyx, webrtc, (sip)
  brisvox-engine      the production voice pipeline: stages (pump, STT, turn driver, LLM runner,
                      TTS, transport-out), EngineConfig, TaskGuard/GuardedSender/EventSlot/Offload,
                      PlayoutBook. Design: PIPELINE.md (Wake/EventSlot self-wake contract: sections
                      0-2).
  brisvox-server      multi-tenant session manager, admission, drain, config, HTTP API, binary
  brisvox-sim         mock providers, deterministic simulator, fixtures, leak checks
  brisvox-bench       benchmark harness binary
sdk/python, sdk/ts    out-of-process agent SDKs
bench/                pipecat baseline, results

Dependency direction: core ← audio ← inference ← agent ← providers/transport ← engine ← server. engine sits above providers and transport and below server. sim depends only on core, agent and providers (never on brisvox-engine, transport or server): the engine's own tests dev-depend on sim (hybrid tests, TaskCounter) and run its suite through a SessionFactory, so sim stays the untouched reference baseline and there is no cycle. bench may depend on anything. Nothing depends on sim or bench except dev-deps.

Time

All logic reads time through brisvox_core::Clock (SharedClock = Arc<dyn Clock>): now() (tokio monotonic Instant), wall() (logs only), sleep_until()/sleep() (concrete tokio::time::Sleep, no allocation). SystemClock in production; SimClock on a paused-time tokio runtime in simulations (fixed wall origin 2026-01-01Z). Direct Instant::now/SystemTime::now/tokio sleeps are disallowed-methods in clippy.toml.

Errors and tracing

Per-crate thiserror enums wrapping brisvox_core::Error; spans session{id} ⊃ turn{epoch} ⊃ stage{name}; credentials only as Secret<T>. Details: docs/TRACING.md.

Runtime model

  • tokio multi-thread runtime: all I/O (sockets, provider streams, timers, pipeline routing).
  • Inference pool: N dedicated OS threads (default = logical cores / 2, at least 1), bounded queue. Anything heavier than ~50 µs (VAD, Smart Turn, heavy DSP) goes here. Never on tokio workers. Two-lane queue: VAD windows (Priority::High, 60-100 µs, one per call every 32 ms) before everything else.
  • Smart Turn executor: a second, separate InferencePool (brisvox-turn-<i> threads, default as many as the VAD pool, BRISVOX_SMART_TURN_THREADS, 0 = share the VAD pool) that runs only the Smart Turn analyses (20-25 ms each) and the model's warm-up. A 25 ms analysis on a VAD pool thread made the windows of other calls queue behind it (mean wait 5 ms, p95 V2V over the SLO at 250 calls with 2.3-3.2 of 4 cores busy); on threads of their own it cannot. Queue: 32 jobs per thread, reject newest. A refused or late analysis is a missing verdict and the turn ends at its fallback deadline (the same degradation as a saturated pool before), the VAD is never involved. Server and benchmark build it with one function (brisvox_engine::offload::start_smart_turn_executor).
  • One session = a supervised group of tasks sharing a CancellationToken.

Frame flow (one session)

transport.in → decode/resample → VAD(pool) → turn SM ──► STT stream ─┐
                                   │                                 │ transcripts
                                   └──► Smart Turn(own executor) ──► end-of-turn decision
                                                                     │
                     context ◄── tool engine ◄── LLM stream ◄────────┘ (speculative start allowed)
                                                    │ tokens
                                         TTS segmenter → TTS stream → pacing → encode → transport.out
                                                                         │ word timestamps
                                                       played-text truncation on interrupt

System frames (interrupt, cancel, VAD events) travel on each processor's priority queue and overtake data frames.

Routing

Code: brisvox_core::{queue, processor}. Each processor is a task (run_processor) owning one Inbox with four bounded lanes: {downstream, upstream} × {system, data}. Neighbours hold a FrameSender bound to a direction; ProcessorContext::push(frame, dir) sends to the neighbour in that direction (dropped at the pipeline edge).

  • Receive order: any system frame first (downstream lane, then upstream), else data lanes, alternating directions when both have data. Within a lane: FIFO.
  • Lane buffers are allocated once (VecDeque::with_capacity); send/recv never allocate. One std::sync::Mutex per inbox, never held across .await; waiting via tokio::sync::Notify.
  • Capacities: QueueConfig (default 32 system / 32 data per direction; min 1). Keep data lanes small: queued audio is latency.
  • Overflow (data lanes only; system lanes always backpressure): OverflowPolicies per inbox configures audio and metrics; everything else is always backpressure. DEFAULT = audio backpressure, metrics drop-newest. REALTIME_INPUT (stage after transport.in) = audio drop-oldest (evicts the oldest queued audio frame, never reorders), metrics drop-newest. Drops are counted in QueueStats.
  • Closing: recv returns None once all senders are gone and the inbox is drained; sending to a dropped inbox fails with ChannelClosed (also releasing blocked senders). In a bidirectional chain neighbours hold senders into each other's inboxes, so drop-based close never fires: pipelines stop via Stop/Cancel system frames or the CancellationToken.
  • Cancellation: run_processor holds one cancelled() waiter for the task's life plus an atomic check per frame; it interrupts process wherever it is parked. Processor::process must therefore be cancel-safe.
  • Upstream rule: upstream traffic must be rare or lossy (metrics are drop-newest). Two neighbours each blocked pushing backpressure frames into the other's full data lane would deadlock.
  • Priority detail: downstream system frames are taken before upstream ones; blocked senders on one inbox are woken together with no FIFO order between them (one sender per direction is the normal wiring).
  • Known limitation: while process is blocked pushing into a full neighbour, newly arrived system frames wait until that push completes. Interrupt handling must interrupt the blocked push and purge stale frames (an Inbox::retain-style API is needed).
  • Cost (release, Windows, 640-byte frames, measured by perf audit): ~100 ns per hop SPSC; ~0.4–0.6 µs per frame through a 5-processor chain on 1–4 worker threads. Zero allocations per frame.

Epochs

Every turn has an epoch number. An interrupt bumps the epoch; all processors drop frames whose epoch is older. This makes "stale audio after barge-in" structurally impossible.

Epochs are turn numbers: the turn state machine starts a new epoch at every user turn start and sends Interrupt only for a barge-in (bot speaking/generating).

Mechanism (code: epoch::EpochGate, run_processor, ProcessorContext::push, Inbox::{purge_stale, epoch_watch}), applied by every task loop (processors, fan-out, merge):

  1. Gate on dequeue: an Interrupt { new_epoch } newer than the task's epoch advances it; an interrupt that is not newer is a duplicate and dropped (no echo when it arrives from both sides); any other frame with Frame::is_stale is dropped before process.
  2. Purge on advance: the task's inbox drops queued stale data-lane frames.
  3. Outgoing: push drops frames older than the task's epoch.
  4. Blocked push: when push must wait for a full neighbour, it also watches the task's own inbox; the sender side records the newest enqueued interrupt epoch (atomic fetch_max), and a push whose frame becomes stale is abandoned at once, so an interrupt is never delayed behind backpressure. Fast path (space available) registers nothing.
  5. Originating: a processor that pushes a newer Interrupt advances its own context epoch; the task loop then adopts it and purges its inbox. push also drops frames older than the newest interrupt already waiting in the task's own inbox (fast path: one atomic load). ToolResult frames are exempt everywhere (SideEffect safety). Processor::epoch_policy() defaults to DropStale; KeepAll opts out (recorders). The layers are redundant on purpose: tests were mutation-checked to fail only when every layer covering a path is removed (e.g. the merge path needs merge gate + push watch + push-side advance all removed).

Providers

Traits in brisvox-providers (details in docs/PROVIDERS.md): channel-based sessions (bounded, backpressure; dropping a handle cancels), warm() default methods.

  • SttProvider::connect -> SttSession { input: Audio | Finalize, events: Transcript { is_final, speech_final, from_finalize } | Error }.
  • LlmProvider::stream(LlmRequest) -> LlmStream of TextDelta | ToolCallDelta | Done { finish, usage } | Error.
  • TtsProvider::connect -> TtsSession { input: Text | Flush per ContextId, cancel: priority channel, events: Audio | Word | Flushed | Cancelled per ContextId | Error }. Adapters are cargo features (provider-deepgram, ...) to keep builds small. Mocks with latency profiles and fault injection live in brisvox-sim.

Server

Session manager (spawn/supervise), admission control (CPU/pool pressure + per-provider semaphores), graceful drain, health endpoints, metrics, config loading.

Session manager (code: brisvox_server::session): bounded admission (try_admit → SessionSlot, typed AdmissionError::{AtCapacity, ShuttingDown}, nothing queues; a slot reserved before drain is honoured, SessionSlot::spawn cannot fail, and the shutdown waits for outstanding reservations); one tokio task per session with the future and the classification of its result (error Display, output/error drop) under catch_unwind, so a panic ends only that session (SessionEnded::Panicked, counted); a drop guard owned by the task removes the registry entry on every exit path (ok, error, panic, cancel, abort, runtime drop). Each session gets a child of the manager-wide CancellationToken (cancelled only by shutdown; cancel_all cancels the sessions registered at that moment, not later ones). shutdown(ShutdownPolicy) runs drain → cancel → abort, each stage timed on the injected Clock, and reports leftovers. The manager is generic over the session future; the engine plugs in through SessionRunner (adapter for run_call). The server binary uses mimalloc as global allocator (cross-thread Arc<str> frees ~2x faster, examples/alloc_bench.rs).

Process lifecycle (code: brisvox_server::{settings, health, lifecycle}, docs/OPERATIONS.md): settings from BRISVOX_* environment variables; a minimal HTTP/1.1 listener (no web framework) serves /healthz (always 200 while alive) and /readyz (200 ready, 503 full or draining, JSON with active/reserved/max). SIGTERM/SIGINT (Ctrl-C/Ctrl-Break on Windows) starts lifecycle::drain: begin_drain at once (readiness flips, try_admit returns ShuttingDown), wait up to the drain timeout on the injected clock (a second signal skips the rest), then SessionManager::shutdown cancels and aborts what is left. The exit status says how it ended (0 drained, 3 cancelled, 4 aborted, 5 leaked, 1 startup failure); the runtime is then shut down with a 1 s bound (RUNTIME_SHUTDOWN_TIMEOUT) so a stuck spawn_blocking closure cannot hold the exit.

Agent definitions (code: brisvox_server::config, docs/CONFIG.md): one TOML file per agent (prompt, language, tools by name, turn/interruption/speculation settings, one provider per stage with its options table). Unknown keys are rejected; every error names the file, the key path and the line/column; cross-field checks (provider feature compiled in, provider language and sample-rate support, policy-specific keys) report together; secrets come only from the environment variables the file names (api_key_env) into Secret<String>. Pure functions map the result onto EngineConfig and the adapters' XxxConfig structs.

SDK boundary

External agent logic connects over a versioned protocol (docs/AGENT_PROTOCOL.md, crate brisvox-agent-link). The audio path never awaits SDK code; remote tools are just tools with timeouts.

  • One link per call, opened by the server (attach, from the runner: EngineRunner:: with_agent_link, BRISVOX_AGENT_URL; the agent process can be picked per call by a route named in the start message) before the pipeline starts. Three tasks per link: a connection task (owns the socket and the queues; runs a writer, control messages before events with every write bounded, and a reader that applies commands without ever waiting on the call; reopens a lost connection of an agent that declared resume with bounded backoff and a session.start carrying resume), an event pump (the call's EngineEvents ring, shared with the RTVI bridge) and an operations task (end, transfer; transfer goes through the call's own carrier binding, so the runner gives a call the telephony state even when its definition names no telephony tool).
  • Commands use the engine's own inputs (brisvox_engine::agent_input):
    • Bot lines (say) go to the turn driver, the epoch authority, as a pure machine input TurnInput::BotTurn { interrupt }. From Idle (no candidate) the machine starts a bot turn in a new epoch (TurnAction::StartBotTurn, state AwaitingResponse); with interrupt, from AwaitingResponse/BotSpeaking (no candidate), it cuts the response first (Interrupt, then StartBotTurn). Otherwise the driver holds the line (at most 8, in order) and offers it again after every frame it handles. The driver sends the line downstream as an LlmText of the new epoch; the LLM runner (nothing upstream of it produces LLM text) settles the context as at a turn end and speaks it through the round task's canned-line path (Turn::say, the same path as the fallback line): in the context at once, registered in the PlayoutBook, cut to what was heard on a barge-in. Its audio drives the machine like any response (BotAudioStarted/Ended), so the caller can barge in on it.
    • Conversation edits (context.update, tools.set) are a bounded channel the runner drains at its safe points only (a user turn's end, a bot line, a speculative start), after the late tool notes: never while a round task can touch the context. SetTools narrows the offered tools (Shared::offered); a call to a tool not offered is answered unknown_tool without running.
    • end resolves AgentLink::ended once the farewell line was played and the bot is silent; the runner then stops the call gracefully. transfer goes through the call's TelephonyOps.
  • Remote tools are registered next to the builder's own tools (the registry is copied with ToolRegistry::filtered, the tool engine rebuilt with the same EngineConfig); their Tool::call is a tool.call/tool.result RPC with the engine's ReadOnly/SideEffect rules.
  • Failure never fails the call (fallback: the agent definition). See the protocol document.

Pipeline

Code: brisvox_core::pipeline. Pipeline::new(name).then(p).then_pipeline(inner)…; processors are type-erased once when added (boxed spawn closure), never per frame. Nested pipelines are flattened at start, so nesting adds no hop and no task. start(links, &parent_token) creates one inbox + task per processor, links neighbours both ways, connects edges to Links, then sends Start downstream.

  • Graceful stop: RunningPipeline::stop(timeout) sends Stop. run_processor treats a downstream Stop specially: it processes everything already queued (in receive order), then Stop (which the processor forwards), then exits. Upstream neighbours therefore finish first, and frames pushed upstream into a stopped neighbour are discarded (debug-logged, not an error). The whole stop (including sending Stop into a full system lane) is bounded by timeout; on expiry the pipeline is cancelled, tasks aborted, and Error::Timeout (or the root-cause failure, if any) returned. A Stop drain stops early on cancellation or a queued Cancel frame.
  • Cancel frame: processed (forwarded) immediately, then the task exits; queued frames dropped. RunningPipeline::cancel() / the parent token: every task exits at once.
  • Supervision: wait() returns the root-cause processor error or Error::ProcessorPanicked(stage); the first failure cancels the rest. ChannelClosed from a neighbour of the failed processor is secondary and replaced by the real cause. Cancel-safe (failure kept on the handle). A panic stays inside its task (session isolation). Dropping a RunningPipeline cancels and aborts its tasks.
  • Leak checks: brisvox_sim::leak::TaskCounter (tokio num_alive_tasks) waits on the Clock until the runtime is back to its baseline task count.

Parallel branches

Pipeline::then_parallel([branch pipelines]) wraps the branches in a fan-out unit and a merge unit (code: pipeline/parallel.rs); start wires the whole graph (units built first, then linked), nested pipelines and nested parallel blocks included.

  • Every frame entering the block goes to every branch (data clones share buffers). At most 64 branches; an empty branch or block is an error, an empty nested pipeline is skipped.
  • Each branch reaches the merge (and, upstream, the fan-out) through a sender tagged with its index (Inbox::sender_from/recv_from). Copies of broadcast System/Control frames collapse via a shared bounded ledger (256 entries/direction) that records, per broadcast, which branches have passed it (u64 mask): a branch's copy matches the oldest equal broadcast it has not passed yet, so a branch that swallows frames cannot make later broadcasts disappear.
  • Stop is forwarded once every branch has passed it on (then the merge drains and ends); Cancel on the first copy, after which the merge forwards nothing more (the next stage is shutting down) and ends once every branch has cancelled; every other broadcast frame on the first copy, so an interrupt is never held back by a slow branch.
  • Frames originating inside a branch, and all data frames, pass through: data broadcast to N pass-through branches arrives N times, so branches that should not emit data must consume it.
  • Upstream is symmetric (merge broadcasts to branch tails, fan-out collapses copies).
  • Branches must forward the System/Control frames they receive (the general processor contract). A branch that swallows them leaves incomplete ledger entries (evicted oldest-first, warned once); other branches' copies are still forwarded exactly once.
  • Head-of-line: broadcasts go to branches one after another, so a full branch inbox stalls the fan-out for every branch; the merge's upstream broadcast multiplies upstream load by N. The pairwise upstream/downstream backpressure caveat (see Routing) applies at fan-out↔head and tail↔merge.

Frame table

Code: brisvox_core::frame. Frame ≤ 64 bytes (compile-time assert; exactly 64 on x86-64 Windows, measured; Linux/macOS checked by CI once CI covers them). Direction: ↓ downstream (transport.in → transport.out), ↑ upstream (back towards transport.in). Overflow policies are the intended ones; the processor core implements and tests them.

VariantLevelDirProducerConsumersOverflow policy
System::StartSystem↓pipeline/sessionall processorsnever dropped (system queue)
System::StopSystem↓sessionall processorsnever dropped; drain data first
System::CancelSystem↓↑session, fatal errorall processorsnever dropped; drop queued data
System::Interrupt{new_epoch}System↓↑turn state machineall processors (purge older epochs)never dropped
System::UserStartedSpeaking{at}System↓↑VADturn SM, interruption policy, timelinenever dropped
System::UserStoppedSpeaking{at}System↓↑VADturn SM, Smart Turn, timelinenever dropped
System::UserSpeechFalling{at}System↓VAD (opt-in: VadSetup::with_early_analysis)TurnDriver (consumes it, never forwards; starts an early Smart Turn analysis); other stages forward it unchangednever dropped; sent once per fall, always BEFORE the stop of the same fall, at = exactly that stop's time (needs speech_pad <= min_silence, else none is sent); a fall cancelled by speech sends no stop
System::Error(Box)System↑any processorsession supervisornever dropped
System::Wake{token}Systemselfa helper task of the stage, via ProcessorContext::self_senderthe same stage onlynever dropped or purged; never forwarded (push discards it); payload travels in the stage's EventSlot
Control{UserTurnStarted}Control↓turn SMSTT, context, timelinebackpressure; never dropped by overflow, subject to epoch purge
Control{UserTurnEnded}Control↓turn SMLLM runner, context, timelinebackpressure; epoch purge
Control{LlmResponseStarted/Ended}Control↓LLM stageTTS segmenter, contextbackpressure; epoch purge
Control{TtsStarted/Ended}Control↓TTS stagepacing, context (played text)backpressure; epoch purge
Control{Flush}Control↓turn SM, LLM stagesegmenter, TTS, pacingbackpressure; epoch purge
Control{UtteranceFinal}Control↓STT stageturn SMbackpressure (user side: KeepAll)
Control{UtteranceStarted{utterance}}Control↓TTS stage, right before the first audio of each utterancetransport-out stagebackpressure; epoch purge; same data lane as the audio, so it stays in order with it
Data::Audio (input)Data↓transport.indecode/resample, VAD, STTdrop oldest (stale input is useless) + metric
Data::Audio (output)Data↓TTSpacing, transport.outbackpressure (paced at real time)
Data::TranscriptData↓STTturn SM, contextbackpressure
Data::LlmTextData↓LLM stagesegmenter, contextbackpressure
Data::ToolCallDelta(Box)Data↓LLM stagetool enginebackpressure
Data::ToolResult(Box)Data↓tool enginecontext, LLM runnerbackpressure; exempt from epoch purge
Data::DtmfData↓transport.inturn SM / flowsbackpressure
Data::Word(Box)Data↓TTS stageplayed-text tracker (brisvox_agent::played)backpressure; exempt from epoch purge
Data::MetricData↑any stagetimeline / metrics sinkdrop newest + counter (observability must not stall audio)

Epochs: System frames carry none (never stale). Every Control and Data frame carries the TurnEpoch (u32) it was produced in. Frame::is_stale(current) is the purge predicate for interruptions: older epoch → drop, except ToolResult (a SideEffect call may already have run; its result must reach the context, TOOLS.md item 8). The turn state machine owns the current epoch; Interrupt carries the new one and every later frame (starting with UserTurnStarted) is tagged with it. AudioFrame fields are private: the buffer is always whole PCM16 samples; sample_count() and duration() are derived, never stored; seq is u64. Debug of frames prints sizes only, never samples or user/LLM text.

Production pipeline (brisvox-engine)

One call is brisvox_engine::run_call(session, builder, cancel) over a TransportSession: it builds the stages (build_session_pipeline), starts them, and runs the transport pump. The design and the as-built notes of every stage are in docs/PIPELINE.md; this is the overview. The reference pipeline of brisvox-sim stays untouched as the golden baseline the engine is tested against (docs/TESTING.md, "How the production pipeline is proven").

Stages

#StageEpochsDoesHeavy work
-pump (task, not a stage)retags input to epoch 0drains TransportSession::recv: audio into stage 1 (drop-oldest, counted), marks, positions and leads into the PlayoutBook, end marks as upstream TtsEnded, hang-up as Stopnone
1AudioIn + VAD workerKeepAll, epoch 0resamples to 16 kHz and forwards at once; a recycled copy goes to the VAD worker, which batches windows into one job on the pool and sends backdated UserStartedSpeaking/UserStoppedSpeaking (opt-in UserSpeechFalling)VAD on the inference pool
2SttStageKeepAll, epoch 0connects at start; audio to the provider (try_send, drops counted); at a VAD stop forwards the stop, then sends Finalize; exactly one UtteranceFinal per Finalize (real, or synthetic on timeout or a dead session)none
3TurnDriverKeepAllthe only epoch authority: owns the TurnMachine, the Smart Turn ring, the finals gate, the response backstop and the barge-in guards; emits Interrupt, UserTurnStarted, the final Transcript, UserTurnEndedSmart Turn on the pool
4LlmRunnerDropStaleone round task per epoch: request (build_window), streamed text, tool loop, fillers, played-text truncation at the next turn (docs/TOOLS.md, "Tools in the production pipeline")a large assembler finish or request build on the pool
5SegmenterStageDropStalespeakable chunks as raw slices of the response (the first after one word)none
6TtsStageDropStaleone provider context per round (utterance), audio cut into 20 ms frames at the call's output rate, word timings, TtsEndednone (per-chunk resampling, measured < 50 us)
7TransportOutKeepAll, filters itselfframes to TransportCommands: audio, marks, Clear; FirstAudioOut, upstream TtsStartednone

Every queue is bounded with a stated overflow policy (docs/PIPELINE.md section 1); every helper task is a TaskGuard cancelled with its stage; every network wait has a timeout on the injected Clock (connect 5 s, LLM first token 8 s, idle 15 s, Finalize answer, clear send).

Epoch ownership

  • The TurnDriver alone advances epochs: a new epoch at every user turn, Interrupt{new} only for a barge-in, always before UserTurnStarted{new}. Stages 1 and 2 (the user side) stay on epoch 0 and keep everything; stages 4 to 6 (the bot side) drop stale frames; TransportOut drops and counts them itself.
  • Helper tasks (LLM rounds, TTS readers, timers) send through a GuardedSender that checks the stage's epoch right before each send, so a cancelled round cannot leak a late frame.
  • ToolResult frames are never purged (SideEffect safety, docs/TOOLS.md item 8).

Clear on epoch advance

The transport's own epoch ledger advances only through Clear{epoch}, but the turn epoch advances every turn. TransportOut therefore sends Clear{epoch} before the first audio of any epoch newer than the last one it cleared to (on an idle transport it cuts nothing; Clear is idempotent for an epoch). Without it the second turn's audio would be dropped as stale by the transport. On a barge-in the driver clears first, on the fast path (below), and TransportOut's own Clear is a no-op.

No second pacer

TransportSession::open already contains the OutputPacer that releases one 20 ms frame per tick. The pipeline's tail is TransportOut, which only translates frames: there is no pacing stage in the engine (docs/PIPELINE.md D8; a test shows the session's pacer alone spaces the output).

PlayoutBook

PlayoutBook (engine, playout.rs, std Mutex never held across an await, a watch for settle notification, bounded) is the record of what the caller heard, keyed by (epoch, round): the runner writes each round's context message index, the TTS stage its utterance id and the exact text it sent, TransportOut and the pump the word timings, released positions, echoed marks, clears, losses and client leads. At the next turn (and at stop) the runner waits, bounded, for the cut utterances to settle and cuts the context to what was heard (PlayedText::heard, apply_truncation): the context holds what was played, and only that.

Side channels

Frames are the only data path, except for these shared objects (the first three are listed in the engine design notes):

  1. PlayoutBook (above).
  2. The CommandSender the driver uses for the fast-path Clear at a barge-in: applied inline in the transport, bypassing the command queue, before the Interrupt travels downstream (docs/PIPELINE.md section 3; the book learns of the clear before the command).
  3. Arc<Mutex<Context>>, the conversation, written by the runner and read by the sim probes.
  4. Observation only: the TimelineRecorder (every stage marks its turn events; the VAD worker and the STT stage mark the open user turn) and the RTVI event observer (Observed<P> on five stage inputs, a drop-oldest broadcast ring). Neither can block or reorder a frame.

Latency budget (measured)

Engine overhead at p95, from the engine's own timeline in the real-time bench: gated turn end to LLM request <= 3 ms, first TTS audio to transport command <= 2 ms, barge-in decision to applied Clear <= 1 ms. All three are met by orders of magnitude (docs/LATENCY.md, "Engine stage times and memory"); prewarming keeps every connect and warm-up off the first turn (tests/prewarm_first_turn.rs).

Known limits

  • Measured on a shared Windows machine; the Linux numbers for the comparisons with Pipecat and LiveKit and the strict 2 ms scheduler-gap bound on a quiet machine are still to be recorded.
  • Speculative LLM start is opt-in and off by default; the default-on variant is a follow-up.
  • Pre-rendered filler clips play through the TTS stage's clip path, but only inside a live provider session; text TTS is the fallback.
  • A mid-utterance barge-in has no end mark yet: its heard position is the first position report after the clear (playout module docs).
  • Live providers are tested against recorded protocols and mocks; the live variants need keys. The bench's VAD is the energy detector and its Smart Turn a fixed model.
  • The server binary hosts the transports, RTVI included, but has no tool registry or Smart Turn model yet. Every call's providers run behind the resilience layer (brisvox_server::resilient).
  • RTVI send-text with audio_response=false and llm-function-call events are not supported .

Long-call context

brisvox_agent::window bounds the input that a caller builds for an LLM request, over a call of any length. The full Context is never modified: each request gets a new, smaller Context built by build_window(ctx, &WindowPolicy, &dyn TokenEstimator, summary) -> Window.

Not yet wired into the running session. Nothing in the live pipeline calls build_window: the reference respond() in brisvox-sim (crates/brisvox-sim/src/reference.rs) still sends the full history. Until that call site exists, the LLM input of a live call is not bounded, and the claim above holds for the library only. Wiring it in is a follow-up; the window tests below measure the Window value, not an LlmRequest.

  • Turn: a user message plus everything after it up to the next user message (assistant text, tool calls, their results, late notes). Messages before the first user message form one turn. Leading system messages are the header, always kept first and never clipped.
  • Cut: the window keeps the most recent turns, oldest dropped first. It keeps up to WindowPolicy::keep_recent_turns (minimum 1) turns and, within max_tokens (estimated tokens, header and summary included), shrinks that count as far as needed, never below the latest turn. A tool call and its results are always in the same turn, so a cut never splits them.
  • Clipping: a message whose estimate exceeds WindowPolicy::max_message_tokens (default: a quarter of max_tokens) is sent clipped: its text is cut at a character boundary, the longest prefix that fits, and CLIP_MARKER ( [clipped]) is appended. The transcript keeps the full text. Tool call arguments are not clipped, so a message whose arguments alone exceed the limit still does. The summarizer receives the same clipped messages.
  • Summary: Summary { text, covers_until } covers the messages from the end of the header up to covers_until (an index in the full context that must be a turn start). The window inserts it as a system message (SUMMARY_PREFIX) right after the header, counts it in the budget, and clips it to WindowPolicy::max_summary_tokens (default: a quarter of max_tokens) if a caller passes a longer one.
  • Summarizing: needs_summary(ctx, policy, est, summary) returns the message range that the window drops without a summary yet (or None). The range is at most one round long: whole turns whose sent size fits WindowPolicy::max_summary_input_tokens (default: max_tokens), always at least one turn. refresh_summary(...) loops over the rounds, calling a Summarizer (a trait returning a BoxFuture; no LLM implementation lives here) until the summary covers exactly the dropped turns. Each call gets the current summary, the clipped messages and the size limit max_summary_tokens, which it should compress to. Summaries are produced asynchronously by the caller; while a summary lags, Window::uncovered names the dropped messages it does not cover.
  • Summary limit: refresh_summary never stores a summary whose message estimates to more than max_summary_tokens. It returns WindowError::SummaryTooLarge and keeps the previous summary. The window then stays within budget but leaves the newer dropped turns uncovered, and every later call repeats the same round, so the caller decides how often to retry. This is a deliberate choice over silent truncation, which would drop the newest facts of a summary.
  • Over budget: the window only exceeds max_tokens when a piece that cannot be dropped is larger than the budget alone: the header (the prompt), or the latest turn after clipping. That piece is still sent, and Window::over_budget reports it. Callers should log it and shorten the prompt. The number of messages in one turn is not limited here.
  • Errors: an invalid full context (Context::validate) or a summary that does not end at a turn start is rejected (WindowError); the transcript is left untouched.

Acceptance (crates/brisvox-agent/tests/long_call.rs): a 60-minute call of 360 turns (about 15 words from the caller, about 40 from the agent, a tool call with its result every tenth turn) is fed turn by turn. After every turn the test refreshes the summary, builds the window, and asserts that the estimated tokens stay within budget, validate() passes, the prompt comes first, the latest caller message is present, tool calls are whole, the summary message in the window is exactly SUMMARY_PREFIX plus the stored text, and the summarized user turns plus the window's user turns equal all user turns in order. Each summarizer call of a turn must receive the summary the previous call returned (the first one, the summary from before the turn), with covers_until equal to the start of its range, and the limit max_summary_tokens. Three policies run: 4000 tokens with 8 recent turns (the summary covers 352 turns at the end), 4000 tokens filled by the budget alone (at least 3500 tokens used, never more than 4000), and a tighter 1200-token window.

Further tests in the same file and in window.rs:

  • A summarizer that extends its summary every call (a_summarizer_that_keeps_growing...): the summary is rejected once it outgrows its limit, the window stays within budget for all 360 turns, and the rejected summary replaces nothing.
  • A summarizer that rejects inputs above 200 estimated tokens, after a full outage: the backlog is caught up in several rounds, with no single input above the limit.
  • A 40 KB tool result in the latest turn: the window is clipped to within budget, not over it, and the clipped text stays on character boundaries.
  • A caller-supplied 40 KB summary is clipped in the window, and the caller's summary is untouched.

Each of the new behaviours was checked by reverting its fix once: disabling clipping, the summary limit, or the round limit, dropping the previous summary from the summarizer call, and leaving the summary text out of the window message each made the matching tests fail.

Conversation flows

brisvox_agent::flows (flows.rs, flows/def.rs, flows/manager.rs, flows/render.rs, flows/example.rs) is the equivalent of the basics of Pipecat Flows. A conversation is a graph of named nodes; the tool results of the LLM move it from node to node. It is data plus a state machine: no IO, no tasks, and no tool execution of its own. Tools run in the existing ToolEngine, history lives in the existing Context, summaries come from the existing window::Summarizer.

Not yet wired into the running session. The LLM runner is the caller; until it exists the module is exercised by crates/brisvox-agent/tests/flows.rs against the real ToolEngine.

Concepts

ItemWhat it is
FlowImmutable, validated, shareable (Arc<Flow>): nodes, global tools, a role shared by the nodes, the ToolRegistry it was validated against (Flow::registry(), the registry the engine must run). Built with FlowBuilder::build(registry).
NodeA step: role messages (default: the flow's) and task messages (the prompt), the tools it offers (ToolBindings), a ContextStrategy, pre_actions, post_actions, respond_immediately, keep_last_user.
ToolBindingA registry tool as one node offers it, plus a Transition and what to capture. A flow-level global_tool is offered in every node; a node's own binding of the same name wins.
TransitionGoto(node) (fires on success), Rules(Vec<Rule>) (first Condition that holds: Always, Success, Failure, ResultEquals, ResultPresent, ArgEquals, JSON pointers), Custom(fn(&ToolOutcome) -> Option<NodeId>).
ContextStrategyAppend, Reset, ResetWithSummary: what happens to the history when a transition enters the node.
ActionSay(text), EndCall, Custom{name, payload}. Named by the flow, performed by the runner.
FlowManagerThe per-call state: current node, generation, state map. One per call, driven by &mut self from the runner's round task.

FlowBuilder::build rejects (FlowDefError): no nodes, duplicate nodes, unknown initial node, a node without prompt text, a duplicate or unregistered tool in a scope, and a static transition target that is not a node. Custom targets cannot be checked statically and are checked at run time.

The runner protocol (per round)

flow.start(&mut ctx)                     // once; installs the initial prompt; returns the pre actions
loop per LLM round:
    request  = (ctx, flow.tool_specs())               // the CURRENT node's tools, name order
    ids      = flow.next_tool_call_ids(&ctx, epoch, n) // NOT ctx.next_tool_call_ids (see below)
    push the assistant message of the response
    screened = flow.screen(calls)                       // tools outside the node are refused here
    exec     = engine.execute(screened.take_admitted(), epoch, &cancel).await
    applied  = flow.apply(&mut ctx, screened, exec, Some(&summarizer)).await?
    // results appended; applied.transition = Some(Entered{pre/post actions, respond_immediately})
    // when a transition ran; applied.respond says whether to run another round

FlowManager::transition_to(ctx, node, summarizer) is the explicit transition for application code (a DTMF key, a timer). It needs every tool call answered.

apply is two steps in one call, and the runner can take them apart:

applied = flow.apply_results(&mut ctx, screened, exec)?   // sync: append, capture, DECIDE
// applied.pending = Some(node): play flow.pending_pre_actions() (the node's Say) now ...
entered = flow.resume_pending(&mut ctx, Some(&summarizer)).await?   // ... while this summarizes
// if ctx.context_replaced: drop the window Summary, the round->msg_index map, play-out indices

After the decision flow.pending_transition() is set until the node is entered. While it is set the flow offers no tool (tool_specs() is empty and screen refuses every call), so a dropped apply (barge-in, session cancel) cannot leave the node being left offering a SideEffect tool again. The runner finishes with resume_pending (or the next apply does it: the round it screens is refused whole, then the pending node is entered), or drops the decision with abandon_pending.

Rules and decisions

  • Message 0 is the node prompt. start installs it, and every transition replaces it (Append) or rebuilds the context around it (Reset*). Role and task messages of a node are rendered into one system message joined by a blank line (FlowManager::system_prompt() is what message 0 holds). It is a leading system message, so window::build_window always keeps it. This is a deliberate difference from Pipecat, whose APPEND adds the new task messages at the end of the history: an appended system message is dropped by the window after enough turns and the model loses its instructions (test the_node_prompt_survives_the_long_call_window_...). The manager owns message 0; put global instructions in the flow role.
  • Strategies. Append keeps the history and swaps message 0. Reset leaves the prompt alone (the LLM then sees no user message, so such a node should respond immediately and carry what it needs in its prompt through state placeholders). ResetWithSummary asks the summarizer for the newest history (whole turns from a user message, at most summary_input_tokens, the latest turn always; message 0 is not included) with summary_max_tokens as the size, and the context becomes the prompt plus a system message FLOW_SUMMARY_PREFIX + summary (clipped to the size). No history: plain Reset. No summarizer, an error, an empty reply or no answer within summary_timeout (default 8 s, on the injected clock): the transition still happens and falls back to Append, so history is never lost silently (Entered::fell_back()). The summary chains: a flow summary at message 1 (from an earlier ResetWithSummary followed by Append nodes) is passed to the summarizer as previous and is not part of the input slice, so a second summary extends the first even when the newer history is cut to whole turns; with nothing new since, the old summary is carried over without a summarizer call. Input messages are clipped like the window clips them (SUMMARY_MESSAGE_MAX_TOKENS, 1000), and a history with no user message is cut at assistant messages, so the input always respects summary_input_tokens (plus the latest turn).
  • Latency of ResetWithSummary. The summarizer is an LLM call on the voice path: up to summary_timeout of silence with apply. Use apply_results + resume_pending and play pending_pre_actions() (the node's Say, for example "One moment please.") while it runs, set a shorter FlowConfig::summary_timeout (1-3 s), or use Reset/Append for nodes where the summary is not worth it. A timeout costs nothing but the summary: the history is kept.
  • keep_last_user. Reset and ResetWithSummary leave [prompt] or [prompt, summary]: no user message, which Anthropic rejects. Node::with_keep_last_user(true) copies the newest user message of the history behind them (the example's confirm and done do), so the context is request-shaped. Off by default, so existing flows keep their exact contexts.
  • A reset invalidates indices. Reset and a ResetWithSummary that summarized replace the whole context (Entered::context_replaced, Applied::context_replaced). Anything the runner keys by message index refers to messages that no longer exist: the window Summary (covers_until is an index; kept, the next build_window fails with InvalidSummary and every later request of the call would fail), the round to message-index map of the play-out book, and the play-out book's truncation indices. The runner must drop them when context_replaced is set (test a_reset_invalidates_a_kept_window_summary_unless_the_runner_drops_it). Append (also a summary that fell back to it) keeps indices; they stay valid. A flow summary and a window summary are otherwise independent: the flow summary is an ordinary system message at index 1, which the window keeps as part of the header.
  • Atomic transitions and cancellation. The node, the generation and the context change only after the whole transition succeeded; the only await (the summarizer) comes before any mutation. Everything before it (results appended, state captured, the transition decided) is synchronous and recorded in pending_transition(), so dropping apply at the await loses nothing: the result of a SideEffect call is in the context, the decision is kept, and no tool is offered until it is completed or abandoned (test a_dropped_apply_keeps_the_decision_...).
  • Which transition wins. For the calls of one response in issue order, the first that fires and names a node of the flow. Later ones are counted (Applied::ignored_transitions). A target outside the flow (only possible with Custom) is not an error: it is reported in Applied::rejected, logged, and the node does not change. A failed call (an is_error result: timeout, tool_failed, unknown_outcome, ...) never fires Goto, so the model can retry; Rules can route on it. Answers that are not tool results never transition: when Execution::budget_exhausted is set no transition is decided. An error result is routable only if the tool ran: the allow-list is tool_failed, timeout, unknown_outcome and result_too_large (TOOL_RESULT_ERRORS in manager.rs). Every other error is skipped before any Condition or Custom function sees it: budget_exhausted, stale_turn, interrupted, duplicate_call, invalid_arguments, arguments_too_large, too_many_calls, unknown_tool, store_full, a malformed call (invalid tool call), missing_result and tool_not_available (the flow's own), and any kind not on the list (a refusal the engine gains later is not routed until someone adds it; a new tool-run kind has to be added too). So a Failure or Always rule cannot enter an apology node, or wipe the context, because the model sent arguments that fail the schema, the retry budget was spent or the idempotency store was full: the model just retries. A call that did run and failed is routable, unknown_outcome included: a SideEffect call that timed out may still be running, and Always follows it like any failure; use Success or a ResultEquals rule after a booking.
  • Refused tools. screen answers a call to a tool the current node does not offer with {"error":"tool_not_available","tool":...,"detail":"... Available tools: a, b."} (same shape as the engine's errors) and does not pass it to the engine, so it cannot run and spends none of the engine's retry budget. The model can call a tool it remembers from an earlier node; this is the guard. Calls the assembler could not parse go to the engine, which answers them. Screened::merge restores issue order; apply does it itself.
  • SideEffect tools run once. The engine's idempotency key (session, epoch, call id) already guarantees it per id. The flow adds three things. A Screened is consumed by apply, so a round cannot be applied twice. A round screened before a transition is stale after it (Applied::stale: results of calls still in the context appended, nothing captured, no second transition; results whose calls a reset removed are dropped, Applied::dropped_results, so the context stays valid; a transition cannot intervene while the round's assistant message is in the context, because transition_to needs every call answered, so a stale round only arises when the runner screens before it pushes that message). The tool of a node that was left, or that is being left (pending_transition()), is refused. A transition lost to a late result is recoverable: apply_recorded_result(call, content, is_error) evaluates the current node's binding on a result that arrived after its slot was answered (the late note is already in the context) and makes the target pending. The fourth is an unsettled call: a SideEffect call whose result is timeout, unknown_outcome or interrupted may still be running (or have happened), and the model's retry would carry a fresh call id that the engine's idempotency key does not match. The manager remembers the tool (unsettled_tool), stops offering it (tool_specs, allows_tool) and answers a call to it with tool_not_available and the detail "ended without a known outcome ... Do not call it again". The tool is released when apply_recorded_result receives a real result of the call (a success, or a tool_failed: the effect did not happen) or when the node changes; a repeated timeout/unknown_outcome keeps it withheld and engine refusals change nothing. Conservative choices: any interrupted of a SideEffect tool counts (including the rare one before the tool started), and a unknown_outcome that never gets a late result keeps the tool withheld in this node for the rest of the visit (hand the caller to a checking node or a human with a Rules binding on unknown_outcome). The fifth is a hazard the flow had to close: Reset removes tool calls from the context, and Context::next_tool_call_ids counts calls in the context, so it would hand out t<epoch>.0 again in the same turn, and the engine would treat a new SideEffect call as the earlier one and not run it. FlowManager::next_tool_call_ids continues after the removed calls (it remembers a count for the newest epoch only; an older epoch is refused by the engine as stale anyway; the count accumulates over several resets of one turn). The runner and the assembler must mint ids through it while a flow runs.
  • State. capture_args() merges the top-level arguments of every successful call into a state map; capture_result(key) stores the result (JSON when it parses). Bounds: MAX_STATE_ENTRIES (64) keys, MAX_STATE_KEY_BYTES (64) per key and MAX_STATE_VALUE_BYTES (4096) per value; one over the bound is dropped and counted (state_dropped()), an existing key can be overwritten. A value dropped for its size also removes the key's earlier value, so the prompt of the node a new result led to never shows data of an older call as current (the placeholder stays literal). Keys of capture_args are chosen by the model: an argument whose name is a capture_result key of the flow is dropped (counted), so it cannot shadow a captured tool result. Task messages use {key} or {key.sub.0} placeholders; unresolved ones stay literal. Substituted text is cut at 256 characters, control characters become spaces, and it is never scanned again. Values come from caller speech via the LLM and end up in a system message, so constrain the schemas of tools that feed prompts (enums, maxLength). Debug of the manager prints state keys, never values.
  • Pre and post actions are returned in Entered, not performed. Say is a canned utterance that is not added to the context (unlike the tool filler, which is, see TOOLS.md); EndCall as a post action means "hang up after this node's first response was played". respond_immediately false makes Applied::respond false: the runner ends the turn and waits for the caller.
  • Time. Only the summarizer wait is timed, with Clock::sleep from the injected clock. No Instant::now. Everything else is pure and synchronous.

Example

flows::example::restaurant_flow(Language::En | Language::Tr, &Arc<Restaurant>) over an in-memory Restaurant (one table per date and time, some times always full) and three tools: check_availability (ReadOnly), make_reservation (SideEffect), end_call (ReadOnly, Ack).

greeting --check_availability--> confirm (ResetWithSummary, pre: Say) --make_reservation--> done (Reset, post: EndCall)
   |  free=false                    ^   |  status=unavailable (lost the race)
   '--> alternatives (Append) ------'   '--> alternatives
 end_call (global) --> farewell (Append, post: EndCall)

Tests

crates/brisvox-agent/tests/flows.rs (57 tests; paused time, real ToolEngine, a scripted Summarizer) calls Context::validate() and checks that message 0 equals system_prompt() after every step. Unit tests are in flows/render.rs, flows/def.rs, flows/manager.rs.

  • English and Turkish reservation: transitions by Rules, all three strategies, state in prompts, pre and post actions, the exact messages the summarizer received.
  • Busy slot to alternatives (Append keeps history) and back; a booking lost to a race by a second session returns to alternatives with one booking in total; the global tool from any node.
  • Strategies on a generic flow; summary fallbacks (none, error, empty, timeout: the wait is summary_timeout of simulated time); nothing to summarize; clipping and whole-turn input cut.
  • Invalid transitions: unknown target rejected and nothing changes, failed tool stays, first of several wins, stale round, use before start, pending calls, foreign message 0, unknown node, dangling references in the definition.
  • Refused tools (never run, issue order kept, unparseable calls still reach the engine).
  • SideEffect once: a transitioning make_reservation runs once, a replay of its id at the engine returns the recorded result, a call in the left node is refused, and the id hazard above (a second SideEffect call after a Reset in the same turn does run).
  • State bounds, prompt sanitising, refresh_prompt, and the window keeping the node prompt after an Append transition over 40 turns.
  • Review fixes: stale_turn (real engine) and budget_exhausted answers never fire Failure, Always or Custom; hand-built Executions with too few results (missing_result, issue order, refusals interleaved), too many, late_notes (after the results; dropped by a Reset); a stale round whose calls a Reset or summary removed drops its results; apply dropped mid-summary (decision kept, no tool offered, resume_pending, next-round and abandon_pending paths, one booking); apply_results returns the pre actions before the summarizer is asked; apply_recorded_result; build_window after Append (kept summary valid) and after Reset* (kept summary InvalidSummary, none fine); keep_last_user; a second summary chaining the first, carried over when nothing is new; summarizer input clipped; key bounds and result-key collisions.
  • Review fixes (round 2, each test fails without its fix): a real invalid_arguments answer (and a table of arguments_too_large, too_many_calls, unknown_tool, store_full, malformed and unknown kinds) never fires Failure, Always, Custom or a SideEffect binding while the tool-run kinds do; an oversized capture removes the key's older value (prompt keeps the placeholder); a timed-out SideEffect tool (real engine, 5 s tool, 1 s timeout) is withheld, refused on retry (one run), and released by a recorded late result, a real failure or a node change; five SideEffect calls in one turn across Reset / ResetWithSummary / Reset / Reset get ids t1.0..t1.4 (all ran), a new epoch restarts at zero, and an older-epoch leftover behind a newer call does not move the offset; the budget_exhausted flag stops a transition that a genuine success asks for, and without the flag the same success transitions.

Known gaps

  • The summary input covers only the newest summary_input_tokens of history (the earlier flow summary is chained as previous). A summary the window made for the runner is not in the flow summary's input: the flow summarizes what is in the context, and a reset makes that window summary invalid anyway (see "A reset invalidates indices").
  • Reset/ResetWithSummary without keep_last_user yield a context with no user message. Providers that need one (to verify: Anthropic) need that option or a converter placeholder.
  • A SideEffect call that timed out (timeout, unknown_outcome, interrupted) keeps the node, but its tool is withheld (unsettled_tool) until apply_recorded_result gets the real result or the node changes, so a retry with a new id cannot book twice. The flow cannot tell whether the first call ever finishes: if no late result comes (unknown_outcome), the tool stays withheld for the visit. Route unknown_outcome to a checking node with a Rules binding. Only the runner can feed late results to apply_recorded_result; until it does, a timed-out booking is never retried by the flow. A failed call that ran (tool_failed) does not withhold the tool.
  • Which timestamp or runner event triggers resume_pending after a cancelled apply is the runner's job; the flow only guarantees that nothing runs in the meantime.
  • A node whose context still holds tool calls from an earlier node but offers no tools (the done node) may be rejected by providers that require tool definitions next to tool-use blocks (to verify: Anthropic); Reset/ResetWithSummary nodes avoid it by dropping the calls.
  • A refusal does not spend the engine's malformed-call budget; a model that keeps calling an unavailable tool is bounded only by the runner's max_tool_rounds.
  • Pipecat's function actions (a flow-invoked handler) and dynamic node factories are not provided; use Action::Custom, transition_to and set_state from application code.

Turn-taking

Code: brisvox_agent::turn (TurnMachine, TurnPolicy, TurnState, TurnInput, TurnAction). A pure, synchronous machine: every input carries its time (tokio::time::Instant, a reading of the injected clock); it does no IO, reads no clock and owns no task. handle(input, &mut out) appends the actions the caller must carry out:

ActionCaller does
StartUserTurn{epoch}emit Control::UserTurnStarted
RequestAnalysis{id}run Smart Turn on the inference pool; feed Verdict{request: id, ..} back (a late verdict is ignored, not cancelled)
EndUserTurn{epoch, reason}emit Control::UserTurnEnded; start the response, except for EndReason::Hangup
Interrupt{new_epoch}emit System::Interrupt; always followed by StartUserTurn in the same epoch
ArmTimer{deadline} / CancelTimerone timer slot: ArmTimer replaces any pending deadline; deliver Tick{now} at (or after) it

State diagram

                 VadStart (first turn: epoch 0; each later user turn: epoch + 1)
   +------+ ------------------------------------------> +--------------+
   | Idle |                                             | UserSpeaking |
   +------+ <-- BotAudioEnded (current epoch) --+       +--------------+
      ^                                         |        |          ^
      |                                         |  VadStop          |  VadStart: resume the
      |                                         |  (Adaptive: a     |  same turn (same epoch),
      |                                         |   RequestAnalysis)|  cancel the timer
      |                                         |        v          |
      |                                         |   +------------+  |
      |                                         |   | UserPaused |--+
      |                                         |   +------------+
      |                                         |        | Verdict complete (ends at once, or
      |                                         |        | waits for the STT final), Tick at the
      |                                         |        | deadline, or a punctuated final
      |                                         |        v                        (see policies)
      |                                         |   +------------------+
      |                                         +---| AwaitingResponse |   <- EndUserTurn
      |                                             +------------------+
      |                                                |            ^
      |                                  BotAudioStarted|            | VadStart = barge-in:
      |                                  (current epoch)|            | Interrupt{new} +
      |                                                v            | StartUserTurn{new}
      |   BotAudioEnded (current epoch)         +-------------+     |
      +-----------------------------------------|  BotSpeaking |----+
                                                +-------------+

   Hangup (any state) -> Closed (terminal: every input is ignored; an open user turn is ended
   first with EndUserTurn{reason: Hangup}; the timer is cancelled)
   UserText (a typed message) in Idle / AwaitingResponse / BotSpeaking -> AwaitingResponse:
   [Interrupt{new} when the bot is busy] + StartUserTurn{new} + EndUserTurn{new, reason: Text};
   with a pending barge-in candidate the candidate is confirmed instead (-> UserSpeaking, the
   caller adds the text to that turn); ignored in UserSpeaking / UserPaused / Closed

Transitions are exhaustive: crates/brisvox-agent/tests/turn.rs checks every (state, input) pair (6 states x 10 inputs = 60) for each policy, asserting actions, next state and whether the input was counted as ignored. A second table keys the pause (UserPaused) by its analysis sub-state (NotRequested, Pending, Incomplete, Complete), with and without an STT final, punctuated and not, against the inputs that end or shorten it, including the deadline cap.

  • Idle: VadStart starts a turn. BotAudioEnded of the current epoch is ignored. (Idle can also hold a detached barge-in candidate, see "Interruption policy": the bot's audio ended while the user was speaking over it and the policy has not decided yet.)
  • UserSpeaking: VadStop opens a pause (UserPaused). SttInterim is accepted with no effect. SttFinal answers an owed stop (see the caller contract); when no stop is owed it is ignored and counted. VadStart again is a duplicate and ignored.
  • UserPaused: the candidate end of turn. Carries the VAD stop time, the Smart Turn progress (NotRequested, Pending(id), Incomplete, Complete) and the STT final of this pause. The one armed deadline is computed from this data. VadStart resumes the same turn and drops the pending analysis (its verdict is then stale).
  • AwaitingResponse: the user turn ended; waits for the bot. BotAudioStarted of the current epoch moves to BotSpeaking. BotAudioEnded of the current epoch means the response produced no audio and returns to Idle. VadStart is a barge-in candidate (see "Interruption policy"). A final that answers an earlier stop is consumed with no effect.
  • BotSpeaking: BotAudioStarted of the current epoch again is accepted. With the echo guard off it has no effect; with it on it restarts the echo window only for audio that resumes after a real gap (at least echo_guard after the previous BotAudioStarted); closer, repeated or older reports change nothing (see "Echo window"). BotAudioEnded of the current epoch returns to Idle. VadStart is a barge-in candidate.
  • Stale audio: BotAudioStarted and BotAudioEnded of any other epoch are ignored and counted (tested in AwaitingResponse and BotSpeaking at epoch 1, for every policy: state unchanged, no actions, ignored count +1; without the epoch guard a late end of the interrupted response would return the machine to Idle and the next VadStart would emit no Interrupt).
  • Closed ignores every input, including an SttFinal still owed by a stop that was open when the call hung up (tested after hang-ups from every state).
  • Barge-in (from AwaitingResponse or BotSpeaking): epoch + 1, Interrupt{new_epoch}, then StartUserTurn{new_epoch}, state UserSpeaking. There is no separate "interrupted" state: no state exists in which no user turn is open. Whether a VadStart is a barge-in at all is decided by the interruption policy (next section); with the default (disabled) policy it is, at once.
  • Closed: terminal. Hangup from UserSpeaking or UserPaused emits EndUserTurn{reason: Hangup} so that every StartUserTurn is paired with an end; the caller emits UserTurnEnded and starts no response. From any other state it emits nothing.

Interruption policy

Code: brisvox_agent::interrupt (InterruptionPolicy, BackchannelSet, Observation, Assessment), TurnMachine::with_interruption(turn_policy, interruption). TurnMachine::new uses InterruptionPolicy::disabled(), which confirms every candidate at once: the plain turn-machine behaviour, all existing tests and the sim unchanged.

Representation. A VadStart in AwaitingResponse or BotSpeaking creates a barge-in candidate (TurnMachine::barge_in_candidate()), a field next to the state, not a TurnState variant. While it is pending: state() is unchanged, no user turn is open, the epoch is unchanged, and nothing but the recheck timer has been emitted. So a vetoed barge-in cannot bump the epoch, emit Interrupt, or emit UserTurnStarted, and the stale-epoch rules for bot audio keep working untouched. (A new TurnState variant would also have changed the exhaustive transition tables.) The candidate stores when VAD started, the largest word count of the interims seen, and what the latest interim looks like (TextKind: none, speech, backchannel).

When the bot's audio ends while a candidate is pending (BotAudioEnded of the current epoch), the candidate is detached: the state becomes Idle, the bot is silent (the echo window no longer applies) and the candidate stays pending. The other guards keep judging it, so a "mm-hmm" or a cough over the last word of a sentence does not become a user turn.

Resolution. The machine asks the policy (assess(Observation)) on the VadStart, every SttInterim, every Tick of the recheck timer, BotAudioStarted (the echo window starts) and the VadStop:

EventResult
policy confirmsInterrupt{new} + StartUserTurn{new} (as without a policy), candidate cleared
VadStop, policy still blocks (judged with assess_at_stop, see below)candidate dropped (InterruptionStats::vetoed(reason)), no actions besides CancelTimer when a timer was armed; the stop counts as an owed STT final so later finals stay aligned
VadStop, policy confirms (the clock passed the threshold, tick not delivered yet)barge-in, then the ordinary pause of the new turn
BotAudioEnded of the current epochthe candidate is judged again with the bot silent (no echo window). Confirmed: the user is speaking into silence, so StartUserTurn{epoch+1} without Interrupt (promoted). Still blocked: it stays pending in Idle (detached) and is decided by the next interim, tick or VadStop like any candidate; a veto at the stop starts no turn and does not bump the epoch
Hangupcandidate dropped, timer cancelled, no EndUserTurn (no turn was open)

A second VadStart while a candidate is pending (also a detached one) is a duplicate: ignored and counted. An SttInterim with no candidate in Idle / AwaitingResponse / BotSpeaking stays ignored and counted. A Tick while a candidate is pending is always read as the recheck timer (consumed, the candidate judged, the timer re-armed if needed); a tick that carries a time before the candidate began is judged as of the start. Every candidate ends exactly once as confirmed, promoted or vetoed (the invariant test checks candidates == confirmed + promoted + vetoed + pending (+ dropped by hangup)).

Guards (all must pass; a guard that is off never blocks):

GuardFieldBlocks while
Backchannelbackchannels, BackchannelSet, backchannel_max_speechthe latest interim is only backchannel phrases and VAD has run for less than backchannel_max_speech (zero: no cap). The cap keeps a stale interim (STT lag or loss) from swallowing a long real utterance; a real backchannel is short
Echo guardecho_guard, echo_min_wordsbot audio younger than echo_guard and fewer than echo_min_words (at least 1) interim words: VAD alone cannot interrupt in the first N ms
Minimum speechmin_speech, vad_hangoverVAD active for less than min_speech. Live (a tick or an interim before the stop) the comparison uses the clock time minus vad_hangover, and the recheck tick is armed at min_speech + vad_hangover (see "VAD hangover")
Minimum wordsmin_wordsfewer interim words than min_words
Transcript waitbackchannel_waitbackchannels on, no interim yet, VAD active less than the wait (fail open: after it, a silent STT does not stop a real interruption). Zero means the 350 ms default (DEFAULT_BACKCHANNEL_WAIT): with no wait the backchannel guard could never act, since a candidate is judged on its VAD start, before any interim exists

min_speech and min_words combine with Confirm::All (default: both) or Confirm::Any. Time-driven guards give the machine a recheck time (recheck_in): the earliest of the min-speech end, the echo window end, the transcript wait end and the backchannel cap; guards that only words can lift arm no timer. Interims are cumulative per utterance: the word count is the maximum seen (a restarted interim cannot lower it), while the backchannel test looks at the latest interim only (a partial "ye" is not a backchannel; STT providers emit whole words, which is why the lists hold whole words).

Backchannel matching. Text is lower-cased ("İ" becomes "i"), split on every non-alphanumeric character ("uh-huh" is "uh", "huh") and must be fully covered by a sequence of phrases of any configured set (at most 16 tokens: tokenising stops after the 17th, so the cost does not grow with the length of a long cumulative interim). Turkish dotless "ı" stays distinct from "i". Defaults (BackchannelSet::english() / turkish()) are guesses, not tuned: English yeah, yep, uh-huh, mm-hmm, mhm, hmm, okay, right, alright, sure, "i see", "got it", cool, oh, ah, uh, um; Turkish evet, hı-hı, hı, he, hm, mhm, tamam, aynen, anladım, anlıyorum, peki, tabii, "öyle mi", ya. Answers ("yes", "no", "hayır") are deliberately not in the lists. InterruptionPolicy::recommended() (300 ms min speech, 100 ms VAD hangover, 400 ms echo window with one word, both languages, 350 ms transcript wait, 1.5 s backchannel cap) is also an untuned starting point.

VAD hangover. A VAD reports a stop only after its silence hangover (min_silence: 100 ms in brisvox-inference's defaults, 300 ms in the simulator's energy VAD) and backdates it to the end of the speech. A 220 ms cough against min_speech = 300 ms would therefore still look "active" at the tick armed for started + 300 ms, before its stop (delivered at about started + 320 ms) arrives, and the clock path would confirm a burst the stop path (which uses the backdated time) vetoes. InterruptionPolicy::vad_hangover closes that: while a candidate is judged live, min_speech is compared with elapsed - vad_hangover and the recheck is armed at min_speech + vad_hangover; at the stop the backdated duration is exact and nothing is subtracted. It defaults to zero (stops assumed undelayed); recommended() sets 100 ms (DEFAULT_VAD_HANGOVER); set it to the real VAD's min_silence. The price is that a genuinely long utterance is confirmed vad_hangover later than min_speech alone would say (conservative choice: a missed 100 ms of reaction rather than cutting the bot off for a cough).

At the VAD stop the machine uses assess_at_stop: when the latest interim is speech with at least one word (or a backchannel past its cap), the min_speech duration is waived, because the utterance is over and STT heard a word, so a short real "stop" or "no" at 250 ms is not lost. min_words, the backchannel guard and the echo guard still apply as configured (an echoed single word inside the window with echo_min_words >= 2 is still vetoed at the stop; tested in the_echo_guard_still_applies_at_the_vad_stop_when_a_word_waives_min_speech and the pure assess_at_stop_table).

Echo window. It counts from the first BotAudioStarted of the response, or from the latest one that came at least echo_guard after the previous BotAudioStarted of the epoch (a real gap), so a new round of bot audio after a gap (text, tool call, text) is guarded again. The machine cannot tell a burst from a chunk by itself, so it uses the gap: a report is compared with the previous report (not with the window start), and a report that is not later than the previous one is ignored. Per-chunk reporting (every 20 ms) therefore never restarts the window and cannot hold it open for the whole response; one report per burst restarts it when the burst begins at least echo_guard after the previous report. Two bursts closer together than echo_guard (measured between their reports) share one window, the conservative side: the user can interrupt sooner, not later. Tests: per_chunk_bot_audio_reports_do_not_keep_the_echo_window_open, a_repeated_or_late_bot_audio_report_never_moves_the_echo_window_backwards, the_echo_window_restarts_only_after_a_gap_of_the_guard_since_the_last_report and the simulator-level per_chunk_bot_audio_reports_cannot_block_a_wordless_interruption.

Trade-offs and known limits.

  • A candidate still blocked at its VadStop is dropped for good: no Interrupt, no user turn, nothing for the LLM, only the vetoed(reason) counter. That is the cost of the guards. It covers a backchannel or a cough, but also a very short real utterance that STT did not transcribe by the stop (with recommended(), a 250 ms "stop" without an interim), and a one-word interruption when min_words >= 2. Callers that cannot afford it keep min_speech low and deliver interims promptly.
  • The echo guard is lifted by any interim with echo_min_words words. When the VAD fires on the bot's own echo, STT transcribes that echo, so the words confirm the barge-in from the echo itself. The guard only stops VAD-only (wordless) echo blips. Comparing the interim with the bot's currently played text would close this gap and is a possible follow-up, not part of the interruption policy.
  • After BotAudioEnded the echo window no longer applies to a detached candidate. With an echo-only policy (no min_speech, no backchannels) an echo tail overlapping the end of the bot's audio therefore becomes a user turn once the bot is silent; combine the echo guard with min_speech (as recommended() does) to cover that.
  • A detached candidate that is vetoed at its stop (for example a "mm-hmm" over the last word of an answer) gets no reply, like any other vetoed candidate.
  • The guard hook is InterruptionPolicy::assess / assess_at_stop inside the machine; the driver feeds interims and ticks and needs no separate hook.

Caller contract additions. Deliver SttInterim text promptly (before the VadStop event): a short real interruption with no interim by the stop is judged on VAD alone. BotAudioStarted must carry the time the audio reaches the user (the echo window counts from it). Set vad_hangover to the VAD's min_silence.

Tests: crates/brisvox-agent/tests/interruption.rs (table rows for each guard in both languages, candidate life cycle, the pure policy including assess_at_stop, randomized invariants) and crates/brisvox-agent/tests/interruption_sim.rs (simulator level, see below).

Simulator-level tests. interruption_sim.rs plays scripted calls built from brisvox_sim::scenario::Scenario (the caller's speech) with the simulator energy VAD's VadConfig timing (40 ms start, 300 ms stop hangover, backdated stop) through a TurnMachine behind a thin deterministic driver (virtual time; STT interims every 200 ms, a final after each stop; every ended turn answered by a scripted bot; the stale bot audio flushed at an Interrupt). It covers each guard (backchannels in both languages, echo burst, words inside the echo window, cough, minimum words, short and long real interruptions, per-chunk bot-audio reports) and checks the s-suite style invariants on every run (epochs only grow, Interrupt followed by its StartUserTurn, starts equal ends, no stale bot audio delivered, no leftover timer or candidate). The reference pipeline's TurnManager (scenarios s01..s11) does not use TurnMachine yet; running s02/s10 with an InterruptionPolicy through the real pipeline is part of wiring the machine in, not of the interruption policy.

Caller contract

  • VAD: VadStart at speech start, VadStop at the backdated stop (the start of the silence).
  • STT: exactly one SttFinal per VadStop, in the same order. The STT stage finalizes on every VAD stop (as the reference TurnManager does, pending_finals), and the final's text is the transcript of that utterance. A final answers the oldest VAD stop not yet answered. Only the final that answers the current pause's stop can shorten or end that pause; a final with no unanswered stop is ignored and counted. A VAD stop whose final never arrives misaligns every later final by one: the fallback deadline still ends the turn, but the STT signals are then unreliable (see Known gaps).
  • Smart Turn: each Verdict echoes the AnalysisId of its RequestAnalysis.
  • Timers: one timer slot. Deliver Tick{now} at (or after) the armed deadline; a Tick for a timer that was cancelled is ignored.
  • Bot audio: BotAudioStarted{epoch} at the first audio of the response, and again when audio resumes after a gap (a new TTS round). With the echo guard on, a repeat restarts the echo window only when it comes at least echo_guard after the previous report, so per-chunk reports are harmless (they never restart it); BotAudioEnded{epoch} when the response's audio ends. For a response that makes no LLM request (an empty transcript, for example) send BotAudioEnded straight after EndUserTurn. Otherwise the machine waits in AwaitingResponse, and the next VadStart is reported as a barge-in against nothing.
  • Hangup: Hangup once, when the session ends.

Policies

TurnPolicy::FixedSilence { silence }: the turn ends when VAD has been silent for silence after the VAD stop. The VAD stop time is backdated by the VAD's minimum silence (min_silence), so the silence is counted from its start. Deadline = VAD stop + silence; Tick at the deadline ends the turn with EndReason::FixedSilence. STT finals are consumed but do not affect the deadline.

TurnPolicy::Adaptive { fallback_silence, require_final, final_grace } (Smart Turn + STT):

  • Every VadStop requests one analysis (RequestAnalysis, a fresh id).
  • Fallback deadline = VAD stop + fallback_silence (default 3 s, Pipecat's stop_secs). It is always armed while the pause is open and ends the turn with EndReason::Fallback when reached.
  • Complete verdict, require_final = false: the turn ends at once (EndReason::Verdict).
  • Complete verdict, require_final = true: the turn waits for the STT final of this pause (ends on it with Verdict). The deadline becomes verdict time + final_grace (default 800 ms) if that is earlier than the fallback; reaching it ends the turn with EndReason::FinalGrace. The end reason is FinalGrace as well when the fallback comes first.
  • Incomplete verdict: the fallback still applies. The STT final of this pause, when its text ends with sentence-final punctuation (. ? ! … as the last non-whitespace character), shortens the wait: the deadline becomes final time + final_grace when that is earlier than the fallback, and it ends with EndReason::SttPunctuation. The rule holds whichever of the two arrives first (the final during the analysis or after the verdict).
  • Only one final per pause counts: the one answering this pause's VAD stop. A second final in the same pause is ignored and counted, so it never moves the deadline. A final of an earlier pause (the user resumed in between) is consumed and never attributed to the current pause.
  • A verdict whose request is not the pending analysis of the current pause is stale and ignored. So are verdicts after a resume, a second verdict for the same analysis, and verdicts in any state without a pause.
  • An unpunctuated final does not shorten the wait. It still answers the pause, which matters for require_final.
  • The deadline never moves later than the fallback: a late verdict or final whose grace would end after the fallback leaves the fallback deadline in place.

Defaults: DEFAULT_FALLBACK_SILENCE = 3 s, DEFAULT_FINAL_GRACE = 800 ms, require_final = false (TurnPolicy::adaptive()).

Epochs

The first user turn runs in TurnEpoch::ZERO. Every later user turn start (from Idle) advances the epoch by one, as the reference TurnManager does. A barge-in also advances it, and emits Interrupt. A resumed pause keeps its epoch. Actions carry the epoch they belong to; EndUserTurn and StartUserTurn always name the current epoch.

Timers and ignored inputs

  • At most one deadline is pending at a time: ArmTimer replaces the previous one, CancelTimer clears it. The machine emits nothing when the desired deadline is unchanged. Tick consumes the timer that fired; a Tick earlier than the deadline re-arms it, and a Tick outside a pause or a pending barge-in candidate is ignored (a stale timer after a resume or hang-up).
  • An input with no legal transition in its state leaves the state unchanged and appends nothing. It is counted per input kind (TurnStats::ignored(kind), total_ignored()) and logged at debug level. Accepted inputs with no effect (an interim during speech, a final answering an earlier stop, a repeated BotAudioStarted) are not counted, so the counters show only inputs the caller sent out of step.
  • No input can panic: deadlines use checked arithmetic (a deadline beyond the clock range is clamped to about 100 years; if even that does not fit, to 10 years, 1 day or 1 hour, and only at the very end of the clock's range to the input time itself). Tests drive Duration::MAX policies through every deadline kind (huge_durations_clamp_instead_of_panicking).
  • A Tick strictly later than the armed deadline ends the pause exactly like one at the deadline (real timers fire late): tests cover deadline + 1 ms and deadline + 5 s for every end reason. The late-tick and stale-epoch properties are also checked as oracles in the randomized invariant test.
  • While a barge-in candidate is pending the single timer slot holds its recheck deadline (BargeInCandidate::recheck_at); at most one of pause and candidate exists at a time.

Known gaps

  • AwaitingResponse has no deadline. A response that never starts leaves the machine waiting until the caller delivers BotAudioEnded or a barge-in. The caller must bound the LLM/TTS wait; a response deadline inside the machine is a follow-up.
  • The machine does not cancel an analysis already running on the pool; its verdict is dropped.
  • A VAD stop whose STT final never arrives leaves the finals one stop behind for the rest of the machine's life (the caller contract above). The fallback still ends every pause. Recovering the alignment needs an STT-side correlation id; that is a follow-up.

Played-text truncation

When the caller barges in, the LLM context must hold only what was played, not the whole response that was synthesized (the engineering rule: after an interruption, the LLM context contains ONLY what was actually played). brisvox_agent::played decides that; brisvox_agent::played::apply_truncation writes it into a Context.

Inputs, per assistant utterance (PlayedText, one per TTS context):

  • The text sent to TTS, in order, as one or more segments (push_sent). It must be the response text unchanged; segments are concatenated verbatim, so a word split across two segments still maps.
  • The word timestamps the TTS reported (push_word(word, start)), start being the offset from the utterance's first audio sample. Carrier in the frame model: DataFrame::Word(Box<WordTimestamp>) (brisvox_core::frame; Frame stays at most 64 bytes, checked at compile time). Timestamps may arrive late or out of order; the mapping is computed when heard is called.
  • The playback position: the duration of the utterance's audio that had played at the barge-in instant, passed to heard(played, &PlayedConfig). It is a clock reading, not a count of released chunks: a chunk in flight at the barge-in counts only the part that had played by then. The sim computes it from a per-chunk playout log (Playout::position(at), brisvox_sim::reference).

Rules (crates/brisvox-agent/src/played.rs, module docs).

  • A word is heard when playback has passed its start (start < played). PlayedConfig::min_word_played raises the bar (default zero: a word counts once its first sample played).
  • Words are placed in the sent text in start order. Each is searched forward from the end of the previous located word, so repeated words map to successive occurrences. A located word starts and ends on word boundaries (ha does not match inside haha); its core is matched (leading and trailing non-alphanumerics dropped; a word with no alphanumerics, such as an emoji, whole).
  • A word that is only punctuation (., !, …) is placed at the next non-space position if the text has it there, and is otherwise zero-width, so timed punctuation marks never move the cut.
  • The heard text is the sent text up to the end of the last heard word, plus the punctuation that closes it (dünya!, "Merhaba!"), with everything before it verbatim. Opening brackets and quotes ((, a quote before a letter, “, ¡) are not kept; dashes are not kept. Offsets are char boundaries, so Turkish letters and emoji are safe.
  • Gaps. If a timed word skips text that no timestamp covers (the TTS lost a chunk, e.g. after a disconnect), the heard text stops there, since the caller may not have heard the lost text. Counted in Truncation::gaps.
  • Rewritten words (the provider wrote "bin" for "1000") are counted in Truncation::unmatched_words. Their text is known only when isolated: one such word, between two timed words (or at the end), whose untimed text is exactly one whitespace token. That token is its text. Any other unlocatable word (a run of rewrites, a rewrite that expands to several words such as "3.5" spoken as "üç nokta beş", or one whose text is more than one token) has unknown text. The heard text stops before it, so it never extends past text that is not provably heard. For "Oran 3.5 kg ağır" with timings through "kilogram" at 400 ms and playback at 250 ms, the heard text is "Oran": the caller heard "3.5" as "üç nokta", but nothing in the timings says which tokens those words cover, so "3.5" is not kept.
  • Invariant. The heard text is always a prefix of what was heard. Where the timings resolve the text exactly, it is exactly what was heard; where they cannot, it is shorter, never longer.

Fallback without timestamps. When no word timestamp arrived for the utterance, the heard text is played x chars_per_second (default 14 chars/s, DEFAULT_CHARS_PER_SECOND), cut back to a whitespace boundary (a mid-word cut backs up to the previous space). The result has Truncation::estimated == true. TruncationStats::record counts truncations, estimates, unmatched words and gaps. The reference LlmStage records every truncation into its TruncationStats (SessionProbes::truncation_stats), so an estimate reaches metrics; the sim test a_barge_in_is_counted_and_a_provider_without_timestamps_is_flagged_as_estimated checks it (MockTts::without_word_timings). An estimate is only made for a reply that was cut: never for one that played out in full (see below).

Applying to the context (apply_truncation(&mut Context, index, &Truncation)). The index is the utterance's own assistant message, not the last assistant message: the LLM may append messages while the TTS is still ahead of playback, and the cut must hit the message that was played.

  • Heard text equal to the message, non-empty: unchanged.
  • Nothing heard and no tool calls (including an empty placeholder message): the message is removed, since an empty assistant message is invalid.
  • Nothing heard but tool calls: the text is emptied and the calls kept. The model issued them and SideEffect tools may already have run (docs/TOOLS.md), so the call history stays as issued.
  • A heard prefix: the text is replaced. provider_data is dropped for a message without tool calls (it may embed the complete text; the adapter re-derives it) and kept for a message with tool calls (it may be a signature that must be echoed with them). Adapters must verify that the kept data does not carry the full text back to the model; this is not yet checked by any adapter.
  • Heard text not a prefix of the message, or the index not an assistant message: an error (TruncateError), context untouched.

Frame purge. DataFrame::Word is exempt from the epoch purge (Frame::is_stale), like tool results. When an interrupt arrives, word timestamps of the interrupted turn may describe audio that already played; dropping them would under-report what was heard. Consumers filter words by their own epoch. This is a design choice with a test (stale_purge_spares_system_frames_and_tool_results).

Timing contract. Word timestamps of an utterance must reach the tracker before the audio they cover is released (the sim's TTS reports a word at or before its first chunk). apply_truncation is one-shot per utterance: the sim applies the cut at the next response or at stop. A word that reaches the tracker after its cut is applied is lost, so the context under-reports what was heard.

Sim (reference pipeline). The LlmStage keeps the conversation as an agent Context and cuts the interrupted response at the playback position of the barge-in instant (Cut), through the production apply_truncation. The playout log is per chunk (Playout), so the position at an instant is exact to the chunk's partial play.

A fully played reply is not cut. heard() only knows a position, so on its own it would cut a reply that played out to the end: the rewrite rule stops at the first unresolvable run ("3.5 kg" spoken as four words keeps only the text before it) and the rate estimate caps the text at played x 14 chars (a 320-char reply spoken at 16 chars/s would lose its last 40). The truncation is for what was not heard, so the caller of heard() must say when everything was: the sim's Playout records that the TTS flushed the turn (TtsEvent::Flushed), that no text of the turn was lost on the way (a send the TTS stage could not make, or a TTS session that died with the turn's text in flight and was replaced: a later flush says nothing about the lost text; both set by the TtsStage through with_play_log) and that the pacer saw the turn's end after its last chunk had played out (Playout::is_complete). When the turn is complete and the cut position has reached all the audio released, the message stays as sent (minus trailing whitespace, which is not a word) and no truncation is recorded. A turn that was interrupted, whose synthesis failed or lost text, or that was cut before its end is cut as before (the chaos test for TTS disconnects found the lost text case). The production engine must pass the same fact (utterance fully played, no text lost) instead of a position alone.

Tests.

  • Unit tests in played.rs: punctuation and spacing, word boundaries, repeated words, Turkish and emoji, words split across TTS segments, late and out-of-order timestamps, gaps, rewrites (isolated, expanded, consecutive, followed by untimed text), timed punctuation marks, opening and closing quotes and brackets, the minimum-played rule, the estimate (char counting, whole-word cut, flagged), stats, the context rules above, the utterance's own message index, and provider_data handling.
  • A seeded randomized test (400 cases) generates replies with attached punctuation, timed, rewritten and untimed tokens, timed punctuation marks, random segment cuts, shuffled timestamp arrival and a random playback position. It asserts that the heard text is a prefix of the expected text and equal to it wherever the timings resolve exactly, and that the context stays valid.
  • Playout (crates/brisvox-sim/src/reference.rs, unit tests): hand-computed positions at chunk starts, ends, mid-chunk, across an underrun gap, before the first chunk and after the end; the completeness flags. The sim oracle below does not use Playout; it recomputes the position from the audio frames the transport received and asserts that the playout log agrees.
  • LlmStage (same module, unit tests): a fully played reply with a rewritten number ("3.5 kg") or without word timestamps (320 chars at 16 chars/s) keeps its whole text and records no truncation; the same reply without a clean flush, or with the cut before its end, is cut.
  • Sim (crates/brisvox-sim/tests/played_text.rs): 40 seeded barge-ins through the reference pipeline are compared char-exact with the answer's own text up to the last word that started before the barge-in position. At least 8 interrupted calls have the barge-in inside a 40 ms chunk (the counter ignores uninterrupted answers, whose total length is not a multiple of the chunk). A deterministic case (TTS at 70 ms/char) aims the barge-in 340 ms into the audio, where the word "is" starts at 350 ms inside the in-flight chunk [320, 360): it must not be heard, which a position counting the whole chunk would get wrong. Whole answers (with and without word timestamps) stay whole. A mid-stream barge-in (the LLM pauses 9 s before its third delta, 16 seeded cases) leaves only the heard part of the text sent so far, and the tail never reaches the context.
  • Estimate in the pipeline: runs without word timestamps and barge-ins at four points must equal an independently computed estimate (floor(played x 14) chars, backed up to a whitespace) and are flagged as estimated in the stats; a timed run never is. The VAD works in 20 ms steps, so the barge-in lands on a multiple of 20 ms.
  • Chaos (crates/brisvox-sim/tests/chaos.rs): for TTS disconnects, the context must equal the heard prefix of the first answer exactly, matched token by token against the word log (the answer's words are distinct), and at least one disconnect must cut an answer mid-way with text heard.

Production path. The engine wires this through the output pacer's playout-position report (at the barge-in instant) and the PlayoutBook (see PIPELINE.md and the PlayoutBook section above); the simulation exercises the same contract.

Transport

crates/brisvox-transport is the provider-neutral boundary between a caller and a session. It has no socket code: a protocol adapter (websocket, Twilio, WebRTC) bridges the provider protocol to two channels, and the core does the rest.

Pieces

  • Transport::accept yields a CallLink per call. The adapter keeps the matching CallEnds (call_pair): WireEvents in (caller audio, DTMF, mark echoes, fault, hang-up), WireOuts out (audio, clear, mark, hang-up), and ended, a token the core cancels when the call is over (an adapter blocked on its client must stop waiting and return).
  • TransportSession::open(clock, link, config, parent) starts the session. The engine reads TransportEvents (recv) and sends TransportCommands (send, or a cloned commands() CommandSender). close(timeout) cancels and joins every task; dropping the session cancels it. stop_reason() returns why the session ended, even if the Stopped event was lost.
  • LoopbackTransport + LoopbackClient: the in-memory transport. The client sends audio, DTMF and mark echoes and reads what the client would hear, with the time it heard it.
  • CallInfo::sample_rate() parses metadata[CallInfo::SAMPLE_RATE_KEY] into Result<Option<CallRates>> (CallRates { input, output }, CallRates::symmetric): Ok(None) when the adapter reports no rate (the loopback), Err for a malformed or unsupported value. The engine falls back to EngineRates::fallback_call on None. The websocket adapter writes the key through the same constant, and a test pins it.

Tasks of one session (all children of the parent token)

  • commands: accepts queued engine commands in order. Audio goes into the production OutputPacer (pipeline transport.out); Mark is recorded at the enqueued position; Stop records Requested and cancels. Clear is not queued (see Clear).
  • output: the only writer to the wire. It takes frames from the pacer's sink, drops stale ones, writes WireOut::Audio, updates the released total and sends marks once the released audio reaches their position (never while a clear is waiting to be applied: a mark registered after the clear must not overtake WireOut::Clear). When the ledger's generation changed it sends WireOut::Clear, then reports the cut utterances' final positions and the cut marks. A position counts as reported only once its report is queued, so a cancellation in the middle leaves it for the final report.
  • input: forwards WireEvents to the engine, re-tagging caller audio with the current epoch.
  • supervisor: joins the three tasks and the pacer pipeline, hangs up the client (WireOut::Hangup), cancels CallEnds::ended, and emits Stopped{reason}. The first task to end cancels the rest, with one exception: when the output task finds the wire closed (the adapter dropped its outgoing end), the input task keeps reading for up to 1 s, so a hang-up, key presses and mark echoes the adapter queued before it left are delivered and the stop reason is PeerHangup rather than PeerDisconnected. Past that second, or when the adapter's event side closes without a hang-up, the session ends as PeerDisconnected.

Output timing: Audio is released by the production pacer, so the wire never sees faster than real-time audio. The pacer keeps its absolute 20 ms grid and underrun rules. Added latency, from the engine's Audio command to the wire: the pacer's own queue (pacer_capacity, headroom for synthesis, not delay), the pacer's sink (sink_capacity, default 4 frames = 80 ms of queue when full, but the sink drains as fast as the wire accepts), the wire queue (WIRE_OUT_CAPACITY = 8 frames when full), then the adapter's own buffer (for the loopback, its latency, default 30 ms). Only the first is on the audio's schedule. Keep the sink and the wire queue short: each queued frame is latency.

Clear is out of band. The audio path is deep (command queue 64, pacer input 64, sink 4, wire queue 8), and a clear that waited its turn in it would wait for up to 2 s of audio. So CommandSender::send(Clear { epoch }) applies it before it returns, from the caller's task:

  1. under the ledger lock it raises the epoch (accepted audio becomes stale) and moves every mark that was not echoed yet to the cut list (see Marks), and, still under the lock, pulls the stale WireOut::Audio and every queued WireOut::Mark out of the wire queue (Sender::purge; the output task sends only after it saw the new generation, so nothing newer is in the queue);
  2. it wakes the output task;
  3. it puts Interrupt{new_epoch} on the pacer's system lane, which purges the pacer's data lane and abandons the frame the pacer waits on; frames still in the sink are skipped by the output task. The output task then writes WireOut::Clear, so the adapter drops what it buffered and tells the client. Audio that was still in the command queue is rejected by its epoch when the command task reaches it. Boundary: after the clear command, at most the one frame the output task was already writing to the wire (its write was blocked, or in flight) still reaches the adapter ahead of WireOut::Clear; the adapter drops it on the clear. Tested at the wire level (an adapter that reads nothing, a full wire queue, 40 queued frames: at most one old frame after the clear) and through the loopback (100 frames queued, the client is cleared in under one frame time). A Clear with an epoch not newer than the current one is ignored. Audio must follow the Clear of its epoch (epoch 0 needs none); audio of any other epoch is dropped. Stop is not out of band: it takes effect when the commands queued before it were accepted, and then drops all pending audio.

Marks: a mark is positioned at the audio accepted before it, and is sent to the client when that audio has been released; the core then remembers it as sent (at most 256, the oldest is given up and reported as MarkCleared past that). The client echoes it when it has played (MarkPlayed). A clear cuts every mark that has not been echoed: waiting for its audio, queued on the wire (purged with the stale audio) or buffered in the adapter (dropped on WireOut::Clear, or echoed by a protocol like Twilio's; the core does not care). The engine gets MarkCleared{id} for each, and an echo that arrives later is ignored (an echo of an id the core does not wait for is dropped), so every mark ends in exactly one of MarkPlayed / MarkCleared (or in Stopped). A mark still in the command queue when a clear is applied counts as cut: each queued command remembers the epoch the engine sent it in. A mark whose echo was in flight when the clear arrived is reported as cut, never as played: MarkPlayed stays "the caller heard everything before it". Use distinct ids; an echo is matched to the outstanding mark with that id. The played-text logic can tell a cut mark from a slow one.

Playback positions: PlaybackPosition{epoch, utterance, played} is the audio released to the wire for the utterance, counted per (epoch, utterance) (the same utterance id in another epoch starts at zero, and utterances may interleave without restarting each other). It is emitted every position_interval (default 100 ms of audio), when another utterance takes over, on a clear (for every cut utterance) and on stop. The last report of an utterance equals the audio released for it; reports are monotonic per (epoch, utterance). At most 256 counters are kept; the oldest is reported and forgotten past that (an id that returns after 256 others restarts at zero). Accuracy limits: (1) it counts audio handed to the transport, not audio the caller heard; it runs ahead by the adapter's buffer (the loopback's latency) and a barge-in does not subtract what was buffered; (2) a report is at most position_interval behind while audio is still playing and is not emitted on idle; (3) audio that was purged from the wire queue by a clear after it was released is still counted in the cut utterance's final position (up to WIRE_OUT_CAPACITY frames). Exact playout is MarkPlayed; the telephony adapter reports marks, which is what the played-text logic relies on for "what the caller heard". Between utterance-end marks an adapter whose client reports its playout (Twilio) also sends WireEvent::Lead, which the session emits as TransportEvent::PlaybackLead{lead} (Displace, latest wins): the audio sent to the client and not yet played. heard ~ PlaybackPosition.played - lead. Loopback and the generic websocket send none.

Overflow policies (channel::Overflow, one per item). Every queue is bounded:

  • engine events (events_capacity, default 64): Audio (input) is DropOldest within its class, so a slow engine loses the oldest caller audio. PlaybackPosition is Displace: a new report removes the queued report of the same (epoch, utterance) (reports are cumulative, so nothing is lost but intermediate values), so the output never waits for the engine to read. Stopped is Displace too: it evicts the oldest input audio to get in. Started, Dtmf, MarkPlayed, MarkCleared, Error are Backpressure (never lost; the input task or the output task waits). The engine must therefore keep reading events while it uses the session, as long as it registers marks or expects DTMF; position reports alone cannot stall it.
  • engine commands (commands_capacity, default 64): Backpressure. Clear does not use it.
  • wire events (WIRE_EVENTS_CAPACITY = 64): Audio DropOldest, the rest Backpressure.
  • wire out (WIRE_OUT_CAPACITY = 8): Backpressure; a slow client slows the output, the pacer falls behind, and it resyncs with an underrun, as it does for any slow sink.
  • pacer input (pacer_capacity) and sink (sink_capacity): Backpressure (the framework's audio policy). A slow engine therefore loses input audio (oldest first) and intermediate position reports, and nothing else, except one case: if the event queue is full of lossless events and the engine reads nothing for one second after the session ended, Stopped is dropped. The reason stays available from TransportSession::stop_reason().

Cancellation, timeouts and shutdown: every session task waits on channels inside a select! with the session token. Bounded waits: the output task's wire write (WIRE_WRITE_TIMEOUT, 10 s: an adapter that takes nothing for that long has stalled and the session fails with a timeout), the final position reports and the hang-up and Stopped sends (1 s each). The command task's pacer push waits without a timeout (the pacer is in-process and drains at real time), but it ends with the token. Stop, peer hang-up and peer disconnect each record a StopReason (first writer wins) and cancel. The supervisor reports the reason as the last event and cancels CallEnds::ended; close(timeout) returns Timeout and aborts the supervisor if the tasks do not finish in time. Held CommandSender clones do not keep the pacer or the wire alive after that. The loopback adapter cancels every send (to its client and to the core) on the owner token or ended, and bounds each by LoopbackConfig::send_timeout (default 5 s; past it the call ends and the core reports a lost peer). A dropped LoopbackClient is a lost peer (PeerDisconnected); LoopbackClient::hangup is PeerHangup.

Tests (crates/brisvox-transport/tests/transport.rs, paused time, SimClock; the channel policy tests use a test item type): input audio and DTMF arrive in order; output is paced on the 20 ms grid and takes its real duration; a clear drops the queued old audio (the client hears no old audio after the clear, and the cut utterance's final position is reported); a clear behind 100 queued frames reaches the client within a frame time; at most one old frame is written to the wire after a clear (adapter that reads nothing, so the loopback's filtering cannot hide it); a mark arrives right behind its audio and its echo becomes MarkPlayed; a mark cut by a clear becomes MarkCleared and never reaches the client (also behind enough new audio to pass its position, and a late echo of it is ignored), also when it was still in the command queue, and also when it was already sent (in the adapter's buffer or left in the wire queue behind purged audio); a mark registered after a clear never overtakes the clear on the wire; the cut utterance's final position is exact (120 ms for a clear at 130 ms with seed 0) and comes from the clear, not the interval; a report that a cancellation interrupts is still delivered at the end; the adapter that queued a hang-up and left delivers it (and what preceded it) as PeerHangup; caller audio is re-tagged with the current epoch and keeps seq and timestamp, DTMF keeps its time; WireOut::Hangup is queued before ended is cancelled, also when the wire queue is full (bounded by 1 s); an adapter that reads nothing for 10 s fails the session with a wire write timeout; held CommandSender clones are refused after the end and keep neither the wire nor the pacer open, and concurrent clears from two clones apply in epoch order; DropOldest evicts only its own class (a lossless item in front of lossy ones survives; with nothing of its class queued the new item is dropped and counted); positions are monotonic and equal the released audio, also when utterances interleave and when an utterance id repeats in a new epoch; an engine that never reads events can still send 20 s of audio (positions coalesce); stop drops pending audio and ends both directions; stop and cancel with a client that never reads leave no task behind (checked through Arc::strong_count); a client that stops reading for too long ends the call; stale-epoch audio and an old clear are ignored; hang-up and vanished client map to PeerHangup and PeerDisconnected; a protocol fault becomes Error; stop_reason() survives a lost Stopped; a slow engine loses only input audio and keeps DTMF in order; the channel's four policies and purge hold under a slow consumer.

WebSocket transport

brisvox_transport::websocket (feature websocket, on by default) is the generic protocol adapter on top of the sans-IO transport core. The wire protocol (messages, limits, close codes, error codes) is in docs/WEBSOCKET_PROTOCOL.md; this section is the design.

Pieces

  • WebSocketServer implements Transport. bind/serve start one accept task (tracked by a TaskTracker); accept yields a CallLink per connection that completed the upgrade and sent a valid start. shutdown(timeout) cancels, says goodbye (stop{shutdown} + close 1001) to every call and waits for all tasks. Dropping the server cancels without waiting.
  • One connection = one spawned task. It does the handshake, reads start, builds the call_pair (queues WireEvent::Started first, hands the CallLink to the accept queue), then splits the socket and drives a reader and a writer future with join! in the same task. A stuck write therefore never stops reads (and vice versa) and there is exactly one task per call to leak-check.
  • CallInfo.metadata: the client's start.metadata, plus client_call_id, transport, sample_rate, encoding, peer_addr (server keys win). The engine reads sample_rate to synthesize at the call's rate.
  • websocket::client::run is the reference client (also the ws_client binary); wav is the ~100-line mono PCM16 WAV reader/writer both use.

Reader (client to engine): binary frame -> AudioFrame (pcm16 zero-copy from the websocket's Bytes; mu-law decoded into one fresh buffer) -> WireEvent::Audio (DropOldest, never waits). Text frame -> dtmf/mark/stop events. Any frame refreshes the liveness stamp; a single timer fires at ping_timeout after the last frame and re-arms itself if frames arrived meanwhile (no per-frame timer work). A close frame is WireEvent::Hangup (PeerHangup), a vanished socket drops the events sender (PeerDisconnected). "Vanished" includes what tungstenite reports as Protocol(ResetWithoutClosingHandshake) (EOF without a close frame) and any I/O error: they are not the peer's protocol violation, so no fatal error is queued and the engine sees no TransportEvent::Error (only genuine violations, such as a bad frame or invalid UTF-8, are reported and answered with 1002). After a fatal fault or a hang-up the reader stops feeding the core (drops its sender) but keeps reading, up to close_grace, for the peer's close frame, so the close handshake completes and the peer does not get a reset that would discard the error message in flight. A closing connection is not read before the writer has said goodbye (Conn::writer_done): the websocket layer refuses every write after it has read the peer's close frame, so reading it early would lose the stop. When the peer's close frame is read first (a bare ws.close()), the layer queues the close reply and only a later read or flush sends it; the writer's last step (Writer::finish) closes the sink, which flushes it, so the client always gets a close frame back and never a reset (RFC 6455 5.5.1). A client that sends stop then close gets its stop{ended} first.

Writer (engine to client): WireOut::Audio -> binary frame(s) in the call's encoding (pcm16 zero-copy; frames longer than max_audio_bytes are split into chunks of at most that size); Clear{epoch} -> clear message and a floor epoch (older audio is dropped); Mark -> mark (an id over 256 bytes is not sent: non-fatal mark_id_too_long, since the client's echo would be refused); Hangup/ended -> stop{ended} + close 1000; server cancel -> stop{shutdown} + close 1001. A write that is blocked on the socket gives up the moment the server shuts down or the core ends the call (CallEnds::ended), and the goodbye then gets at most min(write_timeout, close_grace), so a stuck client holds the call's permit for about a second after a hang-up, not for several write timeouts. Bot audio at another sample rate than the call's is dropped with one non-fatal output_rate_mismatch per call: resampling is DSP and belongs to the audio layer, not to a transport task. Known limitation: a call negotiated at 8 kHz or mu-law stays mute unless the engine synthesizes at the call's rate (CallInfo.metadata["sample_rate"]). Follow-up: resample bot audio to the call's rate on the inference pool before the transport (see docs/WEBSOCKET_PROTOCOL.md, "Audio"). The writer also sends the server pings and the reader's error reports (a bounded queue of 8: non-fatal notices are dropped when it is full, a fatal report waits up to write_timeout).

Flow control and overflow (all bounded, see also the transport core above): connections max_calls (a 503 with Retry-After past it, answered by at most 32 concurrent reject tasks; beyond that connections are dropped), accept queue (accept_queue, wait send_timeout, then server_busy), frames (max_audio_bytes, max_control_bytes, enforced by the websocket layer and again per message kind), input audio DropOldest, control events lossless with send_timeout (then overloaded), output through the 8-frame wire queue with write_timeout per socket write (then the call is closed: playout is real time, so a client that falls behind is lost rather than buffered). At most 16 non-fatal faults per call.

Configuration: ServerConfig::validate (called by bind/serve, Error::Config) refuses ping_timeout <= ping_interval, ping_interval < 10 ms, zero timeouts or close_grace, max_calls/accept_queue of 0, max_audio_bytes < 2 and max_control_bytes < 512.

Timeouts (every network await has one): handshake, start, each write, each event delivery, the close handshake, the accept hand-off, the shutdown wait. All of them, and the ping and idle timers, run on the injected Clock (websocket::timeout races the future against clock.sleep), so a simulated clock drives them all; the reference client does the same. Only the socket I/O is real. Tests: the start, handshake and ping timers run on a SimClock (paused tokio time with real sockets; a busy task keeps tokio from auto-advancing while it waits for I/O) and fire only when the test advances the clock, never when real time passes; a source scan fails the build of the tests if the websocket module uses tokio::time::timeout/sleep/interval or real instants outside the clock. Hot path per frame: no allocation for pcm16 in or out; the mu-law path converts into a per-connection BytesMut that is allocated in 16 KiB chunks and split per frame (51 frames per chunk for the 320-byte decoded frames of 20 ms at 8 kHz, 102 for the 160-byte encoded ones: about one allocation per 50 frames in and per 100 frames out, instead of one per frame; a unit test in connection.rs keeps 300 frames and checks their content and the number of allocations); the cancellation futures each side polls in its main loop are created once per connection, not once per frame (a write creates its own interrupt future, which does not allocate).

Reference client: the send loop never starves the receive side. After every sent frame the select loop looks at the socket once (needed in --no-realtime, where the next frame is always due), and a write that cannot complete (the server is blocked writing to the client, so its receive window is closed) keeps serving the server's messages while it waits (send_reading); otherwise a long input sent at full speed against a server already producing audio would deadlock both directions until the client's write timeout. The server's messages are applied in one place (Call::on_server), whether they arrive between two sends or during one.

Tests (tests/websocket.rs, real sockets on 127.0.0.1:0, the real clock unless noted): handshake and started; invalid starts; audio both ways in order (pcm16 and mu-law); clear drops queued audio and cuts marks; mark round trip; DTMF; malformed JSON, oversized and half-sample frames, a second start, unknown message types (non-fatal, counted); a flooding client (input drop-oldest, call survives); the call limit (503 and slots freed); ping timeout and pings keeping an idle client alive; a client that reads nothing (12 MB frame) is dropped after write_timeout while the engine still hears it; hang-up by stop, close frame, abrupt drop and engine stop; shutdown; TaskCounter leak check across all of it; the reference client and the ws_client binary against an echo engine (byte-exact recording, mu-law, clear handling, refused start). Added by the review: a client close frame and a stop-then-close are answered with a close frame (no reset); a stuck client does not hold the permit past an engine hang-up or a server shutdown; the accept queue full (server_busy), an engine that reads no events (overloaded), the reject slots running out, oversized start, long mark ids, a rate mismatch lasting seconds, configuration validation, the app-level audio limit; the leak check across fatal closes, a stuck client and rejected connections; the reference client against scripted servers (connection vanishing mid-stream, a fatal error mid-stream, the maximum duration, a bot hang-up, mark echo timing and clear), and the ws_client exit code 1 with the recording still written. Added by the second review: every close code of the protocol table (1000, 1001, 1002, 1008, 1009, 1013) is asserted at its call site; an abrupt disconnect (before and after start) gives PeerDisconnected and no engine error; any client frame keeps the call alive (audio only, no pongs); the start, handshake and ping timers on a SimClock; the clock-injection source scan; the reference client reading while it sends (--no-realtime, 16 MiB of input against 32 MiB of bot audio) and serving a mark between frames; failure() for a clean close before and after the end of the input; the ws_client options (--encoding mulaw, --dtmf, --meta, --no-realtime, --linger-ms with a measured run time, the WAV's sample rate) and its exit code 2 cases (unknown option, bad values, missing or invalid input, unwritable output).

Twilio transport

brisvox_transport::twilio (feature twilio, on by default) speaks Twilio Media Streams on the infrastructure of the generic WebSocket server; protocol facts, TwiML and the UNVERIFIED list are in docs/providers/twilio.md.

Reuse: TwilioServer wraps a WebSocketServer built with serve_with(.., ServeFn). The accept loop, call limit (503), reject slots, accept queue, shutdown and Transport::accept are the same code; only the per-connection function differs. ServerConfig (limits, ping, idle, write, send and close timeouts) applies unchanged; TwilioConfig adds the path, the auth token and public URL, the outbound chunk size and the progress-mark interval. All timeouts and timers run on the injected Clock.

Connection (one task per call, as in the generic websocket transport): upgrade callback (path -> 404, signature -> 403), read connected/start within start_timeout, validate the media format, build the CallInfo (call_id = callSid, metadata in the provider doc), call_pair, hand the link to the accept queue, then split the socket and run a reader and a writer with join!. Shutdown is symmetric as in the generic websocket transport (reader_done, writer_done, close_grace); the farewell is only a close frame, because Twilio takes no stop/error messages from the server. The reader asks the writer for a fatal close over a 2-slot queue (first wins); the writer reports a fault (rate mismatch) to the reader over an 8-slot queue (full: dropped, the reader owns the engine's event queue).

Audio: inbound base64 is decoded into a reusable Vec, mu-law into PCM16 in a 16 KiB-chunk BytesMut (scratch_bytes, shared with the generic websocket transport); outbound PCM16 is encoded the same way and each 160-byte piece becomes one media message built in a per-connection BytesMut (fixed prefix with the pre-escaped streamSid, base64 straight into the buffer, Utf8Bytes::try_from on the frozen slice): no per-frame allocation beyond one per ~60 messages. Parsing borrows from the frame (Cow), numbers that Twilio prints as strings go through a visitor, no allocation per media message.

Marks (twilio::marks::MarkBook, shared by reader and writer behind a std::sync::Mutex that is never held across an await): engine marks e<n> (id kept locally), progress marks p<n>, at most 512 in flight (oldest forgotten, counted), clear() forgets all of them. Echoes resolve against the book: engine mark -> MarkAck, progress -> latency statistics, unknown -> dropped. The core's own ledger reports MarkCleared; the adapter only guarantees an echo of a flushed mark is never taken for a played one. TwilioServer::stats() exposes shared atomic counters (media in/out, lost inbound chunks, marks sent/echoed/cut/unmatched/evicted, clears, faults, max echo latency, max unplayed audio).

Bounds: calls and accept queue as in the generic websocket transport; frames max_control_bytes (text) and max_audio_bytes (decoded payload); faults 16 per call; marks 512 per call; every channel is bounded with the policy named in the code. Slow Twilio (a write over write_timeout) closes the call.

Telnyx transport

brisvox_transport::telnyx (feature telnyx, on by default) speaks Telnyx media streaming (bidirectional rtp mode) on the same server infrastructure as the Twilio adapter; protocol facts and the UNVERIFIED list are in docs/providers/telnyx.md.

Shared code. What both telephony adapters use moved to telephony (crate private, compiled for twilio or telnyx): MarkBook / Echo / MarkName (re-exported by twilio::marks and twilio::protocol), the lenient number readers (Lenient, Soft, SoftText), the base64 engine, and the upgrade/refusal helpers (refusal, blame, refuse, close_reason). The reader/writer loops are a copy of Twilio's with the differences below (their wire formats, auth and ending differ too much for one generic loop to be cheaper than two).

Differences from Twilio.

  • Wire: snake_case, stream_id; the server's media/mark/clear carry no stream id (Telnyx's schemas forbid extra properties). sequence_number, chunk, timestamp are strings (RTP timestamp, not ms). error frames (100002..100005) become engine faults.
  • Codecs: PCMU or PCMA at 8 kHz (inbound from start.media_format, outbound from TelnyxConfig::codec, which must equal stream_bidirectional_codec); L16/G722/OPUS/AMR-WB are refused at start (L16 byte order is undocumented, the rest need codecs not in the tree).
  • Chunks: Telnyx takes 20 ms to 30 s per message, so chunk_bytes is at least 160 and a tail under 20 ms is carried (<= 159 bytes, no allocation per frame) to the next audio, silence-padded when an engine mark follows it, dropped by clear.
  • Order: frames are not ordered. telnyx::reorder::Reorder (sans-IO, BTreeMap) holds at most reorder_window (default 8, max 64) frames; an in-order frame never touches it (no copy). A gap is given up on when the window overflows or reorder_timeout after the first frame behind it arrived (injected clock; one timer in the reader's select, re-armed after every message and flush from the arrival times of what is still held, so a later gap keeps its own full wait). Late and duplicate chunks are dropped and counted; the first frame anchors the numbering. A chunk with an unusable payload (empty, bad base64, too large) is offered with no data so it fills its place. Chunks more than 1500 ahead or behind the one due are dropped; more than max(window, 4) of them in a row re-anchor the buffer on the newest (chunk_resyncs, one engine fault), so a corrupt or restarted counter cannot silence the caller. Audio still held is released before a stop or close is delivered. dtmf waits up to reorder_timeout in a bounded list (32) and is delivered in sequence_number order (dtmf_reordered). Frames that overtake start are dropped (counted, at most 64).
  • Auth: no signature. With auth_token (Telnyx's stream_auth_token): a wrong x-telnyx-streaming-auth-token upgrade header is 401; a missing header defers to connected.x-telnyx-streaming-auth-token; without proof by start the connection is closed 1008 and the engine never sees it. Comparison: HMAC-SHA256 of both under a fixed key, compared with ring::hmac::verify (constant time, length independent). The token is Secret in the config and redacted in the parsed connected message.
  • Hang-up: closing the socket is not documented as a hang-up. TelnyxConfig::hangup is a host-supplied async hook (REST hangup; brisvox holds no Telnyx API key), called once, bounded by hangup_timeout on the injected clock, before the close frame, whenever the adapter ends the connection (engine hang-up, fatal fault, idle timeout, shutdown, broken socket, a bare close frame from Telnyx, which is not documented as ending the call) and for a call refused after start (unsupported media format, engine not taking calls), but not after Telnyx's stop and not for authentication or duplicate refusals. Telnyx error frames are reported as faults but do not count toward the 16-fault limit.
  • One stream per call: telnyx::registry remembers live and the last 1024 ended call_control_ids; a second connection (TeXML reconnect) is closed 1008. A claim is remembered only once the call was handed to the engine, so the retry after a 1013 is accepted. The upgrade path is compared in constant time (it may be the only secret).
  • Stats: TelnyxStats adds media_reordered, media_late, media_before_start, telnyx_errors, duplicate_calls_refused, hangups_requested, hangups_failed, hangups_skipped, chunk_resyncs, dtmf_reordered.

WebRTC transport

A second door on the same port: a WebSocket upgrade on BRISVOX_WEBRTC_WS_PATH (webrtc/ws.rs). The request is read by the signaling HTTP parser, checked (upgrade headers, Origin, the listener's credential, offer rate, call slot, handshake slot, per-address call cap), answered 101 there, and the raw stream is handed to the websocket transport (websocket::connection::run_upgraded, the code the generic listener runs), with the WebRTC server's calls queue as its sink. The call has no media path, no UDP and no codec: it is a generic websocket call that shares the WebRTC listener's admission. Details: docs/DEPLOY.md "WebSocket fallback", docs/SECURITY.md. brisvox_transport::webrtc (cargo feature webrtc, off by default: it needs a C toolchain and cmake for libopus) lets browsers talk to the engine. Facts, measurements and the UNVERIFIED list are in docs/providers/webrtc.md (section 10); the library choice (str0m + libopus) is recorded there.

Parts. WebRtcServer (a Transport) owns three kinds of tasks: a signaling accept loop (minimal HTTP/1.1 on a TcpListener: WHIP-style POST/DELETE/OPTIONS, a demo page), one UDP reader for the single shared media socket, and one task per call. demux routes a STUN request by the ufrag it carries (<server ufrag>:<browser ufrag>; the ufrag wins over a known source, so a client reusing its 5-tuple for a new call reaches the new call) and every other datagram by its source address (least-recently-used list of 8 per call, refreshed by every datagram); each call has a bounded inbound queue (WebRtcConfig::inbound_queue, drop-oldest, exactly sized copies so the bound is in bytes too, overflow visible live in stats()). peer drives one sans-IO str0m::Rtc per call: drain poll_output (transmit with try_send_to, never waiting), then select over cancel, DELETE, the engine's ended token, the inbound queue, the engine's WireOut queue and one timer (the Rtc's timeout, the connect deadline, the disconnect grace, the next mark acknowledgement). Time is the injected Clock (tokio::time::Instant converted with into_std).

Call life. POST -> permit (call limit, 503) -> per-address count (429) -> spawn_blocking: parse the offer (mDNS candidates removed), Rtc with ICE-lite, the server's host candidate, the shared DTLS certificate, accept_offer, MediaPath -> 201 with the answer and Location. The engine hears about the call only when Event::Connected fires (ICE and DTLS up): the CallLink goes into the accept queue with try_send (a full queue ends the call), then WireEvent::Started with sample_rate (input) and output_sample_rate (CallInfo::sample_rate now reads both; absent output = input). An offer whose browser never connects is abandoned after connect_timeout and was never visible to the engine. A SessionGuard removes the registry entry, the per-address count and the permit on every exit, including a panic (catch_unwind around the call: call_panics).

Audio. MediaPath (sync, no sockets) holds the Opus decoder and encoder and two StreamResamplers: 48 kHz -> input rate on the way in (loss concealed from the timestamp gap when contiguous is false, at most max_concealed_frames), output rate -> 48 kHz -> 20 ms frames -> Opus on the way out. Codec work (encode 90-376 us, decode 25-45 us a frame) runs on the inference pool when WebRtcOptions::codec_pool is set, else on the call task (serve warns when there is no pool and max_calls is above 8); the path sits behind Arc<Mutex<..>> so a refused, timed-out or panicked pool job drops its frame but loses no codec state (a poisoned lock is recovered; the call goes on). Inbound audio is rate limited per call (max_audio_packets_per_sec, token bucket, default 200, excess dropped before the codec and counted), which bounds the codec work one client can cause. Bot audio at another rate is dropped with one fault.

Interruption. Clear resets the resampler, the partial frame and the encoder and forgets pending marks; what is already on the wire plays out. Mark flushes the held audio, then is acknowledged mark_delay later (browsers report no playout). RTP timestamps follow the media, with a jump and marker bit after a pause (tested on the received RTP time and talkspurt bit).

Bounds and faults. Calls (max_calls), per-address calls, signaling connections, body 64 KiB, header 8 KiB, one request per connection, deadlines on every read and write, bounded queues with named policies, accept queue. A stuck engine stalls its own call's task for at most send_timeout, then the call ends.

Hosted by the binary (brisvox-server feature webrtc, docs/OPERATIONS.md "WebRTC"): BRISVOX_WEBRTC_* configure a WebRtcServer that hosting::Listeners runs like the other transports, as a child of the manager's shutdown token. The transport has no admission gate, so the accept loop admits a call when it connects and hangs up when the manager is full (the other listeners refuse before the upgrade). The Opus codec runs on the inference pool (HostSettings::with_codec_pool, set by main from the pool it creates). examples/web-assistant is a static page for it; it was connected to the hosted listener from Chrome once, by hand.

Tests (tests/webrtc.rs, real sockets on 127.0.0.1, a second str0m peer plays the browser): signaling round trip and every refusal, auth, CORS, demo page, call limits, ICE-lite and full-ICE connectivity, audio both ways through TransportSession with a mark, a clear and a new epoch, the inference-pool path, DELETE and DTLS close as hang-up, a sendonly offer, rate mismatch, abandoned offers, full accept queue, several calls, shutdown without leaked tasks, loss concealment on a lossy wire and no concealment for DTX jumps, a packet flood against the rate limit (timers still run), the RTP time line, the latency median, pool panic/timeout/refusal, and a vanished browser (simulated time, no #[ignore]).

Reachability. The server is ICE-lite, so a call needs a path to the server. Two additions, both opt-in, both in new modules so signaling.rs stays small:

  • ICE-TCP, passive (webrtc/tcp.rs, WebRtcConfig::tcp_addr): a second host candidate (typ host tcptype passive, lower priority than UDP: str0m host/UDP 126, host/TCP 90). A browser that cannot use UDP connects to it and speaks the same STUN/DTLS/SRTP in RFC 4571 frames (2-byte big-endian length). The listener is unrouted per connection: the first STUN request names the call by its ufrag (Registry::by_ufrag, the lookup the UDP socket uses) and pins the connection to that call; from then on frames go to the call's inbound queue as Datagram { proto: Tcp, reply: Some(writer) } and the call's str0m sees Receive::new(Protocol::Tcp, remote, tcp_candidate, ..). The call remembers (remote address, writer) per connection (at most 4) and frames Transmits with proto == Tcp onto that writer's queue. Bounds: max_tcp_connections (extra connections are closed unanswered), tcp_idle_timeout (absolute deadline for a connection that has not named a live call; junk does not extend it), 30 s without a complete frame once pinned, tcp_max_frame (a longer announced frame closes the connection before the body is read), a fixed 4 KiB read buffer per connection, an outbound queue of tcp_queue frames with drop-oldest (whole frames only, so the stream stays framed; the call task never waits on a socket), the write timeout of the config, and the connection closes when its call leaves the registry. One allocation per outbound packet (header plus payload); the path is a fallback, not the hot one.
  • Browser TURN credentials (webrtc/turn_creds.rs, WebRtcConfig::turn): the server never uses TURN for the browser's media (its own TURN client is described below); it gives the browser short-lived relay credentials, as Link: rel="ice-server" headers on the WHIP 201 (RFC 9725 section 4.6) and as JSON from GET <whip_path>/ice (same bearer check as POST, no-store). coturn: computed locally (<expiry>:<session id>, HMAC-SHA1). Cloudflare / Twilio: one REST call under one lock (so concurrent requests share it), 5 s deadline on the injected clock, 60 s cache, a failure remembered for 10 s; provider answers are validated before they reach a header. A provider failure degrades to "no relay", never to a failed call. A browser TURN relay still needs the server's UDP port; hosts without UDP need the server's own TURN client (below).
  • Server-side TURN client (webrtc/relay.rs, WebRtcConfig::turn_relay, BRISVOX_WEBRTC_TURN_RELAY): for hosts with HTTP(S) only (a one-port PaaS such as Railway). Per call, before the answer is built, the server opens a TCP or TLS connection to the TURN service of WebRtcConfig::turn (turns:...:443?transport=tcp preferred), allocates a UDP relayed address, and creates a permission for every IP in the offer's candidates (the browser sends the first check, and a TURN server drops data from peers without a permission). The relayed address is added to the call's Rtc as a host candidate (ICE-lite refuses relayed local candidates), after the direct one so str0m gives it a lower local preference; in only mode there is no direct UDP candidate. One task per call owns the connection and the sans-IO client (turn-client-proto): it splits the TURN byte stream into frames itself (STUN or ChannelData with its stream padding; garbage, a bad length or a frame above max_frame end that allocation only), turns Data indications and ChannelData into Datagram { local: Some(relayed) } on the call's inbound queue (drop-oldest, like UDP), and wraps the call's outgoing Transmits (source == relayed) as ChannelData (channel bound on the first packet, at most 8 peers) from a bounded drop-oldest queue; the call task never waits on this socket. Refreshes (allocation, permissions, channels) run on the injected clock, Refresh with lifetime 0 when the call ends. Setup is bounded by setup_timeout (3 s): on failure the call answers with its direct candidates, or in only mode 503. Counters relay_*. Tests: tests/webrtc_relay.rs against turn-server-proto in-process over TCP and TLS. Facts, sources and the crate's deviations (unpadded ChannelData, USERHASH): docs/providers/webrtc.md section 12. Tests: tests/webrtc_tcp.rs (a str0m client whose only candidate is TCP-active completes ICE and DTLS and carries audio both ways with datagrams_in == 0; frames cut into 5-byte pieces and several frames per write; oversize frame; unknown ufrag closed after the idle timeout despite junk; the connection limit; close on call end; both candidates in the answer with UDP preferred), tests/webrtc_turn.rs (coturn formula and Link headers, GET /whip/ice, auth/CORS, Cloudflare and Twilio against a local mock of their REST APIs: request shape, caching, failure and hang degrade, one shared fetch).

RTVI transport

brisvox_transport::rtvi (feature rtvi, on by default) serves the Pipecat client SDKs (@pipecat-ai/client-js + websocket transport) on the generic websocket server infrastructure, like the Twilio adapter: RtviServer wraps a WebSocketServer::serve_with. Facts: docs/providers/rtvi.md; usage and the manual check with a real JS client: docs/RTVI.md.

Wire: every message is ONE binary websocket frame holding ONE protobuf pipecat.Frame (rtvi::proto, prost messages derived by hand, no protoc). RTVI JSON is a string in Frame.message, audio is Frame.audio (PCM16 mono). The server never sends text frames (the JS client throws on them) nor field 5 (InterruptionFrame, unknown to the JS schema).

Rates: asymmetric. The call reports 16 kHz in / 24 kHz out through the additive metadata keys input_sample_rate / output_sample_rate (CallInfo::sample_rate reads them and falls back to sample_rate, so the engine pump needed no change). The JS player ignores the frame's sample_rate; as everywhere the adapter never resamples and drops bot audio at another rate.

Handshake: the call is handed to the engine after client-ready (bounded by start_timeout), then bot-ready echoes the request id. Version check as Pipecat (warning error-response, call continues).

Side channels: the core's event enums have no place for RTVI application messages, so a call is an RtviCall { link, sender, inbox }. RtviSender (bounded 64, waits up to send_timeout) carries typed RtviEvents and replies to the writer; RtviInbox (bounded 32, try_send: full or unread means the adapter answers the request with error-response itself) carries client-message, send-text and llm-function-call-result. dtmf and disconnect-bot map to core events.

No playback ack in RTVI: rtvi::playout estimates the client's playout on the injected clock (ends = max(ends, now) + duration); engine marks are acknowledged when the estimate reaches them, the estimate is reported as WireEvent::Lead, and bot-started-speaking / bot-stopped-speaking / bot-interrupted are derived from it (RtviConfig::auto_events, switchable per call with RtviSender::set_auto_events; the engine's RtviBridge turns it off when it sends them). A barge-in drops the adapter's queued audio but cannot flush what the browser already holds.

Production voice pipeline

Designed 2026-10-08 from three independent proposals (latency-first, correctness-first, test-first) that were judged and merged. This file is the design reference for the production pipeline; section 12 records how each part was built.

0. Decisions that settle the proposals

Base: the Proposal 2 process (Exact and Budget profiles, hybrid-chain rollout, Segmenter::push_spans) and the Proposal 1 correctness machinery. Latency choices come from Proposal 0.

#DecisionSource and reason
D1New crate brisvox-engine depends on core, audio, inference, agent, providers and transport. It dev-depends on brisvox-sim. Sim never depends on engine. The reference pipeline in sim stays untouched as the golden Exact baseline.All three proposals
D2Self-wake core change is minimal: SystemFrame::Wake{token:u32} plus ProcessorContext::self_sender(). The payload travels in a typed, bounded EventSlot<T> (Arc<Mutex<VecDeque>>) owned by the stage. Frame stays 64 bytes and keeps its derived PartialEq/Eq. Wake is system lane, never purged, and never forwarded.Keeps TurnDriver single-threaded and deterministic in one ordering domain. Avoids the manual Eq/Debug impls that Custom(Arc<dyn Any>) would need. Fallback: Custom.
D2aEventSlot::push_and_wake coalesces wakes per slot with three states (idle, sending, queued). A push skips its send only while a Wake is already queued in the lane; a rejected push on a non-empty slot still wakes unless one is queued. A helper that finds another helper still sending sends its own wake instead of trusting it (that send may be cancelled), so wakes in flight are bounded by the number of helpers awaiting the lane, never by the number of pushes, and no queued event is left without a wake behind it. The stage must empty the slot on every Wake.Review finding: the first design (one wake_pending flag, rejected pushes never wake) could strand events permanently after a cancelled helper. The conservative option costs at most one extra harmless wake per concurrent helper.
D3RunningPipeline::stats() exposes per-stage QueueStats and stale_dropped.P1
D4STT sits before TurnDriver, and everything flows downstream. TurnDriver feeds the Smart Turn ring from audio that STT forwards (a refcounted Bytes clone). Upstream traffic is only rare control frames: TtsStarted and TtsEnded.Removes the upstream-finals deadlock surface of P1. Resolves the "STT consumes audio" contradiction in P2.
D5Two things were rejected: seq packing, and the find-from-cursor whitespace hack. Sent text comes from Segmenter::push_spans, which is additive in agent.Judges
D6Utterance identity reaches TransportOut through a new ControlKind::UtteranceStarted{utterance}. TtsStage emits it from its reader task immediately before the first audio frame of each utterance. A test verifies that the control frame shares the data-lane ordering and fits the 64-byte cap; if it did not, the fallback would be seq packing.Typed, no side channel
D7Model work (VAD, Smart Turn, large assembler finish) uses Offload::{Pool(PoolHandle), Inline(SharedClock)}. The sim uses Inline with an EnergyModel: SpeechModel and a scripted TurnModel. Real Silero and Smart Turn are covered only by real-time tests and the bench.P0
D8No second pacer. TransportSession::open already contains OutputPacer. The pipeline tail is TransportOut.All proposals
D9Cross-stage side channels are limited to three. Each has a D-entry. (a) PlayoutBook (std Mutex, written at word and 10 Hz position rate). (b) The CommandSender for the fast-path Clear, held by TurnDriver and TransportOut. (c) Arc<Mutex<Context>>, shared with the sim probes.

1. Stage list and frame flow

TransportSession.recv()
   |  pump task (TaskGuard; always drains recv; started AFTER pipeline.start(), it needs
   |  input() and upstream_input())
   |    Audio    -> retag epoch ZERO, try_send into first-stage queue
   |    Started  -> parse metadata["sample_rate"] into CallRates{input, output}
   |    Dtmf     -> DataFrame::Dtmf in epoch 0 (the TurnDriver re-tags it with the current epoch)
   |    MarkPlayed / MarkCleared / PlaybackPosition -> PlayoutBook
   |       (+ for the end-of-utterance mark: Upstream Control TtsEnded via upstream_input())
   |    Stopped (and Error only with hangup_on_error) -> Hangup path (SystemFrame::Stop; lossless frames wait in one FIFO per
   |       target lane, so a DTMF stuck on a full data lane never holds back Stop or TtsEnded)
   v
[1 AudioIn]   KeepAll, e0, REALTIME_INPUT queue (data cap 8 = 160 ms, drop-oldest, counted)
              resample call rate -> 16 kHz (StreamResampler, inline <50us, equal rate = copy)
              forward 16k AudioFrame immediately
              tee a COPY of the 16 kHz samples (about 640 B) into a recycled Vec<i16> for VadWorker
              (bounded, drop-oldest, gap flag; a Bytes::from(Vec) clone would allocate a Shared box)
   .. VadWorker (helper task, TaskGuard): accumulates all queued frames (cap 8) into ONE job in flight
   .. offload.run(timeout, move |..| { stream.push(..); (stream, events) })
   .. emits backdated UserStartedSpeaking/UserStoppedSpeaking via ctx.sender(Downstream) (system lane)
   .. opt-in (VadSetup::with_early_analysis): also UserSpeechFalling{at} once per fall, when the
      probability stayed below the negative threshold for fall_delay, before min_silence confirms the stop;
      `at` is exactly the time the stop of that fall will carry. SttStage forwards it (its `_` arm), the
      TurnDriver consumes it: it snapshots the Smart Turn ring and analyses early, and reuses the verdict
      at the stop of the same fall (see TurnDriver module docs, "Early analysis")
   v
[2 SttStage<P>]  KeepAll, e0. Connects at Start (prewarm).
              audio -> provider via try_send (drops counted), and forwarded downstream
              on UserStoppedSpeaking: forward the stop, then send Finalize at once (speculative)
              reader task: Transcript (interim lossy, final reliable) + ControlKind::UtteranceFinal
              exactly one UtteranceFinal per Finalize, also on timeout or dead session
   v
[3 TurnDriver]  KeepAll. SOLE EPOCH AUTHORITY. Owns TurnMachine, SmartTurn ring, timer slot, finals gate.
              consumes audio (feeds ring), VadStart/Stop, Transcript, UtteranceFinal, Wake events,
              upstream TtsStarted/TtsEnded, Stop
              emits Interrupt (system), Control UserTurnStarted,
              then (after the gate) Transcript{final, joined} + Control UserTurnEnded
   v
[4 LlmRunner<L>]  DropStale. Per-epoch round task. -> LlmResponseStarted, LlmText deltas, Flush,
              LlmResponseEnded
   v
[5 SegmenterStage] DropStale. -> LlmText chunks (raw slices)
   v
[6 TtsStage<T>]   DropStale. -> UtteranceStarted, Audio (20 ms, call output rate), Word, TtsEnded
   v
[7 TransportOut]  KeepAll (as built: it filters epochs itself so it can count what it
                  drops; see section 12). Frame -> TransportCommand. Tail. Not a pacer.
  • Queues. Audio stages use data cap 4–8 and system cap 32. Lossy: the VAD tee, STT audio send, and the pump-to-stage-1 queue. Lossless: control events. Bot-side audio uses Backpressure, bounded by sink_capacity 4 and the wire queue.
  • Upstream rule (core test). Upstream pushes come only from helper tasks, the pump, or a non-blocking try_send inside process(). They are never an awaited push from process(). A core stress test of a bidirectional chain with a slow consumer proves there is no deadlock.
  • Helper-task rule. Helper tasks are TaskGuard-owned and abort when the processor drops. They send through GuardedSender{FrameSender, EpochWatch, epoch}, which checks latest() <= epoch immediately before each send.
  • System-lane overtaking. VAD Stop reaches STT ahead of queued audio. This is safe because Stop is backdated at least min_silence behind the last speech sample, so unsent audio is trailing silence. A test with a deliberately slow STT inbox confirms that Finalize never precedes real speech audio.

2. Ownership of epochs and turns

  • TurnMachine in TurnDriver bumps the epoch at every new user turn. Interrupt{new_epoch} is emitted only on a real barge-in. User-side stages (pump, AudioIn, VadWorker, Stt) stay on epoch ZERO with KeepAll. Bot-side stages are DropStale and adopt the epoch from frames.
  • Ordering on a barge-in: Interrupt always precedes UserTurnStarted.
  • TurnDriver is one Processor. Frames and Wake events are handled in the same task. Wake events carry timer ticks, Smart Turn verdicts and finals-gate deadlines. All times come from clock.now() and clock::wait_until. There is no Instant::now().
  • Action mapping:
    • StartUserTurn pushes UserTurnStarted.
    • RequestAnalysis{id} takes SmartTurn::snapshot() (an owned Vec<i16>) and spawns a job through Offload that calls SmartTurn::analyze_snapshot_blocking (the engine's Offload is the one place jobs go, and brisvox-inference cannot depend on it; see section 12). The verdict returns as a Wake event. A stale or failed verdict is ignored, and the fallback_silence timer ends the turn.
    • ArmTimer/CancelTimer replace a single timer helper task.
    • Interrupt runs the barge-in path in section 3.
    • EndUserTurn goes through the finals gate.
  • Finals gate.
    • owed_finals is +1 per VadStop forwarded and -1 per UtteranceFinal.
    • The segments belonging to one stop are joined into exactly ONE TurnInput::SttFinal.
    • When the machine says EndUserTurn while finals are owed, the end is held until they arrive or final_wait (config, default 1.5 s) expires. On expiry a metric is counted and the turn proceeds.
    • Finalize is sent at the VAD stop and Smart Turn runs concurrently. The added wait is therefore max(finalize RTT, Smart Turn), not the sum.
    • An empty joined transcript produces no UserTurnEnded downstream. The driver feeds BotAudioEnded itself (the caller obligation).
  • response_deadline backstop. After UserTurnEnded the driver arms a deadline (default 20 s) that bounds "the bot never produced audio" (a stalled LLM or TTS). The first TtsStarted of that epoch (TransportOut sends it once, at the first audio handoff) cancels it, as does TtsEnded: a response that is playing must never hit the backstop, because the synthetic BotAudioEnded would make the machine Idle while the bot still talks and the user's next speech a plain new turn without Interrupt and Clear. The end of playback is then guaranteed by the transport's own mark timeout. On expiry the driver feeds a synthetic BotAudioEnded and counts a metric. This closes the known AwaitingResponse gap.
  • Timeline marks. UserSpeechStart, UserSpeechEnd, SttInterim, SttFinal and TurnEnd are marked in the driver. interrupt(old, new, at, speech_start) is called on a barge-in.
  • Transport epoch ledger. The ledger advances only through Clear{epoch}, but the turn epoch advances every turn. The rule is:
    • TransportOut keeps tx_epoch.
    • Before the first Audio of any epoch greater than tx_epoch, it sends Clear{epoch}. On an idle transport this cuts nothing.
    • Clear is idempotent for the same epoch.
    • Without this rule the second turn's audio is silently dropped.
  • Barge-in guards. The driver's machine judges candidate barge-ins with the real InterruptionPolicy, configured by EngineConfig::interruption; see section 12, "SttStage and TurnDriver". With InterruptionConfig::OFF every barge-in interrupts at once (the plain turn-machine behaviour).

3. Barge-in path, step by step

  1. VadWorker job completes and pushes UserStartedSpeaking{at} (system lane). STT forwards it.
  2. TurnDriver feeds VadStart. The machine returns Interrupt{new_epoch} (bot active).
  3. In the same process() call, the driver does these in order:
    1. timeline.interrupt(...).
    2. FAST PATH: CommandSender::send(TransportCommand::Clear{epoch:new}). This is applied inline in the transport, bypasses the command queue, and the transport drops queued audio, sends Clear to the adapter, flushes the final PlaybackPosition and emits MarkCleared.
    3. Push System Interrupt{new_epoch} downstream.
    4. Push Control UserTurnStarted{new}.
  4. LlmRunner receives Interrupt. It cancels the epoch token, aborts the round task and joins it with a bounded timeout. The stream and assembler are dropped. If tool calls were pending it calls Context::interrupt_pending_tool_calls(). If a round had streamed text but no Done, it pushes a partial assistant message with the raw text so far (a superset of what was sent). It records pending_cut(old_epoch). It forwards Interrupt. It never mutates the Context while the round task is alive (join first).
  5. SegmenterStage calls reset(). TtsStage calls cancel.try_send(ctx) for every open ContextId of the old epoch (priority channel) and resets the resampler. Late audio is dropped by DropStale, unknown ContextIds, and GuardedSender.
  6. TransportOut marks AudioOutStopped on the interrupted epoch and sends an idempotent Clear.
  7. At the next UserTurnEnded the runner applies truncation (section 5), then pushes the user message.

Target: Interrupt decision to Clear applied ≤ 1 ms (step 3.2). No stale audio reaches the wire afterwards.

4. LLM runner and tool loop

  • Arc<Mutex<Context>> is a std Mutex, never held across an await. It is the probe surface for the sim.
  • A single seam RequestBuilder::build(&Context) -> providers::LlmRequest passes the full context to the provider; the long-call context window (build_window) is applied there. The tool list is name-ordered for prompt-cache stability.
  • A convert module maps agent Context/Message/ToolSpec to providers types. This covers assistant tool_calls with provider_id, arguments serialized to JSON text, and provider_data Value to JSON text. It also maps LlmEvent to assemble::Delta, including Done.provider_data.
  • provider.warm() runs at Start.
  • On UserTurnEnded(epoch), in this order: apply pending truncation, push the user message, spawn ONE per-epoch round task (child CancellationToken, TaskGuard slot).
  • Per round r, inside the task:
    1. Push LlmResponseStarted{epoch}. Register (epoch, round_ordinal) in the PlayoutBook with msg_index=None.
    2. timeline.llm_request(epoch) returns the round index, which is passed to llm_first_token and llm_done.
    3. provider.stream runs under clock-based first-token (8 s) and idle (15 s) timeouts.
    4. TextDelta goes to the assembler AND is emitted as LlmText immediately (leading whitespace of the first delta stripped once, before both). ToolCallDelta goes to the assembler.
    5. At Done, assembler.finish(epoch,&ctx) runs inline when the buffer is small (<~4 KiB) and on Offload when large. The assistant message is pushed ONCE, before execution. The PlayoutBook is updated with msg_index.
    6. No calls: push LlmResponseEnded and end the turn.
    7. With calls: push Flush, so the text before the tool is spoken during execution. Then race ToolEngine::execute(calls, epoch, &token) against clock.sleep(delay), the delay of the FillerPolicy (LlmConfig::filler, default 1.2 s).
    8. If the sleep wins, emit a canned filler as its own round (own LlmResponseStarted). It enters the Context once no call is open (before the next assistant message, or when the turn is settled), so its msg_index is set then and it is cut to what was heard like any round.
    9. After execution: Execution::append_to, then take_late_notes() only when no call is open, then the next round. The limit is max_tool_rounds (default 6). On budget_exhausted or the round limit, speak a fixed fallback line.
  • Speculation is opt-in and not part of the base round loop (see the end of section 12). Speculative tool execution is not done because it would break "only played text in context" and side-effect safety. SideEffect calls are dispatched once, after Done. The round task is never re-run after dispatch. Exactly-once comes from the ToolEngine idempotency key.
  • Errors. One bounded retry on LlmEvent::Error or timeout is allowed ONLY before any text or tool dispatch. Otherwise the turn ends with LlmResponseEnded and no invented speech. An empty or errored response makes the runner push an upstream-safe TtsEnded (non-blocking), so BotAudioEnded fires.

5. Played-text truncation (marks plus released audio)

Source of truth.

  • PlaybackPosition is an upper bound: audio released to the wire, ahead of the caller by sink 4 + wire queue 8. It is the continuous source.
  • MarkPlayed is the exact boundary.
  • Heard position = min(released, total_audio) − lead. lead is the client's reported playback lead when the transport sends one (PlayoutBook::client_lead, Twilio PlaybackLead; the report in force when the Clear cut the utterance), else lead_margin (configurable, default wire-queue-depth × 20 ms, and 0 in loopback tests where the oracle is wire arrival time). The audio of later utterances sits behind an earlier one in the client's buffer, so it is taken off the lead first (RoundSnapshot::later_released). For an interrupted response the fixed lead_margin (not a reported lead) shrinks by the time between the last audio released to the wire for the utterance or the ones behind it and the barge-in (RoundSnapshot::margin_at): after a tool or filler wait the client's buffer has drained and nothing is subtracted.
  • A mark-played utterance is kept whole, and so is one followed by a later utterance whose mark was echoed. This also avoids truncating text the TTS rewrote, such as numbers. A mark echoed AFTER a Clear covered the utterance does not count (see "As built").
  • The final position is flushed by the transport on Clear, if audio was released since its last report.

PlayoutBook (engine, std Mutex, never across an await, plus a watch for settle notification) is keyed explicitly by (epoch, round_ordinal). Rounds are never zipped to utterances by order.

  • Runner writes: round -> msg_index: Option<usize>.
  • TtsStage writes: round -> utterance_id, plus the verbatim sent slices (push_sent).
  • TransportOut and the pump write: words, positions, marked_played, cleared, lost and total_audio.
  • settled is set when MarkPlayed, MarkCleared, or the final position after Clear arrives. While an end mark is outstanding only MarkPlayed/MarkCleared settle (the transport sends the final position before MarkCleared, so a stale periodic report cannot); a mid-utterance barge-in has no mark yet and takes the first position after note_clear as final (known limitation, see the playout module docs). Utterances of rounds already cut (applied) are ignored by the wait, and so are cut utterances of rounds without a context message (filler, fallback), which mark_applied never reaches. A round owns at most one utterance, and an utterance id belongs to one round; conflicting registrations are ignored and counted.
  • Old epochs are evicted (bounded).

Procedure (runner, at UserTurnEnded and at Stop), before refresh_summary and before build_window:

  1. Wait for book.settled(epoch) of the cut epoch's utterances, bounded by the clock (default 250 ms, counted from the barge-in, so nothing is waited for when the user talked on for longer). On timeout use the last known position and count settle_timeouts.
  2. Walk the rounds from last to first, so Removed outcomes do not shift earlier indices. A round with msg_index=None is skipped.
  3. For each round: if marked_played (or followed by a played utterance) and not lost, keep it as sent. Otherwise build PlayedText::heard(position, &PlayedConfig) and call apply_truncation(&mut ctx, idx, &trunc).
  4. A round never reached (position 0) goes through apply_truncation with an empty heard prefix. Whatever outcome apply_truncation produces is the policy, identical to the reference LlmStage. The engine adds no omission policy of its own.
  5. Orphan tool results are removed together with a removed assistant message. Assistant messages that keep tool calls are retained so tool pairs stay valid.
  6. Record TruncationStats.
  7. Assert Context::validate after every application, in debug and in all tests.

Sent text. SegmenterStage uses Segmenter::push_spans/finish_spans and emits the raw slice raw[prev_end..chunk_end], including its leading whitespace. The concatenation of all sent slices is then byte-identical to the assistant text, which satisfies PlayedText::push_sent/apply_truncation (no NotAPrefix). TTS tolerates leading spaces, and Cartesia continue:true wants them.

Oracle. The sim oracle is wire truth: the LoopbackClient received_at of each 20 ms frame, with a documented ±1 frame tolerance. position_interval is 20 ms in tests.

6. Rate conversion

  • Input. The pump parses Started.metadata["sample_rate"] into typed CallRates{input, output} via a new CallInfo::sample_rate() in transport. An unknown rate is a fatal Error frame. AudioIn resamples input → 16 kHz.
  • Output. The TtsStage reader task owns one StreamResampler per session, 24 kHz → CallRates.output. It has flush() at Flushed, reset() on ContextId change or Interrupt, and a reused scratch buffer.
    • Each provider chunk is copied once into a BytesMut, then cut into exact 20 ms AudioFrames by zero-copy Bytes::slice. The remainder carries over to the next chunk.
    • This is one allocation per provider chunk and none per frame. It keeps interrupt granularity and PlaybackPosition accuracy at 20 ms.
    • The transport never sees a rate mismatch.
  • Measurement. If a resampler call exceeds ~50 µs in the bench, it moves to the pool.
  • Telephony rates. Resampling is part of the output chain. The remaining check is the 8 kHz μ-law WebSocket test with a 24 kHz TTS, which confirms that μ-law encoding lives in the adapter and not in the engine.

7. Error and reconnect policy

  • Every network or IO await has an explicit clock-based timeout. Defaults: connect 5 s, send 2 s, reconnect backoff 1 s with a cap of 3 retries, then degrade.
  • STT death: synthesize UtteranceFinal (the turn proceeds with what it has), and reconnect on the next turn.
  • Behind the resilience layer (brisvox_providers::resilience, opt-in per stage) most of these deaths never reach the engine: the wrapper recovers in place or on a secondary provider and replays the open utterance (STT), re-issues a request that failed before content (LLM) or re-speaks unheard utterances (TTS). See "Resilience layer" at the end.
  • TTS death: mark_lost, reconnect and TtsEnded. The heard prefix stops before the first untimed word.
  • LLM error: see section 4.
  • Pool failures (PoolSaturated, timeout, JobPanicked):
    • Drop the batch and count it.
    • If a stream was lost, rebuild it via model.stream() and reset the gap.
    • N consecutive failures (default 5) produce a non-fatal Error frame. The stage then fails open: it keeps forwarding audio and emits no VAD events, plus a metric.
  • Err from process is reserved for unrecoverable invariants. Fatal ErrorFrames go to the supervisor, and a non-fatal Error goes to a counter plus tracing target brisvox::engine.
  • Transport Stopped: TurnMachine Hangup, cancel the round and tool tokens, running.stop(timeout). Dropping the call future aborts every TaskGuard, so no tasks are left behind.
  • One session's panic stays inside its own tasks. The only shared object across sessions is the bounded inference pool.
    • The pool has two lines (brisvox_inference::Priority): the VAD batch of every call goes in the High line, everything else (Smart Turn analyses, warm-ups, builds) in the Normal one; a worker takes a waiting High job first, and each line is bounded by the queue capacity on its own. Under overload the Smart Turn analyses wait (and fail by their own timeout, which the turn machine handles as a missing verdict) before a VAD window is lost. The queue capacity must cover one job per call in each line (brisvox-server sizes it max(1024, 2 x max_sessions)).
    • Smart Turn analyses have an executor of their own (SessionBuilder::with_smart_turn_offload, brisvox_engine::offload::start_smart_turn_executor): a second bounded pool (32 jobs per thread, reject newest) with the same timeout and failure handling, so a 20-25 ms analysis can never hold the thread a VAD window needs. The model's warm-up sizes itself on the executor's threads. Without with_smart_turn_offload (and with BRISVOX_SMART_TURN_THREADS=0) the analyses use the session's offload as before.

8. Latency design and budget

  • Prewarm at Start: provider.warm(), STT and TTS connect, and a Smart Turn warm per pool thread.
  • Parallel work: Finalize at VAD stop runs concurrently with Smart Turn.
  • Audio path: forwarding never waits on the pool (VAD tee). The tee copies about 640 B per frame into recycled buffers: zero allocations in steady state, at the price of a small memcpy.
  • Clear: fast path, directly to CommandSender.
  • Segmenter: first clause after 2–3 words. As built: after one word in English and Turkish. English is decided from measurements (docs/LATENCY.md, "First TTS chunk"); Turkish follows it as an assumption, not yet measured.

Engine-added overhead budget, excluding VAD silence, model and provider time:

  • gated turn end → LLM request issued: ≤ 3 ms p95
  • first TTS audio byte → transport command: ≤ 2 ms p95
  • Interrupt decision → Clear applied: ≤ 1 ms

These are measured in the V2V bench (brisvox-bench v2v --sim, the engine's own timeline: docs/LATENCY.md, "Engine stage times and memory"); all three are met.

9. Test strategy: sim suite against the production pipeline

  • Seam. brisvox-sim gains trait SessionFactory { build(SessionParts) -> BuiltSession{pipeline, probes, InputSink, OutputLog} }. ReferenceFactory reproduces today's chain exactly. Scenarios s01–s11, played_text, fixtures and chaos bodies move into brisvox_sim::suite, generic over the factory and a Profile{Exact, Budget}. Existing sim tests must keep byte-identical results: fingerprints are checked before and after the refactor.
  • Profiles.
    • Structural invariants are identical under both profiles and asserted exactly: outcome Ok; determinism (two production runs have identical fingerprints); no stale audio after an interrupt; context == played prefix; roles and turn counts; interrupt counts (0 for s06 and s09, 1 for s02 and s10); recovery; isolation versus solo; no leaked tasks (TaskCounter); heap-flat.
    • Exact timings (1480 ms, 280 ms, 660 ms) stay on the reference. Production uses a documented Budget table with ranges. Fixtures reach first audio within 600 ms of utterance end, and the chaos SLO is 1.5 s.
    • Production fingerprints are compared only to production fingerprints.
  • Interrupt counts come from Interrupt frames (a test-only recording tap before TransportOut), NOT from client Clear events, because Clear fires on epoch bumps too.
  • Hybrid chains prove each production group against the untouched reference stages, each gate running s01–s05 or s01–s11 structural invariants under a paused SimClock:
    1. Engine pump/AudioIn/VAD + reference STT/Turn/LLM/TTS/pacer.
    2. Engine input chain + STT + TurnDriver + reference downstream.
    3. Engine input chain + LlmRunner + reference output.
    4. Everything in engine.
  • Harness. Input in tests comes from LoopbackClient::send_audio through the real TransportSession and pump. LoopbackClient gets an auto-ack-marks option (configurable delay and a never-ack mode). LoopbackConfig.latency=0.
  • Dedicated tests. The following each need their own test:
    • Two consecutive non-barge-in turns both reach the wire.
    • Races: a verdict after a new VadStart, and BotAudioEnded after Interrupt.
    • Finalize never precedes speech audio, with a slow STT inbox.
    • Tool exactly-once with a barge-in during execution, then a new turn: body counter == 1, late note exactly once.
    • Multi-round turns (text, tool, text) with a barge-in in round 2.
    • Settle-timeout fallback.
    • A missing final PlaybackPosition.
  • Hot path. A counting-allocator test asserts 0 allocations per steady-state frame on AudioIn → STT → TurnDriver forward, and on the TTS slice path (per-chunk allocation is documented). A multi-thread real-time test (SystemClock, real pool, silero-tract, 20 sessions) has a scheduler-gap probe asserting no tokio worker stall over 2 ms. Micro-benches of Segmenter::push, ResponseAssembler::push/finish, TurnMachine::handle and the resamplers are held to the 50 µs rule.
  • Chaos. 100 production calls cover all chaos kinds plus HangUp. SlowTransport is a slow LoopbackClient consumer.

10. Build order

The pipeline was built in ten stages: the simulation seam (session factory, input sink, output log, shared suite with exact and budget profiles), the core self-wake and engine skeleton, the input chain, the STT stage and turn driver, the LLM runner, the output chain, played-text truncation from the play-out book, assembly (run_call / build_session_pipeline), chaos / isolation / leak / allocation / blocking verification, and latency tuning. Section 12 records what was built per component.

11. Main risks

  1. Wake/EventSlot self-wake touches core. Mitigations: core tests first, the 64-byte assert, and Custom as the documented fallback.
  2. PlaybackPosition is an upper bound. Real adapters may over-include the last words. Mitigations: marks, lead_margin, and the settle wait. The sim cannot prove real-network accuracy.
  3. Epoch/Clear coupling. A missed lazy Clear leaves the bot silent without any error. The output chain has a dedicated two-turn test and a counter for transport-dropped stale frames.
  4. Pool versus paused time. Inline offload can mask pool-only bugs (state loss, job ordering). Only the real-time test and the bench cover these.
  5. Finals gate is new logic beyond the reference. A final that never arrives misaligns the machine. Mitigated by the one-per-Finalize guarantee, final_wait, and driver-side counting.
  6. Budget profile loosening. Budget ranges must be set from measured values and reviewed. Never compare production fingerprints to the reference.
  7. Scope. The turn driver, the LLM runner and the output chain are the heaviest parts.

12. As built: the input chain

Code: crates/brisvox-engine/src/{pump,audio_in,vad}.rs. Details live in the module docs; the decisions that go beyond sections 1 to 3 and 7:

  • Pump (Pump::spawn(session, PumpLinks{input, upstream}, book, clock, config, cancel) -> PumpHandle). It owns the TransportSession and is started after the pipeline (it needs RunningPipeline::input() and upstream_input()). Audio is try_send only. Lossless frames (DTMF, Stop, a fatal Error, the upstream TtsEnded) go through bounded FIFOs, one per target lane (data, system, upstream), each delivered by its own select! branch, so a stuck pipeline never stops the reading and a frame stuck on one lane never holds back another. The Stop therefore overtakes a key press that is stuck on a full data lane (hangup beats the last digit; the digit is still offered until delivered or the flush gives up). Hangup is SystemFrame::Stop into the first stage plus PumpHandle::end(); a hangup is also issued when the event stream ends without Stopped (a defensive branch: TransportSession always sends Stopped unless it is lost to a full queue, which no test can provoke through the public session, so that branch is untested), and the FIFO is flushed (bounded by flush_timeout) after the session closes (tested: the_pending_stop_is_flushed_after_the_session_closed). After a hangup audio and DTMF are discarded and counted while marks and positions still reach the book (tested: after_a_hangup_nothing_reaches_the_pipeline_but_marks_still_reach_the_book). The call's rates are PumpHandle::rates(). TransportEvent::Error is a NON-FATAL transport fault (the Twilio connection keeps its own fault budget and ends the call with Stopped when it is spent), so by default the pump only counts it and logs a warning (hangup_on_error, default OFF; review fix, it used to hang up on the first fault and defeated the transport's budget). hangup_on_error: true restores the strict reading where the first Error hangs up.
  • Epoch of user-side frames. The pump tags audio and DTMF with epoch zero. The TurnDriver re-tags DTMF with the current epoch before it goes to the bot side.
  • End mark. PlayoutBook::mark_played_resolved returns the utterance's epoch, so the upstream TtsEnded carries it. A cut mark (MarkCleared) updates the book and ends nothing upstream (the barge-in's Interrupt already told the machine).
  • AudioIn forwards a 16 kHz frame untouched (same Bytes), converts other rates through a StreamResampler into a ring of 32 reusable BytesMut slots (no allocation per frame once the ring has been used once), copies the 16 kHz samples into the VadTee, and never waits for the worker. A jump in AudioFrame::seq (frames dropped by the first-stage inbox) resets the resampler and marks the tee frame as following a gap.
  • VadTee: bounded (the input queue's data capacity, 8), drop-oldest, copies samples into recycled buffers (so it does not depend on how the transport allocated its Bytes), wakes the worker with a Notify permit.
  • Gap flags and timestamps (review fixes). A seq jump, a frame lost in conversion, or a resampler error sets a pending gap that is carried to the next tee frame actually pushed (a frame without resampled output cannot lose it). Resampled output is stamped with the time of its first sample (completing frame's timestamp, plus samples produced, minus samples consumed, minus StreamResampler::delay()), not with the completing input frame. A rate change resets the resampler even when the frames in between were at the model rate.
  • VadWorker: takes everything queued as one batch, one job in flight on the Offload; a batch is split into contiguous segments and the stream is closed (VadStream::finish) at every gap, so speech in progress gets a stop dated to the last speech. A job the pool refused (saturated) or timed out before a pool thread started it never ran, so nothing is lost: the stream and batch wait in a slot the job takes them from, the worker takes them back, waits 10 ms on the pool's clock and offers the same audio again with what arrived meanwhile (retries; kept audio bounded by max(tee capacity, 50 frames), beyond that shed like a tee overflow). Overload becomes delay, not a lost turn. A job that started and was lost (panic, timeout while running, closed pool) loses the stream: a stop is synthesized if speech was in progress and the stream is rebuilt from the setup's model factory. max_consecutive_failures (default 5) failed batches in a row that also span at least failure_window (default 1 s of audio time, from the batch timestamps) fail the worker open: one non-fatal Error frame, no VAD events, the tee is only drained. Fail-open is NOT permanent (review fix): every recovery_interval of audio time (default 5 s, None disables) the next batch is run as a probe through a fresh stream; the first success clears the failed-open flag and the streak (recoveries counter, a warn log, no frame) and events resume, a failed probe keeps the worker failed open (speech it started is closed again) until the next interval. Batches come every 20 ms, so a count alone would let a 100 ms pool-saturation burst end VAD for the call; a burst shorter than the window heals with the next successful job. EnergyModel (320-sample windows, RMS threshold) lets paused-time simulations run this exact path; with the simulator's thresholds the hybrid marks speech at the same instants as the reference energy VAD.
  • Tests: tests/{pump,audio_in,audio_in_alloc,vad_worker,hybrid_input}.rs (paused time and real-time pool tests) and tests/silero_realtime.rs (feature silero-tract).

SttStage and TurnDriver

Code: crates/brisvox-engine/src/{stt,turn_driver,timer}.rs; the module docs hold the details, the decisions that go beyond sections 1 to 3 and 7:

  • SttStage (SttStage::new(provider, SttStageConfig::from_engine(&config), clock)). process never awaits the provider. Audio is forwarded downstream at once and copied (a refcounted Bytes) into a bounded command queue (mutex-guarded ring plus a Notify permit: audio drop-newest at 64, Finalize at 32, overflow answered synthetically). ONE session task owns the provider session, the ledger of unanswered Finalizes and every frame the stage emits besides the forwarded ones, so transcripts and UtteranceFinals are strictly ordered. The task connects when the pipeline starts (prewarm; explicit connect timeout on the clock) and reconnects after a failed connect or a dead session with a fixed backoff, at most max_reconnects consecutive attempts; then the stage is degraded until the next UserStartedSpeaking grants a fresh budget. A successful connect does not reset the budget (a provider that accepts the socket and drops it would be retried for ever); the budget is restored when the session delivers a final segment or lives at least 10 s. No session means audio is dropped (counted) and every Finalize is answered synthetically at once. A synthetic answer keeps the words the provider had heard: the latest interim since the last final segment is forwarded as a final segment just before the synthetic UtteranceFinal (interims_promoted). A real answer to a Finalize does the same when no final segment was forwarded since the last UtteranceFinal (a session that died mid-utterance leaves its interim behind and its successor answers the Finalize empty); otherwise the caller's words would be lost. Upstream frames are forwarded with try_send (drops counted), never awaited from process.
  • Exactly one UtteranceFinal per Finalize. Only from_finalize && speech_final answers the oldest outstanding Finalize (a speech_final from the provider's own endpointing closes nothing; a from_finalize result with nothing outstanding is late and dropped). A Finalize unanswered after Timeouts::finalize (3 s, longer than the driver's final_wait) declares the session dead: every outstanding Finalize gets its synthetic answer and the session is replaced, so the provider's late answer can never be seen (no per-Finalize ids exist in the protocol, so a kept session could not tell a late answer from the next one).
  • Finalize never precedes the speech it finalizes. The VAD stop is a system frame and overtakes queued audio. A Finalize is therefore held until the stage has processed an audio frame stamped at or after the (backdated) stop time, or max_finalize_defer (1 s) passed, or the pipeline stops. Normally the frame has long been processed and nothing waits. The 1 s cap is a clock deadline (the stage's own DeadlineTimer, woken through SystemFrame::Wake), so it holds even when no further audio arrives (a leg that suppresses silence, a stalled lane): the Finalize is then sent after at most 1 s instead of waiting for the driver's final_wait.
  • TurnDriver (TurnDriver::new(TurnDriverConfig::from_engine(&config), clock, timeline), builders with_smart_turn(model, offload), with_commands(CommandSender), with_interruption(policy), with_probe(TurnProbe)). One task; helpers (the single DeadlineTimer for the machine's, the gate's and the response backstop's deadlines, and the Smart Turn job) return through the EventSlot and a Wake. Handling is level-triggered: on any wake it compares every deadline with clock.now().
  • Smart Turn. The driver owns the 8 s ring (SmartTurn::push of the forwarded audio, no allocation per frame; 128 000 samples, 256 KB; SmartTurn::new allocates only the ring, the buffer of its own analyze path is lazy and the driver never calls analyze). RequestAnalysis copies the ring with SmartTurn::snapshot_into into an owned Vec<i16> and runs SmartTurn::analyze_snapshot_blocking inside Offload::run, so the ring keeps filling. The job hands the buffer back with its verdict (Event::Verdict::samples) and the driver reuses it for the next pause: only the first pause of a session allocates 256 KB on the async worker (a session that has paused holds 512 KB: ring plus recycled buffer; a timed-out job loses its buffer and the next pause allocates again). Measured in release (cargo test -p brisvox-inference --release --lib the_caller_side_copy_is_cheap -- --nocapture, medians of 21 runs, 8 s of audio, 2026-10-08): 1.8 us into a reused buffer, 2.5 us into a fresh allocation (a warm allocator; a cold page fault can cost more, which is why only the first pause allocates). The reused path is far inside the 50 us worker budget of CONTRIBUTING.md. The original plan named analyze_snapshot(offload, samples, timeout); the delivered async analyze_snapshot(&PoolHandle, model, samples, timeout) takes the pool handle because brisvox-inference cannot depend on the engine's Offload (the engine would be a dependency cycle), and the engine, which must also run under Offload::Inline in paused-time simulations, calls analyze_snapshot_blocking itself; the async variant is for pool-only callers. A failed, timed-out or unavailable analysis is counted and the machine's fallback_silence deadline ends the turn. brisvox-inference gained snapshot, snapshot_into, model, analyze_snapshot (pool) and analyze_snapshot_blocking (additive). The VAD stop travels the system lane and can overtake caller audio queued in the driver's data lane, the same hazard the STT stage defers Finalize for. While audio is flowing but the newest ring frame is stamped before the stop, the analysis is deferred (analyses_deferred) until a frame stamped at or after the stop is in the ring, or at most ANALYSIS_DEFER_MAX (200 ms) on the driver's timer (a silence-suppressing leg sends no later frame); a resume, turn end or hang-up drops it. Before any audio has arrived there is nothing to lag behind and no deferral.
  • Barge-in. Interrupt does timeline.interrupt, then the transport Clear{new_epoch} through the CommandSender (or a ClearHook, with_clear_hook; bounded by Timeouts::send; a failed or timed-out clear is counted and the Interrupt still follows), then pushes SystemFrame::Interrupt, then the next action pushes UserTurnStarted. STT interims are fed as SttInterim (and not forwarded); upstream TtsStarted / TtsEnded become BotAudioStarted / BotAudioEnded, which open and close the echo window. EngineConfig::interruption is a Copy description (InterruptionConfig, OFF or RECOMMENDED, default RECOMMENDED) of the agent's InterruptionPolicy; custom phrase lists go through with_interruption.
  • Finals gate. The driver keeps one OwedStop per UserStoppedSpeaking (with the turn it belongs to; None for a vetoed candidate's stop). The final segments between two UtteranceFinals are joined into one SttFinal for the machine and appended to the turn's text. A machine EndUserTurn is held while a stop of that turn is unanswered, until final_wait. When final_wait expires and the turn has no text at all (no final text of an answered stop and no interim), the hold is extended ONCE by TurnDriverConfig::late_final_wait (default timeouts.finalize - final_wait, 1.5 s; zero disables; counted as gate_extensions): the STT stage answers every stop itself at Timeouts::finalize, and a final that is only a little late would otherwise be dropped (late_finals) and the caller left unanswered. A turn with text, or with an interim to fall back on, is not extended. Release pushes the joined Transcript and UserTurnEnded, marks TurnEnd and clears the Smart Turn ring. A gate timeout uses the latest interim if no final text arrived; an empty text emits no UserTurnEnded and feeds the machine a BotAudioEnded. If the user starts speaking while an end is held, it is a RESUME, not a barge-in: the machine is already in AwaitingResponse, but no response was requested and no bot audio exists, so the driver first feeds it the same synthesized BotAudioEnded as for an empty turn and then the VadStart, which opens a new turn without any guard (no minimum speech, no backchannel veto, no Interrupt, no transport Clear). The held end is dropped, its text carried into the new turn and its owed finals re-attributed (the user is answered once, after the whole utterance). A gate timeout never replaces the confirmed text of an earlier stop with a later, unconfirmed interim.
  • Backstop and hang-up. After UserTurnEnded a response_deadline (cancelled by the first TtsStarted of that epoch, and by its TtsEnded; see section 2) feeds a synthetic BotAudioEnded when the bot never produces audio. SystemFrame::Stop closes the machine, drops an open turn and a held end without UserTurnEnded, cancels the timer and any analysis, and is forwarded. Caller audio, VAD events and transcripts stop here; DTMF is re-tagged with the machine's epoch.
  • Timeline. UserSpeechStart (at the VAD start), UserSpeechEnd (at the backdated stop of a stop the machine took into a turn), SttInterim, SttFinal (when the turn's stop is answered), TurnEnd (when the gated end is released, so STT wait counts as end-of-turn delay) and Interrupted via timeline.interrupt.
  • Config rule. EngineConfig::validate requires timeouts.finalize > final_wait (the driver gives up on a final before the STT session is restarted).
  • Tests. tests/stt_stage.rs (MockStt faults, slow inbox, held-finalize deadline, queue overflows, reconnect budget, promoted interims including a real empty answer after a mid-utterance session death, non-blocking upstream, stuck provider, late answers), tests/turn_driver.rs (policies, gate, barge-in with a real TransportSession clear, failing and hanging clears and analyses, exact Smart Turn ring content and clearing, a stop that overtakes queued audio, guards (the echo window in isolation, with a control run), resume while an end is held, the extended gate, the owed-ledger and text bounds, non-blocking upstream forwarding, with_interruption, Cancel, hang-up, backstop (also TtsEnded alone), timeline marks of a held and a lost-final turn, determinism including the exact timer deadlines, and a table of 18 scenarios, including stale verdicts, final_grace expiry, barge-in while awaiting the response, stale and repeated bot audio and empty turns, replayed through the driver and required to give the bare TurnMachine's exact action sequence), tests/hybrid_turn.rs (gate 2: pump, AudioIn, VAD worker, SttStage and TurnDriver in front of the reference downstream; s01..s11 and the basic suite under Budget with fixed silence and with the adaptive policy plus a fixed mock model, with the recommended guards and with every guard off).
  • Not covered in the sim. The hybrid output is the reference stage, which reports only TtsEnded upstream, so the echo window is exercised by tests/turn_driver.rs only; the real TtsStarted comes with TransportOut.

LlmRunner, request building and the tool loop

Code: crates/brisvox-engine/src/{llm,llm_runner,llm_turn,convert,request,truncation}.rs; the module docs of llm.rs hold the full protocol. The decisions that go beyond sections 1 to 4:

  • Shape. LlmRunner::builder(provider, &config, clock, timeline) (system prompt, tools + engine, flow, summarizer, playout book, truncation source, offload, canned phrases) .build(). The runner is a DropStale processor; UserTurnEnded spawns ONE round task per turn (a TaskGuard and a child CancellationToken) that owns all rounds of the turn. Transcript (final) is consumed, UserTurnEnded is forwarded first, Start warms the provider in a bounded helper task, upstream frames are forwarded with try_send.
  • Round ordinals. LlmResponseStarted is sent per round (filler and fallback rounds included); the ordinal of a round is the number of Started frames of its epoch before it. The runner registers (epoch, ordinal) in the PlayoutBook BEFORE sending the frame and records msg_index when it pushes the assistant message. The timeline round index (llm_request / llm_first_token / llm_done) counts model requests only and is separate.
  • Streaming. The first-token timeout (Timeouts::llm_first_token) covers the request and the first event; llm_idle bounds every later event; both on the injected clock. LlmText goes out per delta (the first delta of a round is left-stripped once, whitespace-only deltas are skipped until there is text); the raw text so far is recorded BEFORE it is sent, so a partial message is always a superset of what was sent. Per delta cost: one Arc clone, one mutex lock, one push_str; no other allocation.
  • Finish. ResponseAssembler::finish runs inline below LlmConfig::inline_finish_bytes (4 KiB) and on the Offload above it. The assembler mints ids from a proxy context holding the calls already used in the epoch (with a flow: the manager's offset-aware count, because a reset removes calls from the context but their ids must not repeat for the engine's idempotency key). A failed offloaded finish (saturated, timeout, panic) ends the round with its text only.
  • Tool loop. Flush before execution (only if text was sent since the last flush; the same rule holds before ANY speech that is not the model's response, so the over-limit round's text and the fallback line are separate utterances with their own ordinals); ToolEngine::execute is raced against the filler delay of the FillerPolicy (from LlmConfig::filler: by default 1.2 s, or the smallest filler_after of the called tools under FillerConfig::PER_TOOL); an Execution the runner discards undelivered reports its SideEffect results as late notes and orphans the calls (docs/TOOLS.md); the filler is its own round, put into the context once no call is open (before the next assistant message, or when the turn is settled) and cut to what was heard; results via Execution::append_to (or the flow's apply_results); late notes are taken only while no call is open, at the start of a turn (before the user message) and after each round's results. max_tool_rounds (6) rounds execute tools; the next response's calls are answered tool_round_limit without running, and the fallback line is spoken and put in the context (a real utterance, cut like any other). The engine's own retry budget ends the loop the same way. Calls with no tool engine configured are answered unknown_tool. SideEffect and ReadOnly calls are both non-speculative.
  • Barge-in / new turn. halt: read the barge-in instant FIRST, cancel the token, abort, join (bounded by Timeouts::join); a task that does not stop leaves the context untouched (mutations_skipped), is kept as a zombie that every later halt joins again before it touches the context, and the turn that triggered the halt is dropped (its transcript is lost by design: the caller's next utterance starts a new turn; the machine is told upstream). A halted task also never writes the context itself: its token is checked before each mutation. After the join: partial assistant message from the raw text (if a response was streaming), interrupt_pending_tool_calls, and every unapplied round is stamped with the barge-in instant taken at the start of the halt. A new UserTurnEnded while a task is alive is handled the same way without the stamp (a_new_turn_while_a_round_is_streaming_halts_it_without_a_barge_in_stamp; Stop during a streaming round: a_stop_while_a_round_is_streaming_...). A PANIC in the round task is contained (round_panics): the turn is closed like a failed one (LlmResponseEnded, upstream TtsEnded if silent) instead of leaving the machine to its 20 s response deadline.
  • Errors. Retries (LlmConfig::retries, default 1, retry_delay 200 ms) only while nothing of the response was sent. After the final failure: partial text kept, LlmResponseEnded, and an upstream TtsEnded (non-blocking) only if the whole turn produced no text (otherwise the TTS chain ends the response itself and an early TtsEnded would end the bot state under playing audio). The suite's s06 scripts a failing response followed by the NEXT turn's answer, so the hybrid test sets retries = 0; with the default a retry would consume the next script. A gate-3 pass therefore does NOT cover s06 under the default retry configuration (the retry is tested in llm_runner.rs); a second hybrid test runs every other scenario with the default (every_scenario_but_s06_passes_with_the_default_retry_configuration). The played-text suite runs with a 300 ms fixed silence ("Quick") instead of the 450 ms of s01..s11 because its barge-ins come 0.4 s after the first utterance. the production pipeline must not inherit either accommodation silently.
  • Truncation. TruncationSource::cut(&CutRequest{key, sent, interrupted_at}) -> Cut (Keep, Heard(Truncation) counted, HeardInFull(Truncation) applied but not counted) is asked last round first at UserTurnEnded (before the user message) and at Stop; the stub is KeepAll. The production source implements it over the PlayoutBook (async: the settle wait); the hybrid test implements it over the reference word and playout logs, judged at interrupted_at. The runner bounds the whole pass by Timeouts::analysis_job on the injected clock (a source that never answers is answered with Keep after it, cut_timeouts); the production settle wait must therefore fit inside that bound.
  • Flow hook. with_flow(FlowManager) starts the flow on the context (node prompt = message 0), offers flow.tool_specs() per round, screens the calls, applies the results each round (apply_results under the context lock; a decided transition is entered on a COPY of the context because the summarizer awaits, then swapped in; a task aborted in between leaves the decision pending and the next turn finishes it). context_replaced drops the round records, marks the book's rounds applied and forgets the window summary. Say actions are not played yet.
  • Request building. RequestBuilder::build is the single seam: build_window with EngineConfig::llm.window, then (if the latest turn alone is over budget) the tool results of that turn are clipped oldest first to tool_result_floor_tokens, then the provider conversion and the name-ordered tools. A summarizer keeps the summary of dropped turns current, ENTIRELY OFF the request path: a request never waits for it (the budget of section 8, "gated turn end -> LLM request issued <= 3 ms", would not survive a summarizer round trip, and with a saturated window every turn needs a new summary). After LlmResponseEnded the runner starts ONE background task (owned by the runner, cancelled with it, bounded by summary_timeout = 8 s) that refreshes the summary AHEAD: it summarizes the window as it will be once the next user message is in (a placeholder user turn the size of the last one), so in steady state the summary is ready when the next turn needs it. The ahead refresh NEVER covers the turn that has just finished (refresh_summary_until, limit = start of the last real user turn): that turn's last assistant message is not final until the played-text cut ran (a barge-in right after the response changes or removes it, so a summary of it would break "the LLM context holds only what was played"), and late notes and interrupted-call results are still appended to it (a covers_until at the end of the context would stop being a turn start and the whole summary would be dropped as InvalidSummary). The ahead summary therefore always ends at a turn start that later changes cannot move. Conservative trade-off, chosen deliberately: when the just-finished turn ALONE is too large for the window (its tool results exceed the budget), the next request goes out without that turn (counted: uncovered_requests) and the following refresh covers it. A request that finds uncovered dropped turns for any other reason (quick turns, a slow summarizer, a bigger user message) goes out with the summary at hand and is counted likewise. A summary computed for a context that a flow Reset replaced meanwhile is discarded (summaries_discarded); the generation check and the store are one step under the summary mutex and reset_summary bumps the generation and clears the summary under the same mutex, so a refresh cannot store the old conversation's summary after the reset. A too-large or failing summarizer never replaces the summary; each failure is counted (summary_too_large, summary_failures, summary_timeouts, consecutive_summary_failures, summary_degraded after 3) and logged at warn; once degraded a circuit breaker backs the attempts off (summary_timeout doubled per further failure, at most 60 s; summary_backoffs), so a hanging summarizer costs a bounded number of billed calls. Done.provider_data counts against the budget (WindowEstimator) and is bounded: dropped (provider_data_oversized) above 32 KiB or above half a message's allowance (max_message_tokens * 2 bytes), parsed on the offload from inline_finish_bytes. The simulator's reference LlmStage is deliberately unchanged (the golden baseline); the context windowing lives in the engine RequestBuilder.
  • Cost of a request build (50 us rule). build walks the full transcript once for the cut (turn starts, validation: a few ns per message; the transcript is never trimmed) and clips only the messages of the window. window::clipped gallops up from a 256-byte prefix and then bisects, so clipping costs about 10 times the SIZE OF THE RESULT (a 4 MiB tool result clipped to 250 tokens looks at about 10 KB; plain bisection from the full length looked at 8 MiB), not O(size of the text) per step. Measured (tests/llm_window.rs building_a_tool_heavy_request_is_cheap_..., Windows dev machine): a request with 12 tool results in one turn, over budget, costs about 0.2 ms in a debug build and 0.1 ms in release whether the results are 20 KB or 60 KB each, i.e. above the 50 us rule, so it is NOT built on the worker when it can be that heavy: RequestBuilder::build_offloaded (what the round task calls) builds on the Offload when the context holds more than INLINE_MAX_MESSAGES (384) messages or the latest turn more than INLINE_MAX_TURN_BYTES (16 KiB) of text (is_heavy, O(latest turn)); the summary refresh does the same for its context snapshot (the deep clone of the transcript) and the flow transition for its context copy. Below those sizes a build is a handful of small clips and conversions. A pool that refuses or times out the job falls back to building inline (offload_fallbacks): a request must not fail on a busy pool. Counters: builds_offloaded, snapshots_offloaded, offload_fallbacks. In a long call (more than 384 messages) every request therefore pays one pool round trip; a lone VAD job ahead of it in the queue is the price. Thresholds chosen conservatively; just below them (380 short messages, or a latest turn of four 3.7 KB results) a build costs 17-19 us in a debug build and about 3 us in release (same test), inside the rule with a wide margin. The deterministic guard against a return to bisecting the whole text is the unit test clipping_a_huge_message_costs_in_proportion_to_the_limit_not_to_the_text (brisvox-agent); the timing test only guards coarsely (20 ms per heavy build, and no growth with the result size). A micro-bench of build_window repeats the measurement under load.
  • Filler. The filler is sent while ToolEngine::execute is still polled (both futures are driven together), so a back-pressured pipeline cannot stall a tool or its timeout accounting.
  • Additive change in brisvox-agent. TokenEstimator: Send + Sync (the window refresh holds a &dyn TokenEstimator across the summarizer's await, which must keep the round task Send).
  • Tests. src/{convert,request}.rs unit tests (round trips preserving Context::validate, clipping, summaries); tests/llm_runner.rs (turn, filler, limits, errors, barge-in, exactly-once SideEffect with a late note, truncation order, offload on a real pool, flow); tests/llm_window.rs (a 60-minute call asserts the size of EVERY request, with a turn cap (50) far above what the 4000-token budget allows, so only the budget can produce the bound; 12 large tool results in one turn; a summarizer returning too-large summaries; a 1 s summarizer, quick turns and a summarizer that never answers never delay a request, all with a window whose budget binds before the turn cap; the cost of a tool-heavy build does not follow the size of the results; builds on a real pool and a refused pool); tests/hybrid_llm.rs (gate 3: engine input chain + STT + TurnDriver + LlmRunner in front of the reference TTS/pacer/output; s01..s11, with and without a registered tool, the basic suite, fixtures, the 40 random barge-ins of the played-text suite, leak check).
  • Not covered here. The PlayoutBook as truncation source and the settle wait; Say actions and apply_recorded_result for late flow results; speculative tool starts; the real TtsStage consuming the ordinals (covered in the output chain section below).

Output chain: SegmenterStage, TtsStage, TransportOut

Code: crates/brisvox-engine/src/{segmenter,tts,tts_audio,transport_out,rates}.rs and Segmenter::push_spans / finish_spans in crates/brisvox-agent/src/segment.rs. The module docs hold the details; the decisions that go beyond sections 1, 5, 6, 7 and 9 (deviations from the text above: TransportOut is KeepAll, the TTS stage and not the segmenter stage records the sent slices):

  • Sent text is a prefix of the message, by construction. Segmenter::push_spans returns each chunk with its byte range over the accumulated stream (Span; ranges are kept per char, so whitespace the segmenter drops beyond its run cap still lies inside a raw slice). The SegmenterStage emits the raw slice stream[prev_end..chunk_end] (leading whitespace included, which Cartesia's continue: true wants), keeps only the unemitted tail of the stream, and starts a new stream (offsets from zero) at every Flush, LlmResponseEnded, LlmResponseStarted and Interrupt. Property tests (tests/segment_spans.rs, EN and TR, trailing whitespace, tiny max_chunk_chars, random deltas) prove that the slices concatenate to the text with its trailing whitespace trimmed, and that PlayedText::push_sent + apply_truncation never fail with NotAPrefix at any playback position; the same holds through the stage (tests/segmenter_stage.rs). first_min_words is per language (SegmenterStageConfig::new): English 3, Turkish 2 (a Turkish word carries more); a starting value, not measured. Memory bound: the stage keeps the raw stream text it has not cut yet; whitespace the segmenter shortens would otherwise stay there for ever. Past RAW_CAP_FACTOR x max_chunk_chars bytes (16,000 with the defaults) the stage ends the stream as a Flush would (overflow_finishes, raw_high_water in the stats). Nothing is dropped: the whitespace after the last chunk goes out as a chunk of its own, so the byte-for-byte promise holds across the cut and a barge-in in the text after the flood still truncates the context to the played words (review fix; before it the dropped run made apply_truncation report NotAPrefix and the unplayed text stayed in the context). TtsStage records a whitespace-only chunk in the book as sent text but never sends it to the provider and opens no context for it; with no context open yet it keeps the whitespace (at most WHITESPACE_LEAD_CAP = 64 KiB per round, conservative; beyond it the whitespace is dropped and counted in whitespace_dropped, and that one degenerate round falls back to the safe NotAPrefix) and records it with the round's first spoken chunk. Tests: a_whitespace_flood_does_not_grow_the_retained_text, the_overflow_cut_loses_nothing_the_chunks_still_concatenate_to_the_message (segmenter stage, including the truncation) and the three whitespace_* tests in tests/tts_stage.rs.

  • Who records the sent slices. The PlayoutBook is keyed by utterance, and the utterance (provider context) is minted by the TTS stage, so TtsStage calls push_sent with the raw slice just before the (normalized) text goes to the provider; the segmenter stage has no utterance to record against. The book therefore holds exactly what was sent, text dropped for a dead context is not recorded, and a failed send marks the utterance lost.

  • Utterances and the end of a response. One provider context per text-bearing round, id from a session-monotonic counter (ContextId = UtteranceId = UtteranceStarted.utterance = WordTimestamp.utterance), registered as (epoch, round) where the round is the number of LlmResponseStarted frames of the epoch so far, minus one (the ordinal the runner registers). TtsEnded is emitted exactly once per response, decided in one mutex-guarded ledger shared by the stage and its reader task: when LlmResponseEnded arrived and no context of the epoch is pending. Ordering rules that make that safe: the provider Flush goes out BEFORE the LlmResponseEnded frame is forwarded (the last words wait for it); the ledger is updated AFTER the audio tail of a flushed context was handed on (a TtsEnded can never overtake audio); whoever finds the condition true last emits it. A response without text, with a dead provider or whose contexts were lost ends at once; a duplicate LlmResponseEnded is counted and ignored.

  • The reader task owns the OutputFramer. Provider PCM at EngineRates::tts is converted to the call's output rate (CallRatesCell, published by the pump through Pump::spawn_publishing; the engine's fallback rates until Started) and cut into exact EngineRates::frame frames; the last frame of an utterance may be shorter (padding would put the playback position ahead of the words). The resampler's ramp-in is dropped, so word offsets stay valid on the output timeline and a whole utterance has exactly the input's duration. Per provider chunk: two allocations (block + shared counter) when the previous chunk's frames are still alive, none otherwise; none per frame (tests/tts_alloc.rs, counting allocator; same for 20 ms and 1 s chunks). Per word frame: one Box. A chunk is converted in pieces of at most max_piece (100 ms of provider audio, about 30 us for the worst pair, 24 kHz to 8 kHz) with a yield in between, so a long provider chunk never holds a worker; the resampler (0.6-1.6 ms to build for 8 kHz output) is built on the Offload at the first audio of a call. UtteranceStarted is emitted before the first audio of an utterance and again whenever the audio switches between contexts (the framer tail of the previous context is flushed first, or dropped if that context was cancelled). Every send is abandoned when an interrupt for a newer epoch is queued for the stage (words excepted, they are exempt from the epoch rule). A SEND TO THE PROVIDER abandoned that way leaves its context in the ledger (so the interrupt cancels it at the provider) and marks the utterance lost in the book (its text did not all arrive); only a timed-out or refused send loses the context and ends the response without it. Tests: tests/tts_faults.rs (full input channel: timeout, lost context and the end of the response; interrupt vs a waiting send with a 30 s timeout; full cancel channel; a wedged consumer behind the reader).

  • Interrupt. The stage drops the ledger entries of older epochs and try_sends a cancel per open context on the provider's priority channel; events of unknown contexts are dropped; the reader resets its framer when it finds the context it was framing gone. A NEW epoch that starts without an Interrupt (the runner dropped a turn) cancels what the old epoch left open the same way. Test: an_interrupt_cancels_the_context_and_late_audio_never_comes_out.

  • Provider faults (tests with MockTts): the session connects at Start (prewarm) and again when a round with text finds it dead; connect is bounded by Timeouts::connect on the clock and abandoned on an interrupt; after a failure the stage backs off reconnect_backoff counted from the moment the session DIED (the reader stamps it, so a session that died turns ago is retried at once), drops the text of the round meanwhile (counted, the response still ends), and gives up until the next turn after more than max_reconnects consecutive failures; a session that delivered audio or lived 10 s is healthy and does not count. A session that dies mid-utterance marks its utterances lost in the book (mark_lost, so the heard prefix stops before the first untimed word and the message is never kept whole) and emits the owed TtsEnded; the rest of the round whose context died is NOT resurrected on the new session (its words would restart at offset zero; test the_rest_of_a_round_whose_session_died_is_dropped_not_replayed_on_a_new_session). A provider that is silent for idle_timeout (default Timeouts::llm_idle, 15 s) while it OWES events is declared dead the same way (stalls). It owes events from the first text it has not reacted to (any audio or word of the context answers it) or from a Flush until its Flushed; an open context whose input was all answered owes nothing, so a round that streams slowly (a long tool-call argument between two sentences of one round) is not a stall (review fix: the timer used to run whenever a context was open). The clock starts at the first unanswered input, not at every new chunk, so a silent provider is still found while text keeps coming. Tests: a_round_that_streams_slowly_is_not_a_stalled_provider, silence_after_late_text_is_a_stall_counted_from_that_text, an_unconfirmed_flush_is_a_stall_even_after_all_text_was_answered. Stall faults that end by themselves just delay the response.

  • TransportOut is not a pacer (D8). It hands each frame to the session's command queue as it arrives; the bounded queue pushes back on the TTS side, the session's own OutputPacer spaces the frames 20 ms apart (test: 25 frames accepted by the stage within 10 ms reach the client exactly 20 ms apart). A send is polled once without a timer, then waits at most Timeouts::send and not past the end of the epoch; a frame the transport does not take marks its utterance lost. The stage is KeepAll and filters epochs itself so it can count (stale_dropped, stale_control): the newest of {its own Clear epoch, the last Interrupt} is the floor. Epoch ledger rule: Clear { epoch } before the first audio of any epoch above the stage's tx_epoch (no Interrupt is needed, the second turn of a call is the case that matters), idempotent with the turn driver's own clear on a barge-in; an Interrupt marks AudioOutStopped on the epoch whose audio was playing and sends an idempotent Clear. The first audio the transport accepted marks FirstAudioOut and sends TtsStarted upstream, once per epoch.

  • End mark. TtsEnded becomes Mark { id: "brisvox-end-<epoch>-<n>" } (unique per session), registered in the book for the response's LAST utterance before it is sent (the echo can come back at once). The echo reaches the pump (MarkPlayed), which sends TtsEnded back through this stage; it is let through once per epoch (ended_through), so the echo, a mark_timeout end and a response end without audio can never report the same epoch twice. A client that never echoes: the stage ends the response Timeouts::mark (10 s) after the audio should have played out (first handoff plus the audio handed over), one DeadlineTimer woken through SystemFrame::Wake; a late echo is a counted duplicate. A cut epoch never reports an end: its stale TtsEnded, UtteranceStarted and late mark echo are dropped and counted (stale_control), its pending mark is forgotten at the Interrupt, and the overdue mark of an epoch the transport already moved past ends nothing (tests a_cut_epoch_never_reports_an_end_however_its_end_signals_arrive, the_mark_of_an_epoch_the_transport_moved_past_times_out_silently; the timeout test checks the deadline at 3.1 s and 3.3 s against a 3.2 s deadline, so a deadline taken from the arrival of TtsEnded fails it). FirstAudioOut and TtsStarted describe the moment the transport took the frame, not its arrival (test the_first_audio_out_waits_until_the_transport_has_taken_the_frame blocks the first send for a second), and an Interrupt alone clears the client (an_interrupt_alone_clears_the_client). A response that sent no audio is ended at once, without a mark. Known gap, deliberate: only the last utterance of a response has an end mark, because an echo of an earlier one would end the response early (the pump cannot tell them apart); the truncation reads the earlier utterances from positions.

  • Rates and mu-law. The engine delivers PCM16 at CallRates.output: 8 kHz for a telephony leg (24 kHz to 8 kHz uses the sinc backend). The mu-law/A-law encoding is the TRANSPORTS' job (brisvox_audio::Codec inside the Twilio, Telnyx, SIP and websocket adapters, which advertise 8 kHz as the leg's rate); the engine never sees G.711 bytes. What is left to check is the 8 kHz mu-law WebSocket end-to-end test with a 24 kHz TTS.

  • Seams added for this stage. CallRatesCell + Pump::spawn_publishing (the TTS stage is built before the pump reads Started); brisvox_sim::InputTarget::bind_upstream (default no-op, called by SimSession::run_with right after bind: a target that owns a pump needs the started pipeline's UPSTREAM input for the mark echoes; the reference factory is unaffected); brisvox_engine::Error::Audio. The sim edit is additive (a trait method with a default no-op and an InputSink method called once by the runner, session.rs); brisvox-sim still does not depend on the engine. Test: crates/brisvox-sim/tests/seam.rs, the whole-suite test counts one bind_upstream per session, after bind, and a_target_gets_the_upstream_input_and_other_sinks_ignore_it.

  • Cost of Segmenter::push (50 us rule). Release median 0.2-1.2 us, p99 up to 2.8 us, worst push up to 109 us (a 12,000-char token without whitespace), for sentences, long clauses without punctuation, a whitespace flood and one long token. Far inside the 50 us rule, no pool needed. The numbers and the asserted bounds (median, p99 and worst, each the best of three runs) are in docs/LATENCY.md, "Segmenter push cost in the stage"; the test is tests/segmenter_stage.rs, a_push_costs_less_than_the_worker_budget. The gate runs it in debug, where its bounds are loose, so the machine-independent guard is tests/segmenter_alloc.rs (a counting allocator: allocations per chunk, and at most 3 per push for text with sentence ends; a per-character allocation fails it).

  • A silent turn tells the driver twice. For a turn without text the LlmRunner sends its own upstream TtsEnded (nothing downstream would report the end), and the TtsStage still ends the response, which TransportOut turns into a direct end (no audio, no mark). The runner's frame travels from the runner up to the driver and never passes TransportOut (which is downstream of it), so no stage can deduplicate the two: the driver receives two TtsEnded for a silent turn and one for a spoken turn (the mark echo). Harmless: the turn machine ignores BotAudioEnded while idle. "Exactly one TtsEnded per response" therefore holds per stage (TtsStage, TransportOut), not for the whole chain. Test: hybrid_output.rs, a_silent_turn_ends_for_the_driver_and_the_next_turn_still_speaks (counts 3 for a silent turn plus a spoken one, and that the second turn is spoken).

  • Tests. tests/segment_spans.rs (agent), tests/segmenter_stage.rs, tests/tts_stage.rs (20: frames and words, rounds, empty response, ids, marks, resampling to 8/16/24/48 kHz, interrupt, normalization inline and offloaded, no word timestamps, disconnect before and after the end, stall that completes, silent provider, failed connect and backoff, connect timeout, degraded budget, upstream, dropped turn, and a real-time run with the normalization and the 8 kHz sinc resampler on a real inference pool), tests/tts_faults.rs (4: provider and downstream that stop taking things), tests/tts_alloc.rs (the hand-built model path AND the real stage under a counting allocator, 20 x 20 ms vs 20 x 1 s chunks must cost the same), tests/transport_out.rs (12: two turns without a barge-in, stale audio after a barge-in, only the session paces, first handoff, unique marks and their echo, a client that never acks, a response without audio, words and positions in the book, unattributed audio, a burst larger than the queues, a transport that takes nothing, an interrupt that abandons a waiting command send), and tests/hybrid_output.rs (gate 4).

  • Gate 4 (tests/hybrid_output.rs): engine pump, AudioIn, VAD, SttStage, TurnDriver, LlmRunner, then SegmenterStage, TtsStage, a recording tap and TransportOut on a real TransportSession over the loopback transport, judged by the reference harness. s01..s11 under Budget with fixed silence (recommended guards and none), the adaptive policy with a fixed mock model, every scenario but s06 with the default retry, a registered tool, the basic suite, the fixtures; plus wire-level checks (nothing of the cut epoch after the client's clear; both rounds of a tool turn spoken as two utterances with one end mark; counters). The loopback client's events carry their arrival times and are recorded after the run (a client queue large enough for the whole call), giving the suite's wire records and its playout log. The cut context is read from the PlayoutBook the stages write, through a minimal truncation source in the test (BookSource: settle wait for interrupted responses, RoundSnapshot::heard, no lead margin); it was later replaced by the production PlayoutTruncation. Not run on this chain: the played-text suite (suite::played_text): its oracle reads the frames at the tap, which sits BEFORE the transport's pacer here, so it disagrees with the playout (wire) log by seconds; the wire-arrival oracle (LoopbackClient received_at, +-1 frame) replaces it.

  • Not covered here. (The production truncation source and the wire-arrival oracle are in the played-text section, below.) Assembly (run_call, queue wiring, prewarm test); chaos, 100 concurrent calls, the real-time multi-thread run and the micro-benches of the resampler under load; live providers (Cartesia's continue/flush semantics, word timestamps of ElevenLabs/Cartesia against the normalized text).

    real TtsStage consuming the ordinals.

Played-text truncation from the PlayoutBook

  • The source. PlayoutTruncation (truncation.rs, PlayoutTruncationConfig::from_engine: lead_margin, settle_wait, PlayedConfig) is the production TruncationSource; LlmRunnerBuilder::with_playout(book) is with_book plus it. The runner is unchanged: it already asked last round first at UserTurnEnded and Stop, bounded by analysis_job, and asserted Context::validate after every cut. The source answers Keep for a round the book no longer knows or with blank text, HeardInFull for a round played to its end, and Heard otherwise (PlayedText::heard at the heard position, an empty prefix for a round never reached).
  • Settle wait. Only for an interrupted response. wait_settled_through(epoch) on the injected clock for what is left of settle_wait after the barge-in (interrupted_at), not after the question: the transport flushes the final position within moments of the clear, so a position still missing 250 ms after it is not coming, and the caller usually talks for longer than that before the turn ends. A timeout is counted (settle_timeouts) and the last known position is used. After a timeout the rest of the pass does not wait again (a short memo), so N rounds never cost N waits. settle_wait must stay well below Timeouts::analysis_job (the runner's bound for the whole pass): EngineConfig::validate requires settle_wait <= analysis_job / 2 and PlayoutTruncationConfig::from_engine clamps to it, so the pass never hits the runner's deadline (which would answer Keep and leave unheard text in the context).
  • What the wait ignores. Utterances of rounds already cut (mark_applied), and cut utterances of rounds without a context message (filler and fallback rounds, msg_index none): nothing is ever cut to their position and the runner never applies them. Before this rule a filler cut between two position reports (no final position, no end mark, not all released) stayed unsettled forever, so every later interrupted pass waited out settle_wait and the runner's speculation answered skipped_busy for the rest of the call. Not waiting for it costs nothing that matters: its stale, lower released only makes the chain of an earlier utterance more cautious. A round that gets its message later (a partial message at the barge-in) is waited for again from then on.
  • Timeouts are expected. The transport flushes a final position only if audio was released since its last report, so a clear right after a report has none to send. With a report per 20 ms frame (the tests) that is every barge-in between two frames, with the 100 ms production interval about one in five mid-utterance barge-ins. The last report IS the final position then; the counter is not a failure signal by itself. A transport that always flushes (or acknowledges the clear) would remove the case and close the stale-report limit of the playout module docs.
  • Heard position. min(released, total) − lead (see section 5). A response that ended without an interruption loses no lead (it has played out by the time the next turn starts), so a client that never echoes marks does not lose its last words. That includes the client's last reported lead (Twilio): an earlier moment's buffer level is not subtracted from a response that has played out.
  • The margin fades after the last release. The fixed lead_margin assumes audio was still going out at the barge-in. A tool round's utterance has no end mark (one mark per response), so a caller barging in during a 5 s tool call would otherwise lose the last word of "Let me check it" although the buffer drained seconds ago. The book stamps, on the call's clock (PlayoutBook::attach_clock, done by PlayoutTruncation::new), the time a position report last moved released for each utterance; RoundSnapshot::margin_at takes off the margin the time between the latest stamp of the utterance or of the utterances behind it and the barge-in. It is a lower bound of playback as long as the client's buffer never exceeds the margin: what the client held at the stamp (at most the margin) plays out in real time. A stamp is taken when the report arrives, at or after the real release, so the fade is never too large. A reported client lead (Twilio) is measured and is not faded. Tests: playout::tests (stamps and fade), the_margin_shrinks_by_the_time_since_the_last_release_and_is_gone_after_a_tool_wait, a_barge_in_during_tool_execution_closes_the_call_and_keeps_what_was_spoken (runner, 160 ms margin) and the wire-level after_a_tool_wait_the_production_margin_does_not_take_the_last_word_back.
  • Who tells the book about the clear. playout::clear_hook(commands, book) is the TurnDriver clear hook: book.note_clear(new_epoch), then TransportCommand::Clear. The note is made when the hook is called, before its future is first polled, so it precedes the command and the final position the transport flushes right after it (pinned by the_clear_hook_tells_the_book_before_it_sends_the_command and, over the loopback transport, the_clear_hook_tells_the_book_before_the_clear_reaches_the_client). Use it instead of TurnDriver::with_commands wherever the production source reads the book. The assembly MUST install clear_hook on the TurnDriver and LlmRunnerBuilder::with_playout on the runner with the SAME book the stages write. Without the note a mid-utterance cut never settles (every barge-in waits out settle_wait) and the client's lead is not tied to its clear. Miswiring is detected, not silent: BookStats::clears_noted counts the notes, and PlayoutTruncation::unnoted_clear_cuts() counts interrupted responses asked about while it was zero (first occurrence logged as an error on brisvox::engine); the assembly's tests assert it is zero.
  • A mark that is echoed after the clear. A race in TransportOut::tts_ended (found by the wire-oracle test): the TtsEnded of the cut response can be handled after the transport applied the Clear but before the Interrupt reached the stage's epoch watch. CommandSender::send reads the ledger epoch when the command is enqueued, so the Mark counts as the NEW epoch's, is released at once (nothing is ahead of it) and echoed by the client. The book treats an echo that arrives after a clear covered the utterance as a cut mark, so the position decides; the stale Mark on the wire (and the BotAudioEnded it makes upstream) remain. A proper fix is in TransportOut (check the book's clear floor before sending a mark) or in the transport (TransportCommand::Mark carrying its epoch); neither is part of the play-out book itself. The same rule has a cost: a GENUINE echo of a fully played response that is still in flight when the caller barges in is read as a cut mark too (the book cannot tell them apart), so the cut falls back to min(released, total) - lead and can drop up to one lead of the end of a sentence the caller heard (test a_genuine_echo_that_races_a_barge_in_is_cut_by_position_not_kept_whole).
  • Followed by a played round. A round counts as played whole when a LATER round of the SAME epoch was echoed as played; rounds of other epochs never vouch for it (a clear separates epochs, so a round cut by a clear stays a position-based cut even after the next turn's answer was acked).
  • Wire-level margin and latency. The 40-case oracle runs twice: with lead_margin 0 (wire arrival) and with the production 160 ms (the context is the wire position less 160 ms), and both assert that the next turn's LLM request leaves within one frame of the turn end (a settle wait measured from the question instead of the barge-in would add 250 ms and fail), and that at least one interrupted call settled on its flushed final position without a timeout (a book never told about the clear times out every one). The second-round tool test also runs with the 160 ms margin and a first round whose last word starts inside it: only the chain over the second round's audio keeps that word (with_the_production_margin_the_first_round_is_chained_behind_the_second).
  • Tests. tests/playout_truncation.rs: the book driven by hand on the paused clock (exact margin and lead numbers, the lead chained over later utterances, the margin fade, the settle wait and its memo, a missing final position, a cut filler that never settles, the clear hook's ordering, kept-whole rules, the runner's partial messages, a tool round cut in round two whose first round's last word only the chain keeps, a barge-in during tool execution, nothing heard of a message with a call). tests/playout_oracle.rs: the whole production pipeline over the loopback transport (tests/common/output_rig.rs, shared with hybrid_output.rs), judged against WIRE arrival: 40 seeded barge-ins with a ±1 frame window, a proof that the window does not hide a deliberate one-word error (a source wrapper that drops or adds a word: every case with a single-count window is caught), a word starting in the unplayed rest of the chunk in flight, no word timestamps (the 14 chars/s estimate of the wire position), a TTS disconnect (the exact spoken prefix), a tool turn with a barge-in in round two and during execution, a barge-in before any audio, and the filler round (a played filler is put into the context and cut to what was heard like any round: the_filler_round_is_cut_like_any_round_and_kept_as_heard). The shared suite::played_text checks that read the output TAP cannot run on this chain (the tap precedes the pacer). The seven checks map as follows. Run as they are: a_fully_played_answer_is_kept_whole, timed_barge_ins_are_counted_and_never_estimated, a_fully_played_answer_without_word_timestamps_is_not_estimated_away. Wire-oracle twins: random_barge_ins_leave_the_context_at_exactly_the_heard_text (the 40 seeded barge-ins), a_word_starting_in_the_unplayed_part_of_the_chunk_in_flight_is_not_heard (same name), a_barge_in_is_counted_and_a_provider_without_timestamps_is_flagged_as_estimated (without_word_timestamps_the_cut_is_the_speaking_rate_estimate_of_the_wire_position, which asserts TruncationStats::estimated), and a_barge_in_while_the_llm_is_still_streaming_keeps_only_the_heard_part_of_what_was_sent (same name: the LLM stalls before its third delta, the cut is judged against the delivered text on the wire and the aborted tail never reaches the context). The EngineFactory tap records everything but audio as it passes; audio is recorded at wire arrival, so the shared played-text suite runs unchanged on EngineFactory. See "Assembly" below.

Speculative generation

Code: crates/brisvox-engine/src/{speculation,llm_spec}.rs plus additive parts of turn_driver.rs, llm_runner.rs, llm_turn.rs and config.rs. Opt-in: EngineConfig::speculation (SpeculationConfig, default OFF: every pipeline behaves exactly as before).

  • Idea. At a candidate end of turn the LLM is started with the transcript so far; its output is buffered. When the turn really ends with the same words the buffer is released at once, so the model's time to first token is paid while the machine is still waiting for the end of the turn (silence timer, Smart Turn, finals gate). Otherwise the run is dropped and the turn runs as without speculation. The conversation, the frames and the words on the wire are the same either way; only the latency differs.
  • Protocol (frames only). The driver never forwards interims, so a non-final Transcript downstream of the driver is the speculation signal: non-empty text is a candidate for the open turn (a later one of the same epoch replaces the earlier), empty text is a withdrawal. They travel in the same lane as the final Transcript and UserTurnEnded, so there is no race between a candidate and the commit. UserTurnStarted, Interrupt, Stop and Cancel also drop a speculation. (A dedicated ControlKind would be cleaner; it needs a change in brisvox-core and was left out to keep the change additive.)
  • Candidates (TurnDriver). (1) At the UtteranceFinal of a stop, if the machine is paused in an open turn and no other stop of the turn is owed: the turn's final text. (2) With on_partial, at the VAD stop that paused the machine: the final segments heard so far, else the latest interim; with partial_stable_for > 0 (the stable-partial gate) that partial waits until the caller stayed silent and the text unchanged for that long (a resume or the stop's own final cancels the wait, so a caller who keeps talking costs no request; a final is the STT's own stable text and is never held). Equal (normalised) text is not offered twice; max_per_turn (3) bounds the requests a stuttering transcript can cost. A resume (VadStart), or a gate release with no text, withdraws the candidate. With the adaptive policy a complete verdict ends the turn at the final itself, so (1) adds nothing there and costs nothing.
  • Hit rule (LlmRunner). At UserTurnEnded the speculation is committed when it belongs to the same epoch, normalize(final) == normalize(candidate) (lower case, letters and digits only, apostrophes dropped, so punctuation and spacing differences do not count), it is younger than max_age (5 s), and the context is exactly the one its request was built from (a late tool note that arrived meanwhile is a miss).
    • Normalisation. İ is i (a Turkish capital must equal the interim's lower case), and a combining mark belongs to its letter: the composed (NFC) and decomposed (NFD) spelling of a word are equal for the Latin letters of a small compose table (Turkish ç ş ğ ö ü â î û, the common French, Spanish and German accents). A mark with no table entry is kept as it is (it can only miss, never produce a wrong hit); accents are never folded (şu is not su). Symbols that change what a reply or a tool argument would be are kept, so they never compare equal to their absence: every currency sign (the Unicode currency block, $ £ ¥ ¢ ¤, the Turkish ₺), % ‰ @ + = < > / & # * ~ ^, ° ± × ÷, primes, and the joints inside a number or an address (-50, 3.5, 10:30, john.doe).
    • Context identity. The fingerprint of the context (length, and per message the role, the byte length and the last 16 bytes of the text, the tool call ids) is taken at the settle and compared at the turn end. It is O(messages), not O(bytes): it runs on a tokio worker twice per speculated turn, once under the context lock, and a context can hold hundreds of KB of tool results. A settled context only ever grows or has a message cut to a prefix, which the length and the tail see.
  • Settle. A candidate settles the context exactly as the turn end would, minus the user message (halt, played-text truncation, open calls closed, late notes in). The truncation wait is therefore hidden by the speculation too, and the turn end finds nothing left to settle. A runner that is still answering the previous turn (skipped_busy), drives a flow (skipped_flow: a pending transition would change the context behind the request) or cannot settle (skipped_unsafe) ignores the candidate.
  • The helper (llm_spec). One TaskGuard task per speculation. It builds the request from a copy of the context plus the candidate (the real context is untouched until the commit), runs the first round with the round task's first-token and idle timeouts, and sends the pieces LlmText would carry into a bounded channel (max_buffered_events, 256, validated to at most SpeculationConfig::MAX_BUFFERED_EVENTS_LIMIT = 4096 so the bound is real, and clamped to it again where the channel is made; a full buffer back-pressures the provider stream, nothing is dropped). It ends by itself at max_age unless the turn claimed it, and when its token is cancelled. Nothing reaches the pipeline, the PlayoutBook, the context or the timeline from it.
  • Commit. The runner gives the receiver, the helper's guard and its token to the round task. Round 0 of that task replays the buffered pieces at once (LlmResponseStarted first, so the PlayoutBook registration and the frames of the round are those of an ordinary round) and then follows the live stream; the timeline gets the instants things really happened at (request when the speculation started, first token when it arrived). A failed speculative stream counts as the first attempt: before any output the round is retried with a fresh request if the retry budget allows, after output the partial text stays, exactly as for a normal round. Later rounds (after a tool phase) are ordinary rounds.
  • Tools. At Done the helper finishes the assembler as the round task does and, with a tool engine and no flow, calls ToolEngine::speculate: ReadOnly calls run now, SideEffect calls are held inside the Speculation. At the commit the round task pushes the assistant message, sends Flush, and runs ToolEngine::commit (raced against the filler timer): the held calls run there, once, under the engine's idempotency key (session, epoch, call id). A discarded speculation drops them unrun; a ReadOnly call in flight is cancelled with the helper. Without a tool engine, or with a flow, the calls are handled after the commit as always. While the helper runs the ReadOnly tools no idle timeout applies to the committed round (the tools have their own).
  • Metrics (LlmRunner::speculation_stats()). started, hits, misses (text changed), context_changes, expired, superseded, withdrawn, abandoned, kept, skipped_*, failures, tool_phases, held_side_effects, wasted_output_tokens (provider-reported, else 4 bytes per token) and wasted_input_tokens (provider-reported), head_start_ms; the snapshot's hit_rate() is hits over decided speculations. The driver counts speculations_offered, speculations_withdrawn and speculations_capped.
  • Costs and limits. Every discarded speculation is a billed request; on_partial trades more of them for a head start of one STT round trip (real interims often lag the final by a word, the mock's lag by exactly one). The engine's per-turn retry budget for malformed tool calls is spent by a speculation even if it is discarded (ToolEngine::speculate documents it). A speculation covers the first round of a turn only. Speculation with a flow is a follow-up.
  • Default stays off. Measured on the fixtures and over the websocket (docs/LATENCY.md, "Speculative LLM start"): under the adaptive policy the gain is one STT finalize round trip (about -150 ms) and only when the partial at the VAD stop equals the final; a lagging partial costs one extra request on every turn and gains nothing. Under a fixed silence longer than the finals gate, speculating at the final alone (on_partial off) returns the silence beyond the final (-255 ms at 700 ms) with no wasted request. Enable per deployment.
  • Tests. tests/speculation_runner.rs (hit and buffering, identical frames and request, miss, withdrawal, replacement, drop on new turn / barge-in / stop, other epoch, off, SideEffect held then run once, discarded unrun, restarted once across two runs, ReadOnly ahead and used, ReadOnly cancelled, slow tool without idle timeout, late note makes a miss, expiry, retry, failure after output, back-pressure, barge-in after the commit, no leaked tasks, the filler during a committed tool phase, a SideEffect call that still runs after a miss whose speculation had held the same call id, the helper's first-token and idle timeouts), unit tests in llm_runner.rs (a claimed speculation keeps its helper past max_age while the turn settles; an expired one cannot be claimed) and in llm_turn.rs (the committed round's own first-token and idle limits, no idle limit in the tool phase), tests/speculation_driver.rs (when candidates and withdrawals are offered), and tests/speculation_hybrid.rs (a hybrid call with and without speculation: identical context and words, response latency lower by the model's time to first token minus the wait it overlapped, lagging interim, resumption with exact offered / withdrawn / started / hit counts, a tool turn, the adaptive policy with exact counters per variant, and the structural scenarios s01, s02, s04, s05, s07 to s11 with candidates flying and at least one committed speculation in a barge-in call and in a tool call). The adaptive-policy test documents what is measured: the mock STT's last interim lags the final by a word, so with on_partial the partial candidate at the stop is a miss (one wasted request, same context, same latency); a hit on a partial candidate is covered only at the runner level, because the mock never produces an interim equal to the final.

Connection pre-warming and pooling

Goal: no TCP, TLS, DNS or WebSocket handshake on the critical path of turn 1 (or of any later turn).

  • Audit (what existed). SttStage and TtsStage connect at Start; LlmRunner calls LlmProvider::warm() at Start in a TaskGuard under Timeouts::connect and counts warms / warm_failures; the OpenAI, Anthropic, Gemini and OpenAI-compatible adapters warm with an unauthenticated HEAD; Deepgram warms DNS, Cartesia parks one socket, ElevenLabs warms its socket; Deepgram sends KeepAlive after keepalive_interval without audio and ElevenLabs keeps a quiet context alive. TLS is built once per process (net::tls_config).
  • Gap 1: one HTTP client per adapter instance. Every call built its own reqwest::Client, so every call paid a TCP + TLS (+ HTTP/2) handshake on its first request and the warm() HEAD warmed a pool nobody else could use. Now brisvox_providers::net::shared_client(&HttpClientSpec) hands out clones of ONE client per (origin, connect timeout, pool idle timeout, HTTP/2 prior knowledge) from a process-wide map, used by the four HTTP/SSE adapters. Credentials stay in per-request headers, so different keys share the connections. The map holds at most 64 configurations (MAX_SHARED_CLIENTS); a new one at a full pool evicts the least recently used entry (PoolStats::evicted, one warn log), so sharing never switches off. net::pool_stats() exposes built / reused / evicted / entries. The clients survive the tokio runtime that opened their connections: the next request under another runtime reconnects (unit-tested). Clients keep TCP keepalive (30 s) and HTTP/2 PING keep-alive while idle (20 s / 10 s). Tested over a real TLS loopback server (net/http/tls_tests.rs, built through build_with, the function behind the production client): ALPN settles on h2; a warm() HEAD followed by two more requests makes ONE TLS handshake; an idle connection sends PINGs (interval shortened to 100 ms in the test, the 20 s production value stays quiet); a system proxy setting is ignored (child process with proxy variables). Not observable from the peer and so not pinned: the TCP keepalive socket option.
  • Gap 2: STT and TTS never called warm(). Both stages now warm the provider at Start, before the first connect, under the connect timeout on the injected clock (SttStatsSnapshot / TtsStatsSnapshot gain warms and warm_failures). A failed or timed-out warm-up is counted and never fails the call: the connect follows regardless. Reconnects do not warm again, tested on both socket stages in hybrid_prewarm: the first STT session dies while the call is quiet and the reconnect starts exactly one reconnect backoff after the death (no warm-up gap in front of it, one warm-up for the call); the first TTS session dies the same way and is replaced at the next response start, again with one warm-up for the call. The warm-up has a budget of its own, min(connect timeout, 400 ms): a warm-up that stalls (a probe that a proxy swallows says nothing about the real connect) delays the connect by 400 ms at most, not by the whole 5 s connect timeout, and the TTS processor is not held for 2 x 5 s. (The LLM warm-up is a spawned task under Timeouts::connect and never holds anything.)
  • Keepalives. HTTP/2 PING while idle (20 s / 10 s) and TCP keepalive on the shared clients; Deepgram KeepAlive; the ElevenLabs context keepalive. Cartesia documents no TTS keepalive and closes a socket after 5 minutes idle (and advises opening a new one when needed), so the TTS stage replaces a session that died while the call was quiet when the next response STARTS (LlmResponseStarted finds a dead session): the reconnect overlaps the LLM's time to first token instead of waiting for the first text. Proved in hybrid_prewarm.rs (idle death, then a turn: the connect starts with the response and is done before the first token). A reconnect longer than the LLM's first token still shows on that turn (the cost of a connect minus the TTFT); a proactive refresh before the idle limit would remove it and is a follow-up (the stage has no timer hook: it needs a spawned refresher that swaps the session).
  • Evidence. tests/hybrid_prewarm.rs runs the production chain on a paused clock with probe providers that give every warm() and connect() a simulated handshake time and log it: every setup finished before the caller started to speak and before the first VAD stop of the timeline (TurnEvent::UserSpeechEnd, read back from the run), each socket connect starts within 1 ms of its warm-up's end, exactly one connect per socket stage over several turns, a second call in the same process (own sockets and warm-ups) with the same clean turn 1, failures and hangs of the warm-up counted (also with the default 5 s timeout), the idle-death cases above (TTS and STT), and a control run in which the checker catches a handshake that is too slow. The probe does not model the shared HTTP pool, so the second call's test claims only its own clean turn 1; the pooling itself is proved at the socket level in crates/brisvox-providers/tests/{openai,anthropic,gemini,openai_compatible}/protocol.rs (three adapter instances, one TCP connection at the mock server, per adapter) and over HTTP/2 in tests/gemini/review.rs (two adapter instances, 20 concurrent streams, one connection). Live numbers need live runs.
  • Not done / open.
    • (Built, wired by the server) A proactive refresh of an idle TTS socket before the provider's idle limit: ResilientTts replaces a session idle for 80 % of TtsProvider::idle_limit (Cartesia: 4 of its 5 minutes); see "Resilience layer" below. No production call path wraps a provider yet (the server has no call assembly), so real calls still reconnect lazily on the next text until the server wiring.
    • (Done) The Smart Turn / Silero model warm() at Start: the VAD worker warms the model once per inference pool thread (VadSetup::with_warm_up, applied by SessionBuilder::with_vad) and the turn driver asks TurnModel::warm(threads) at Start; see "Assembly" below. hybrid_prewarm still covers the providers only; run_call.rs and warm_pool.rs cover the models.
    • HTTP/2 stream limits: a process-wide client multiplexes every call's SSE stream to one origin onto few connections and servers advertise SETTINGS_MAX_CONCURRENT_STREAMS (commonly 100), beyond which requests queue. Unmeasured against the real providers; see docs/RISKS.md R16 (capacity follow-up: measure with N concurrent calls, shard the pool key if it limits).

Assembly: run_call and build_session_pipeline

Code: crates/brisvox-engine/src/session.rs (library), crates/brisvox-engine/src/sim.rs (feature sim: EngineFactory, the production pipeline as a brisvox_sim::SessionFactory).

  • Builder. SessionBuilder::new(config, clock, timeline, offload, stt, llm, tts) validates the EngineConfig. The Offload is required (the pool in production, Offload::Inline only for paused-time tests), so an integrator cannot run real models on tokio workers by forgetting an option; with_vad(VadSetup) replaces the setup's own offload with the builder's. Options: with_vad, with_smart_turn(model), language, voice, interruption policy, system prompt, tools, flow, summarizer, phrases, a truncation-source wrapper (tests), a FrameTap (tests), a TurnProbe, the book capacity.
  • Model warm-up once per pool. The VAD warm-up (one job per pool thread) and the Smart Turn warm-up (one job asking for one session per thread) run once per pool and model for the whole process (Offload::claim_warm, keyed by the VAD model type and the Smart Turn model instance): the first call warms, later calls skip it, a failed or aborted warm-up gives the claim back. A burst of call starts therefore submits one set of warm jobs, not one per call, and cannot fill the bounded pool queue and make running calls' VAD fail open (test: warm_pool.rs). It was reworked after a performance audit: see "Warm-up claims" below.
  • Ending the call. Call::stop (bounded by timeouts.stop; Error::Timeout after cancelling a pipeline that did not drain), Call::cancel, dropping the Call, and Call::wait returning (hang-up or a stage failure) all cancel the call's token, so the pump ends and drops the transport session. A stage failure cancels nothing by itself: the owner must await Call::wait (or stop, cancel, drop).
  • Tap upstream drops. The test-only tap stage has the sink inbox (4 data frames), so the upstream lane TransportOut writes into is 4 deep instead of the TTS stage's 32. Upstream frames it cannot pass on are counted (SessionHandles::tap_upstream_dropped); the engine suite asserts zero for every call.
  • Chain and queues. pump -> AudioIn(+VAD worker) [input] -> SttStage [user] -> TurnDriver [user] -> LlmRunner [bot] -> SegmenterStage [bot] -> TtsStage [bot] -> (tap [sink]) -> TransportOut [sink], with the inbox shapes of EngineConfig::queues (input: 8 data frames, drop-oldest; user: 8, lossless; bot: 32, lossless; sink: 4, lossless; system lanes 32).
  • One PlayoutBook per call, shared by the pump (marks, positions), TtsStage (utterances, sent text), TransportOut (words, audio), the runner (round to message index) and the PlayoutTruncation source, and the turn driver's clear hook.
  • Clear hook. The driver clears through playout::clear_hook(commands, book): note_clear on the book, then the Clear command. SessionHandles::unnoted_clear_cuts stays 0 when the hook is wired; run_call.rs and every engine_suite run assert it (removing the hook fails five tests). The stale end Mark that could reach the wire after the driver's Clear is fixed: TransportOut sends marks with CommandSender::send_in (the epoch they belong to), so a mark whose epoch a Clear ended between the stage's epoch check and the send is cut by the transport (MarkCleared), never written after the clear (transport.rs and transport_out.rs tests; the latter fails with plain send).
  • Warm-up at Start. STT and TTS (provider warm, then connect) and the runner (LlmProvider::warm) as before; new: the VAD worker runs SpeechModel::warm once per offload thread (Offload::warm_each, every job submitted at once so each idle pool thread takes one; VadSetup::with_warm_up, bounded by timeouts.connect), and the turn driver runs TurnModel::warm(offload.threads()) as one pool job at Start (the ORT backend prepares one session per concurrent analysis). Failures are counted (warm_failures) and never fail the call. A bare VadSetup does not warm (its factory may be scripted in worker tests).
  • Early analysis. SessionBuilder::with_vad applies VadSetup::with_early_analysis(&config), the VAD half of EngineConfig::early_analysis; the driver half is its configuration.
  • run_call(session, builder, &cancel) -> Call. Builds the pipeline over session.commands(), starts it under a child of cancel, then starts the pump under the same child with the running pipeline's two inputs. Call::wait (caller hang-up), Call::stop (graceful, bounded by timeouts.stop, then ends the pump), Call::cancel (pipeline and pump at once), Call::stats (queue depths, drops, pump counters), Call::handles (every stage's counters, the book, the context). Dropping the call aborts every task (TaskCounter test).
  • build_session_pipeline returns the unstarted pipeline, the handles and a PumpWiring, for harnesses that start the pipeline themselves (the simulator binds the pump in InputTarget::bind_upstream).
  • Tap. A FrameTap is a pass-through KeepAll stage in front of TransportOut (it sees what TransportOut sees; TransportOut filters and counts stale frames itself). Production installs none.
  • EngineFactory. Loopback transport (latency 0, marks auto-acked at once, a position report per 20 ms frame), real TransportSession and pump, Offload::Inline, the simulator's energy VAD as an EnergyModel behind the production VAD worker, FixedTurnModel for the adaptive policy, optional time_in tool. The tap records Interrupt and every non-audio frame as it passes; audio is recorded when the loopback client receives it (wire time), which is what the shared suite's played-text oracle needs. Divergences from the reference: docs/TESTING.md.

Verification

Code: crates/brisvox-engine/tests/{engine_chaos,realtime_load,hot_path_cost,stt_turn_alloc, request_alloc,warm_pool}.rs; numbers in docs/TESTING.md and docs/LATENCY.md.

  • Warm-up claims. Offload::claim_warm keys: vad:<VadSetup::with_model_id>, else vad:<SpeechModel::warm_key> for a setup made with VadSetup::from_model (the ORT Silero: silero-ort:<path>), else the model type (the warm job warns when the model names a key of its own, so a type-keyed Silero setup is visible in the logs) and smart_turn:<TurnModel::warm_key> (the ORT backend: path plus a per-instance number; else the instance's address). Offload::warm(claim, timeout, jobs, job) hands a share of the claim to every job; the claim settles when the last share is gone: kept if every job succeeded, back after WARM_COOLDOWN (30 s, on the pool's clock) if the pool was busy (a job refused as saturated, timed out, or the warm-up skipped), back at once otherwise. A job still running after its caller's timeout keeps the claim, so a model is never warmed twice at once. Nothing is submitted while the pool queue holds more than WARM_MAX_QUEUE_PER_THREAD (1) job per thread (warm_skips): live VAD windows go first. Smart Turn warms with smart_turn_warm_jobs(threads) = half the threads (at least one) jobs at once, each asking warm(threads); the ORT backend reserves every session before creating it, so the jobs create threads sessions in all (about two each). Spans vad_warm / smart_turn_warm on brisvox::engine.
  • VAD stream build on the offload. The worker's own streams (first batch, after a lost job) are built inside the batch job, so SileroVad::load's fs::metadata never runs on a worker.
  • Request builds. A pool that refuses or times out a heavy build (or a heavy context copy) falls back to spawn_blocking, never to the worker (the timed-out job may hold the context lock); is_heavy also counts the whole context (INLINE_MAX_CONTEXT_BYTES, 256 KiB).
  • Margin fade. note_clear stamps the clear; the lead margin fades only up to min(interrupted_at, clear).
  • Stats. Call::stats() returns CallStats { pipeline, pump, stages: StageCounters } (every stage's counters, the book's, the truncation counters); CallStats::trace writes one call stats event on brisvox::engine (max queued, drops, stale drops, VAD saturations and timeouts, pool trouble, truncations, finals-gate, settle and mark timeouts).
  • Chaos on the production pipeline. engine_chaos.rs runs the shared 100-call chaos suite on EngineFactory (Budget). The slow consumer is a slow reader of the loopback client (LoopbackClient::split, a one-event client queue, transport_delay after each audio frame, frames stamped when read). Accommodations: adaptive end of turn with a fixed model, first TTS chunk after one word (SessionBuilder::with_first_chunk_words), a factory that keeps no handles (heap check). The first-chunk finding led to the latency tuning described in docs/LATENCY.md.

Resilience layer

Code: crates/brisvox-providers/src/resilience/ (module docs hold the full contract); tests crates/brisvox-providers/tests/resilience.rs (mocks, paused clock) and crates/brisvox-engine/tests/engine_failover.rs (the production pipeline on EngineFactory).

  • Shape. ResilientStt / ResilientLlm / ResilientTts implement the provider traits over a primary Leg and an optional secondary Leg (a provider instance plus its CircuitBreaker). The engine sees an ordinary provider: no engine code changed. Breakers and the process RetryBudget are process-wide (Resilience, Arcs shared by every call); each wrapper instance is one call (its session budget, its counters, probe() to read them after the wrapper was handed to the engine). Not wrapping a stage means no retries, breaker or failover for it.
  • Operations and cost. A connect, a request or a mid-call recovery tries the legs in order, each at most once (primary first; a recovery tries the leg that just failed last). A leg whose breaker refuses is skipped for free. The first attempt is free; each further attempt takes a token from the call's bucket (3, one per 20 s) AND the process bucket (50, one per 100 ms). Attempts followed by another run under attempt_timeout (2 s, inside the engine's 5 s connect timeout); the last attempt of a connect/request relies on the engine's deadline, every recovery attempt has the attempt timeout.
  • Breaker. Consecutive provider failures across all calls (a failed connect/request, a session that dies before it proved healthy: first event or 10 s alive) open it after 5 for 30 s; then one probe is let through (half-open) and every other attempt is refused. Once the probe connected (STT, TTS) or its response produced a first event (LLM) the breaker closes on probation (Permit::probation): the failure count is set one short of the threshold, so other calls are admitted at once and the next failure (the probe's own early death included) re-opens it at once; the probe proving healthy resets the count. Without probation a deployment with no secondary refused every other call's connect or request for up to 10 s (or a whole LLM response) after the provider was already back. A probe that fails before it connected re-opens the breaker. HTTP 400/404/413/422 and cancellations are request errors: no count, no failover.
  • Carry-over. STT: the audio since the last final segment plus the 1 s before it (stt_replay_tail; a rolling window of at most stt_replay, 4 s) is replayed into the new session, followed by an unanswered Finalize. The tail exists because providers send a final segment hundreds of ms after the audio it covers while the audio streams on: clearing the window at the final lost the words of that lag on a recovery. A word at the edge may now be transcribed twice; none is lost to a lag up to 1 s. A speech_final segment (the provider saw the speech end) empties the window. LLM: a request that fails before any content (error, cut, or no event within 4 s when a secondary exists) is re-issued to the secondary; after content nothing is retried. TTS: utterances with no audio and no word timing yet are re-spoken (text and flush); a partly heard utterance makes the session fail as before (the engine marks it lost; resuming exactly would need word timings that may lag the audio). The relay reads cancels before input, so text the engine queued before a barge-in's cancel can be read after it: the relay remembers the last 64 cancelled ids (as the adapters do) and drops their late text and flushes, so a cancelled utterance never re-opens (it would block the idle refresh for the rest of the call and be re-spoken by the next recovery after the engine was told Cancelled).
  • Idle refresh. A ResilientTts session with nothing in flight for 80 % of the provider's idle_limit() (Cartesia declares 5 minutes) connects a new session in the background and swaps it in while still idle (a refresh that completes during an utterance waits as a standby until the utterance ends); the old session is dropped. Nothing is in flight at the swap, so nothing is heard; the next turn never pays a connect.
  • Costs. One relay task per wrapped STT/TTS session and per LLM response; one extra channel hop per audio frame (STT in, TTS out); STT replay holds Bytes clones of up to 4 s of input (about 128 KiB per call at 16 kHz); TTS keeps the text of open utterances (4 KiB and 8 utterances per session at most). Every relay ends when the engine drops its handles.
  • Proof (paused clock). engine_failover.rs: all primaries down, four sequential calls: the secondaries answer every turn, the breakers open after two failures and the third call sends no request to the primaries and makes no retry; the primaries come back, the breakers half-open after 60 s, the probes succeed, everything closes and is served by the primaries. Mid-call deaths of all three stages in turn 1 (STT cut after its first interim, LLM 503, TTS cut before its first audio) leave the conversation identical to the fault-free one, and the same call without the layer does not. Both legs down with a budget of one retry: outcome Ok, no audio, no LLM request, at most two contacts per primary and one per secondary, no task left. A 3 s pause with a 2 s refresh: a refreshed TTS session, no connect on the next turn, first-audio times identical to the run without refresh. A barge-in through the layer: one interrupt, no stale audio, the cut story in the context, a refresh in the later pause and no connect but refreshes. Blackholed primaries (OutageSwitch::set_hanging: connects and requests never answer): every stage fails over at the 2 s attempt timeout, the call is answered whole. No secondaries, the breakers half-open, two calls start together: both are answered whole and neither is refused (probation). resilience.rs pins the timings exactly (failover of a hung connect at 2 s for STT and TTS, a hung recovery ends with the session's error at 2 s), the cancel race (text read after its cancel is dropped: no event of the cancelled utterance after its Cancelled across a recovery, the idle refresh still comes), probation (a probe that connects closes the breaker, a second call is admitted, a probe that dies early re-opens it at once) and the STT tail (a final lagging its audio by 300 ms: the recovery replays the lag).
  • In the server. brisvox_server::resilient::ResilientProviders holds one process-wide Resilience and one Leg per provider instance; providers() builds a ResilientStt / ResilientLlm / ResilientTts over them for every call (IdleRefresh::Auto), with the agent definition's [stt|llm|tts].secondary as the secondary leg. A secondary TTS is an OwnVoice: it replaces the engine's voice id with its own. Test: crates/brisvox-server/tests/resilience.rs (a hosted websocket call refreshes an idle Cartesia-limited TTS session; a failover speaks the secondary's voice).
  • Open. STT has no idle refresh (the engine streams audio continuously and adapters send keepalives); the layer is not in the 100-call chaos suite (its faults would be hidden by the recovery the suite then cannot see; engine_failover.rs judges it instead).

Speech-to-speech models as a stage

A speech-to-speech model replaces STT, turn driver, LLM runner, segmenter and TTS with one S2sStage between AudioIn and TransportOut; epochs, the clear hook, the playout book, the tool engine and the bounds are kept. Design, the turn-detection decision, the truncation rule and the failure policy: docs/S2S.md.

Tool-calling contract

  1. Schema: every tool declares name, description, JSON Schema for args, kind (ReadOnly | SideEffect), timeout, optional filler policy.
  2. Normalization: one internal representation; per-provider adapters translate (OpenAI, Anthropic, Gemini, OpenAI-compatible). Tool-call ids are stable internally.
  3. Assembly: streamed argument deltas are buffered until complete; partial JSON never executes.
  4. Validation: args validated against schema before execution. Invalid → structured error result to the LLM; bounded retries (default 2), then graceful spoken fallback.
  5. Execution: independent calls run in parallel; results appended to context in the order the LLM issued them.
  6. Idempotency: key = hash(session, turn epoch, call id). A SideEffect call with a seen key returns the recorded result instead of re-executing. A model that repeats a SideEffect call it already got the result of, in a later response of the same turn (a new call id), is refused already_done unless the tool is repeatable. The scope of this guarantee is exactly that, see "What "never twice" covers" below.
  7. Speculation: SideEffect calls from speculative LLM runs are held until the turn commits; discarded if the speculation is cancelled. ReadOnly calls may run speculatively.
  8. Interruption: ReadOnly in-flight calls are cancelled. SideEffect in-flight calls complete, are recorded, and are reported to the LLM on the next turn.
  9. UX: after the filler delay (policy default 1.2 s, or the tool's filler_after) without a result, play a filler phrase (pre-rendered when possible). Long tools may stream progress messages.
  10. Observability: every call traced with args (redacted), duration, outcome, retries.

What "never twice" covers

The guarantee is per call (one phone call or session), per turn. Exactly:

SituationWhat happens
The same tool call (same session, turn epoch and call id) is delivered again: a replay, a redelivery over the agent linkThe recorded result is returned; the tool does not run again. If the first run is still in flight the answer is duplicate_call; if its outcome is unknown, unknown_outcome, and it is not run again.
The model repeats an identical SideEffect call (same tool, same arguments) in a later response of the same turn, after it read a successful resultRefused as already_done, unless the tool is repeatable (the built-in send_dtmf is).
A SideEffect call was cut off (barge-in, timeout) and the model calls the same tool with the same arguments again within orphan_window (default 120 s)Not run again: it gets the recorded result if the first run finished, or a note that it was reported (orphan handling).
Two identical calls inside one responseTwo calls: both run ("book two tables for Kaya").
The same request in a later turn of the same call (the caller asks again)A new turn epoch is a new key: it runs. The engine cannot tell a deliberate second request from a repeat.
The same customer phones againA new session with an empty store: it runs.
The server restarts, or another instance handles the callThe store is in memory and per session: nothing is remembered. Do not rely on the key being unique across restarts or servers.
An old result was evicted (more than 256 entries or 4 MiB in one session)A replay of the evicted key runs again.
A tool held for confirmation (confirm)Takes no part in the repeat and cut-off rules above: it runs once, when the caller confirms.
A SideEffect tool returned an errorRecorded like a success: a replay does not re-run it. A retry needs a new call id.

So "never twice" means: the engine will not run the same tool call twice, and will not let a model that already read a success run the same call again in that turn. It does not enforce a business rule such as "one refund per order, ever" or "one booking per slot". For those, use an application idempotency key that is derived from your business data and is the same across calls and turns, for example the order id or sha256(customer, slot):

  • enforce it where the effect lives: a unique constraint in your database, or the Idempotency-Key header of the downstream API (payment and booking APIs support one), and make the tool return the existing result (already_done, the old booking) when the key was seen;
  • ctx.idempotency_key / ctx.idempotencyKey in the SDKs (and ctx.idempotency_key().digest() in Rust) is still the right key for the redelivery of one call: pass it along as well;
  • the examples show both: examples/booking-agent (a store with its own guard) and examples/rust-tools.

Internal representation

brisvox_agent::context: Message = System | User | Assistant { text, tool_calls, provider_data } | Tool { call_id, content, is_error }.

  • Ids: internal and stable, t<epoch>.<seq>, where seq counts every call of the turn across tool-loop rounds and restarts — mint them with Context::next_tool_call_id(s) (the tool-call assembler owns this), so item 6's idempotency key (session, epoch, call id) never maps two calls to one result. The provider's id stays in ToolCall::provider_id.
  • Ordering (Context::validate): every call answered exactly once, after its assistant message and before any other message (system included); results in any order among themselves; no empty assistant message (drop it, e.g. after truncation to nothing played).
  • Interruption with calls in flight (item 8): Context::interrupt_pending_tool_calls closes them with an "interrupted" error result so the next turn is valid; a SideEffect call that finishes later is reported by Context::push_late_tool_result as a system note.
  • Provider round-trip data (reasoning signatures etc.) rides in provider_data on the assistant message and on each call; adapters define and verify its contents.
  • Token estimates go through TokenEstimator (default CharsPerToken: bytes/4 + 4 per message). Serde form: {"role": "...", ...}; arguments must be a JSON object (adapters check).

Assembly

brisvox_agent::assemble: a provider-neutral ResponseAssembler fed with Delta (Text, ProviderData(value) for the whole response, or ToolCall { index, id, name, arguments, provider_data }). Adapters translate their stream events into deltas; the agent never depends on brisvox-providers.

  • Text is appended in order. Fragments are buffered per index; fragments of different calls and text may interleave; JSON may split anywhere (inside strings, escapes, numbers).
  • id/name may arrive on any fragment: the first non-empty value wins, a different later value is an AssembleError and the delta is not applied. Empty strings count as absent.
  • Nothing is parsed while streaming. finish(epoch, &Context) parses once: ids are minted with Context::next_tool_call_ids in index order, empty or blank arguments mean {}, and a call with no name, invalid JSON or a non-object becomes Assembled::Invalid (its id, raw text and CallError), answered with InvalidCall::error_result(). Valid siblings still assemble.
  • Buffered bytes are capped (default 256 KiB, with_max_bytes); a delta over the cap is an error, and each new call slot costs 64 bytes so empty fragments cannot grow the buffer forever. Provider data is charged at its serialized size.
  • Calls per response are capped (default DEFAULT_MAX_CALLS = 32, with_limits(bytes, calls)). A fragment that would open a call beyond the cap is AssembleError::TooManyCalls and is not applied; fragments for calls already open are still accepted. The cap bounds the fan-out of one LLM response (every call is executed and answered, SideEffect ones included), so the tool engine can dispatch a response's calls without a further limit.
  • Provider data: Delta::ToolCall { provider_data } is kept per call (first non-null value wins; a different later value is ConflictingCallProviderData), and Delta::ProviderData is kept per response (ConflictingResponseProviderData on a different later value). Both are carried verbatim into assistant_message(), invalid calls included, so an adapter can echo a signature back. Send each value whole on one fragment: it is not concatenated.
  • Cancelling is dropping (cancel(self)): a cancelled response yields no text and no tool calls. The guarantee is structural: cancel consumes the assembler, and a doctest checks that finish cannot be called afterwards.
  • Response::assistant_message() builds the assistant Message for the context (None when there is no text and no call; provider data alone does not count). A named invalid call is recorded under its name with {} arguments, and unknown_tool is used when no name arrived, so the context stays valid and the error result explains. Pinned by tests that read the recorded message, not only the error results.
  • Caller obligations: push a response's assistant message before finishing the next response of the same epoch. Ids come from the calls already in the context, so two responses finished against the same context before either is pushed get the same ids, and Context::validate rejects the second push (pinned by a test).
  • Cost: finish parses linearly in the buffered bytes. Run it on brisvox-inference for responses near the cap, not on a tokio worker.
  • Decision: id/name take the first non-empty value. The literal "first non-None" reading would keep Some("") and raise ConflictingId on the real id that follows, which breaks providers that repeat empty fields on every chunk.
  • warm() is not part of the assembler. It stays on LlmProvider::warm (default no-op) and is wired by the connection pre-warming code that knows the provider adapters. This keeps brisvox-agent free of a dependency on brisvox-providers.

Tool engine

brisvox_agent::tools (tools.rs, tools/schema.rs, tools/store.rs). Implements items 1, 4, 5, 6, 7, 8 and 10 of the contract. Item 9 (fillers) is carried here and played by the runner under the policy of "Tool UX" below.

Dependencies. jsonschema 0.58.6 (MIT), built with default-features = false: draft 2020-12 validation, no network, no remote $ref retrieval. It adds 36 packages to the lock (referencing, regex, fluent-uri, ...). One transitive license is MIT-0 (borrow-or-share, via referencing -> fluent-uri), allowed by a single-crate exception in deny.toml. Schemas compile once, at registration. brisvox-agent enables tokio's rt and sync features (SideEffect calls run on their own tasks). tracing-subscriber is a dev-dependency (the trace test).

Specs. ToolSpec { name, description, parameters, kind, timeout, filler_after }, built with ToolSpec::new(name, description, parameters, kind) plus with_timeout / with_filler_after.

  • kind: ToolKind::ReadOnly or ToolKind::SideEffect.
  • repeatable (default false, with_repeatable(true)): a SideEffect tool whose identical call may repeat in later responses of one turn (see "Repeats within a turn").
  • Defaults: timeout = DEFAULT_TOOL_TIMEOUT (5 s); filler_after = Some(DEFAULT_FILLER_AFTER) (700 ms). The engine does not play fillers: the FillerPolicy reads filler_after (see "Tool UX ") and the runner plays the phrase.

Registry. ToolRegistry::register(spec, tool) rejects, with RegistryError:

  • InvalidName: not 1 to 64 ASCII letters, digits, _ or -.
  • Duplicate: the name is taken.
  • ZeroTimeout.
  • ConfirmNeedsSideEffect / RepeatableNeedsSideEffect: confirm or repeatable on a ReadOnly tool (a ReadOnly tool changes nothing to confirm and may always repeat).
  • InvalidSchema: parameters without "type": "object", or a schema that does not compile offline (for example a remote $ref). A local $ref (#/$defs/...) compiles and validates.

A rejected tool is not added. specs() returns the specs in name order (deterministic LLM requests); spec(name), len(), is_empty().

Tool trait. fn call<'a>(&'a self, args: Value, ctx: ToolCallCtx) -> ToolFuture<'a>, where ToolFuture<'a> = Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + 'a>>. ToolOutput::Json(Value) or ToolOutput::Text(String), with From impls for both. ToolError::new(message). ToolCallCtx { session, epoch, call, cancel }, where cancel is the turn's CancellationToken, and idempotency_key(). Tools receive arguments that already match the schema.

  • The engine's timeout ends the caller's wait. For a ReadOnly tool it also drops the future.
  • A SideEffect future is not dropped because of a timeout, an interruption or a dropped execute future. It keeps running after its slot was answered timeout or interrupted. A SideEffect tool should still bound its own IO with timeouts of its own, because the engine ends the future in two cases only: the hard backstop (max_side_effect_runtime, below) and the drop of the ToolEngine (the end of the session), which aborts it at its next await point. A SideEffect tool must tolerate being dropped there.
  • A tool that panics does not unwind into the caller. A panicking ReadOnly tool (in call or in its future) is answered tool_failed with the detail the tool panicked, and its sibling calls are answered normally. A panicking SideEffect task is recorded as Lost (unknown_outcome).

Validation (item 4). Each call's arguments must first fit max_argument_bytes (default 16 KiB serialized; checked by a writer that stops at the limit). Larger arguments are malformed (arguments_too_large): they are not validated and do not run, and the round counts toward the retry budget. Then the arguments are checked against the compiled schema, in the planning step (synchronous, on the caller's task; see Cost below). Problems are reported as at most 5 issues of {path, reason}, with the path as a JSON pointer ((root) for the whole object). The path is bounded to 128 bytes and the reason to 200 bytes, the ellipsis included, plus truncated when more exist. A call with unparseable JSON, a missing name, an unknown tool, invalid arguments, or a position beyond the call cap is malformed.

Error results. Every refusal or failure is a tool message with is_error: true and a JSON body {"error": kind, "tool": name, "detail": text} (tool at most 64 bytes, detail at most 512 bytes, ellipsis included; invalid arguments add "issues" and "truncated"). Kinds:

  • invalid tool call: the assembler could not parse it (InvalidCall::error_result).
  • malformed-class kinds: unknown_tool, invalid_arguments, arguments_too_large (serialized arguments over max_argument_bytes), too_many_calls (a 33rd or later call of one response).
  • budget_exhausted: the turn's retry budget is spent. stale_turn: the call's turn was superseded.
  • timeout, interrupted, tool_failed (the tool returned an error, or a ReadOnly tool panicked).
  • result_too_large: a ReadOnly result over max_result_bytes.
  • duplicate_call, store_full, unknown_outcome: idempotency refusals (below).
  • already_done: a SideEffect call identical to one that succeeded earlier in the same turn (below).

Engine. ToolEngine::new(Arc<ToolRegistry>, SessionId, SharedClock, EngineConfig). It must be used inside a tokio runtime with the rt feature, since SideEffect calls are spawned. EngineConfig { max_invalid_rounds: 2, max_result_bytes: 64 KiB, idempotency_capacity: 256, idempotency_max_bytes: 4 MiB, max_argument_bytes: 16 KiB, max_side_effect_runtime: 120 s }. The engine raises idempotency_max_bytes to at least max_result_bytes (so a recorded result is not evicted by its own recording), treats idempotency_capacity: 0 as 1, and raises max_side_effect_runtime to at least a tool's own timeout. The engine's entry points:

  • execute(calls, epoch, &cancel) -> Execution: a committed response.
  • speculate(calls, epoch, &cancel) -> Speculation: a speculative response (below).
  • commit(speculation, &cancel) -> Execution: commits a speculation.
  • invalid_rounds(epoch), idempotency_usage() (IdempotencyUsage { entries, bytes }), take_late_notes(), late_notes_dropped().

Execution { results, late_notes, budget_exhausted }: results has one tool message per call, in issue order. Execution::append_to(&mut Context) pushes the results, then the late notes.

Rounds (items 4 and 5). Every execute, speculate and commit runs the same steps:

  1. Plan, synchronously and in issue order. Calls past position 32 of one response are answered too_many_calls. Malformed calls are answered at once. Under speculate, SideEffect calls are held. Nothing runs during planning.
  2. Admit the round. The engine tracks the newest turn (epoch) it has seen.
    • A call of an older turn is stale: every call of the round is answered stale_turn, nothing runs, and no budget is spent.
    • A newer turn becomes the newest, with a fresh budget.
    • A round with a malformed call adds one to the turn's count. The count does not go down: a valid round does not refund it. A round is exhausted when the count exceeds max_invalid_rounds. An exhausted round runs nothing: every call, valid ones included, is answered budget_exhausted, and Execution::budget_exhausted (or Speculation::budget_exhausted) is set. Every later round of that turn is exhausted too.
    • A discarded speculation still spends its round, since the LLM was asked.
  3. Run. The steps start in issue order, then all of them are awaited together (join_all). Each result is answered in its own slot, so results is in issue order whatever the completion order. Two SideEffect calls of one response that share a call id are not both run: the second answers duplicate_call.

Speculation (item 7). speculate runs the ReadOnly calls, answers malformed calls and refusals, and holds the SideEffect calls. Speculation exposes epoch(), held_calls() (the held calls in issue order), answered() (the results already settled) and budget_exhausted(). Nothing reaches the context until commit:

  • Commit: commit(speculation, &cancel) runs the held calls and returns an Execution whose results are the whole response in issue order (ReadOnly and held results interleaved as the LLM issued them). Append it, as for execute. Commit spends no budget. A speculation of a superseded turn answers its held calls stale_turn, and a turn whose budget is spent since then answers them budget_exhausted.
  • Discard: drop the Speculation. Its held calls never run. The caller then closes the open calls with Context::interrupt_pending_tool_calls(), so the context stays valid (Context::validate would reject a call with no answer before the next user message).

Cancellation (item 8). execute, speculate and commit take the turn's CancellationToken.

  • A call that has not started when the token is already cancelled answers interrupted and does not run, for both kinds.
  • A ReadOnly call still running when the token fires is dropped and answers interrupted.
  • A SideEffect call still running when the token fires answers interrupted at once (outcome interrupted_running). The call keeps running on its own task. Its result is recorded in the idempotency store, and it is reported later as a late note (the same text as Context::push_late_tool_result, which now covers interruptions and timeouts alike).
  • The execution returns when every call has answered. A SideEffect call answers when its result arrives, at its timeout, or at the interruption, so execute never waits for a running SideEffect call past its timeout, and barge-in is not delayed by a booking.

Timeouts (item 3). Each call is raced against its tool's timeout on the injected Clock.

  • ReadOnly: the future is dropped and the call answers timeout (outcome timed_out).
  • SideEffect: the slot answers timeout with a detail that says the call keeps running, that its result follows as a note, and not to repeat it (outcome timed_out_running). The call is not dropped: its result is recorded and reported later, so a booking that the provider accepted at 5.1 s is never reported as failed, and the LLM is not invited to book it again.

Side effects, idempotency and late results (items 5, 6 and 8). The key is IdempotencyKey(session, epoch, call id). IdempotencyKey::digest() is a stable FNV-1a 64-bit value of the same tuple, for logs and provider idempotency headers.

  • A SideEffect call runs on its own tokio task, which owns the tool future. Dropping the execute, speculate or commit future does not drop it. The task records the result, or, when its task ends without one (a panic, an abort), records the key as Lost. The engine keeps the tasks in a JoinSet (reaped on every spawn, so it holds only running calls, which the store bounds): dropping the engine aborts them, and each aborted call is recorded as Lost.
  • Hard backstop (a decision from the tool engine review): the tool future is raced against max(max_side_effect_runtime, the tool's timeout) on the injected clock (default 120 s). When it passes, the future is dropped, the key is recorded as Lost (unknown_outcome, never run again, outcome lost), and the call's slot or late note says that the outcome is unknown. So a hung tool leaks neither a task nor an in-flight store entry; without the backstop, 256 hung calls would have made every later SideEffect call of the session answer store_full.
  • A SideEffect result is never lost, whatever the caller does. Each result reaches the caller exactly once, either in the slot or as a late note:
    • The task sends the result on a oneshot channel. If the channel is closed, the task pushes the note itself.
    • The channel's receiver sits in a guard. When the wait ends at the timeout or the interruption, the engine closes the channel and takes a result that arrived in the meantime (it answers with that result). When the guard is dropped (the execute future was dropped), it closes the channel and pushes the note for a result that arrived but was never read.
    • A result already read into a slot of a run that is then dropped before it returns (another call of the response was still running) is held by the run and pushed as a note on drop. So a barge-in select! that drops execute loses nothing: Context::interrupt_pending_tool_calls tells the LLM the call was interrupted, and the note that follows tells it what happened.
  • Store states per key: in flight, done (the recorded result), lost (the task ended without a result). A done key answers its recorded result (recorded). An in-flight key answers duplicate_call. A lost key answers unknown_outcome and is not run again. When every entry is in flight, a new call answers store_full.
  • Bounds, per session: at most idempotency_capacity entries and idempotency_max_bytes of recorded result text. The worst case is therefore about 4 MiB per session with the defaults, plus the keys. When a bound is reached, the oldest settled entry (done or lost) is evicted. An in-flight entry is never evicted. A replay of an evicted key runs again. Call ids are unique within a turn, so this needs a retry of an old response.
  • A SideEffect tool that returns Err is recorded like a success: the key is Done with the tool_failed error as its result (outcome failed). A replay returns that recorded error and does not run the tool again. A retry needs a new call id (a new LLM response). This is the conservative choice: the engine cannot know whether a failed tool changed anything.
  • Result size: a SideEffect result over max_result_bytes is cut to that size, with the marker ...[truncated], and it is recorded and reported as done (outcome ok_truncated), because the effect happened. ReadOnly results over the limit become result_too_large. JSON results are serialized through a writer capped at the limit, so an oversized value is never serialized in full.
  • Repeats within a turn. A SideEffect call whose tool and arguments equal those of an earlier call of the same turn, of a different response, whose result is Done and not an error, is not run: it answers already_done ("an identical call already ran earlier in this turn and succeeded; its result is above ... do not repeat this call, tell the caller what happened"), counted in side_effect_repeats, traced as repeat_refused. Found by the scenario S3 (docs/TOOLS.md, "Verification"): a model that read the confirmation of its book_table asked for the same booking again in the next round and the table was booked twice (BK-1002), because call ids are unique and the idempotency key therefore was too. What is not a repeat: other arguments (another booking); the next turn (the caller may ask again); the same response (two identical calls of one response are two calls, "book two tables for Kaya"); a call whose earlier run failed (a failed SideEffect may be retried, above); an earlier call whose result the model never read (an orphan, which the orphan check above answers); a gated call (the confirmation gate's own request/confirm cycle); a tool declared repeatable (ToolSpec::with_repeatable(true): pressing the same IVR digit twice in separate responses; the built-in send_dtmf is repeatable, end_call and transfer_call are not; a tool declared by a remote agent opts out with repeatable in its declaration, docs/AGENT_PROTOCOL.md, "Remote tools"). The arguments compare as JSON values (key order does not matter), through the same digest as the orphan check. Cost: the check scans the recent SideEffect calls of the session (at most ORPHAN_CAPACITY = 32), under the state lock that start_side_effect already holds.
  • Late notes: a late result that finds the mailbox full (MAILBOX_CAPACITY = 64) is counted in late_notes_dropped() and not reported. Otherwise a late result waits for the next execute or commit, which append it after their results (Execution::late_notes), or for take_late_notes(). Call take_late_notes() only when no tool call is open in the context (after an Execution was appended), so a note never separates a call from its result.

Stale turns. The engine refuses a turn once it has seen a newer one in execute, speculate or commit. A turn that has made no tool call yet is not seen. So the caller cancels the superseded turn's token when the turn ends, which also stops its calls that have not started.

Caller obligations.

  1. Push the response's assistant message (Response::assistant_message) before execute or speculate. The engine never reads the context.
  2. Append each Execution (append_to) before the next message of the turn.
  3. On discarding a speculation, or dropping an execute future mid-round (for example in a select! on barge-in), close the open calls with Context::interrupt_pending_tool_calls(). A dropped execution's results never reach the context. Its SideEffect calls keep running, and their results (including those already finished when the future was dropped) come back as late notes, so the caller's barge-in path must not expect them in the dropped execution.
  4. Cancel the turn's token when the turn is interrupted or superseded.

Observability (item 10). Every call emits one debug event on target brisvox::tools: tool call settled, with session, epoch, call, tool (at most 64 bytes), outcome, duration_us and retries (the malformed rounds the turn had spent when the call was admitted). A held call emits tool call held at speculation time. Its settle event follows at commit. A late result emits late_result (from its task, or from the dropped execute that held it). Arguments are never logged (the contract's "args (redacted)" is met by omitting them). Outcomes: ok, ok_truncated, failed, timed_out, timed_out_running, interrupted, interrupted_running, not_started, recorded, duplicate, repeat_refused, lost, store_full, malformed, unknown_tool, invalid_arguments, arguments_too_large, too_many_calls, budget_exhausted, stale_turn, result_too_large, held and late_result.

Cost (item 4). Validation runs in the planning step, on the caller's task, not on the brisvox-inference pool, so its cost is bounded by max_argument_bytes (16 KiB) instead of the assembler's 256 KiB: larger arguments are refused before the schema is touched. Valid arguments take the is_valid fast path, which builds no error values. Measured (release build, one run of the engine's execute including the size check and the plan, best of 20, 2026-10-08, development machine; uniqueItems is hash based, not quadratic):

argumentsbytestime
typical (city, days)26under 1 us
uniqueItems array of 2500 integers11,39922 us
uniqueItems array of 1500 strings12,39924 us
uniqueItems array of 800 objects7,89945 us
16 KB string against an enum16,0089 us
1500 strings that all fail a pattern (error path, 5 issues reported)10,8990.30 to 0.36 ms
40,000 integers (over the cap, refused)200,000+under 30 us

Valid and typical calls are inside the 50 us budget of CONTRIBUTING.md. The one case above it is a hostile, invalid array against a regex schema, bounded at about a third of a millisecond by the cap. A reason can still quote the offending value, so it is formatted from at most 16 KiB and then cut to 200 bytes. Moving that error path to the inference pool is not worth a hop for this bound; lower max_argument_bytes for a tighter bound.

Tests. crates/brisvox-agent/tests/tools.rs (paused time, SimClock). Covered:

  • schema cases (valid, missing required, wrong type, extra property, nested, a local $ref); the registry rejections (including a remote $ref); bounded validation reports;
  • parallel timing (100 ms + 100 ms = 100 ms); issue-order results with reversed completion;
  • ReadOnly timeouts and failures; a panicking ReadOnly tool (in call and in its future) answered tool_failed next to answered siblings; the result size limit, exactly at the limit, for Json and Text and for both kinds, with exact cut length, kept prefix and multi-byte cuts;
  • the argument cap (exactly at the cap runs, one byte over is arguments_too_large, a 200 KiB uniqueItems array is refused);
  • ReadOnly interruption (dropped); SideEffect interruption (not dropped, reported later as a note); a result sent before a dropped execute is read, and a slot resolved inside a dropped execute (both come back as notes); a multi-thread stress test where tools finish at their timeout (every result answered or noted exactly once); the hard backstop (a hung tool is dropped, recorded lost, never rerun, and the note says the outcome is unknown); the abort on dropping the engine; a call that has not started; a SideEffect past its timeout (keeps running, not repeated, late result reported and recorded); a dropped execute future (the call is recorded, not stuck); a panicking SideEffect task (lost, never run again); a SideEffect result over the limit (cut and recorded);
  • idempotency: a replay runs once, the same call id twice in one response runs once, eviction by count, eviction by bytes; an in-flight entry is never evicted and a full store answers store_full without running; the byte budget raised to one result and capacity 0; a failing SideEffect is recorded and not retried; tools receive the right session, epoch, call id and the turn's own token; the pinned digest() and its dependence on session, epoch and call id;
  • repeats within a turn: an identical SideEffect call of a later response of the same turn is refused already_done and the tool runs once; other arguments, a new turn, two calls in one response, a repeatable tool, a ReadOnly tool and a retry of a failed call all run; the refusal leaves a valid context (mutation-checked: no check, no epoch check, failures counted as successes);
  • late notes: drained by the next execute after the results (valid context), and the mailbox keeping 64 and counting the 65th;
  • unknown tools; the retry budget (exhaustion, schema failures spending it, no refund by valid rounds, a turn that is superseded neither resets nor spends the current turn's budget, and the next turn's own budget); the call cap; registry names (valid ones with _, - and 64 bytes are accepted; 65 bytes and non-ASCII are rejected; a duplicate keeps the first tool);
  • bounded error fields (path 128, reason 200, tool 64, detail 512 bytes, cut inside multi-byte characters, still valid JSON);
  • speculation: ReadOnly runs, SideEffect held, commit in issue order (with the booking first), a discarded speculation runs nothing and spends its round, a speculation of a superseded turn runs nothing on commit; commit spends no budget, answers budget_exhausted after the budget ran out since the speculation (and for a speculation made under an exhausted budget), answers interrupted with a cancelled token without running, and a replay returns the recorded result;
  • every outcome traced with session, epoch, call, tool, duration_us (exact on the virtual clock) and retries, held and late_result as separate events, and no argument value in any field (a capture layer, brisvox::tools target);
  • Context::validate() after every scenario that builds a context; the per-request LLM metrics of a three-round tool loop; the engine and its futures are Send and Sync.

Tool UX: fillers and interruption semantics

brisvox_agent::tools (tools/filler.rs, tools/stats.rs), and the runner wiring in brisvox-engine (llm_turn.rs, llm_runner.rs). Implements items 8 and 9 of the contract.

Filler policy (item 9)

A FillerPolicy owned by the tool layer decides when and what; the LlmRunner speaks it.

  • Delay, measured on the injected Clock from the start of the tool round (execute, or the commit of a speculation): FillerPolicy::new() uses one fixed delay, DEFAULT_FILLER_DELAY (1.2 s), changed with with_delay. A tool opts out with ToolSpec::with_filler_after(None); under a fixed delay its own Some(d) is only the on/off switch. FillerPolicy::per_tool() takes each tool's filler_after as its delay (the original behaviour). FillerConfig { enabled, delay } (FillerConfig::DEFAULT = on, 1.2 s; FillerConfig::PER_TOOL = on, each tool's own delay) + from_config build either. A round with several calls fills after the smallest delay of its calls (ties: the first call).

  • Session config: LlmConfig::filler (in EngineConfig::llm, default FillerConfig::DEFAULT) is what the runner builds its policy from, so a session assembled from its config (run_call, SimSession) gets the 1.2 s default and can change or switch it off there. LlmRunnerBuilder::with_filler_policy replaces the whole policy (phrases, clips) when needed.

  • Why the default is a fixed 1.2 s: one delay for every tool that has a filler, so the caller hears silence for the same time whatever tool runs, and a tool that usually answers within about a second (most lookups) does not speak a filler that its answer would cut off, as the per-tool 700 ms default of filler_after would. Per-tool delays stay available as an opt-in (FillerConfig::PER_TOOL, [filler] per_tool = true) for agents whose tools have very different latencies; combining it with delay_ms is rejected, since one of the two would be ignored. The TTS stage waits for a pending clip before it opens the next provider context (step 4 below) because providers stream contexts independently: without the wait, the answer's audio could overtake the filler.

  • Phrases: with_phrases (generic list), with_tool_phrases(tool, ..). They rotate round-robin on a counter of the policy (no randomness: simulations stay deterministic), and only a filler that is actually spoken advances it (choose is called when the delay passes). A clone starts its own rotation (it shares only the clip cache), so a policy built once and cloned into every session keeps each session's phrases independent of the others. Default phrase DEFAULT_FILLER_TEXT ("One moment, please."). An empty list or with_enabled(false) switches fillers off.

  • Pre-rendered clips: a FillerPhrase may carry a ClipKey. with_clips(Arc<dyn FillerClips>) names the cache; the key reaches the FillerChoice only when the cache reports the clip as available (StaticClips is the simple implementation; the engine's FillerClipStore also holds the audio). The runner counts the choices that had a clip (LlmStats::filler_clips).

  • Clip playback: brisvox_engine::filler_clip. A FillerClip is PCM16 mono at the TTS provider's rate with its word timings (FillerClip::new, or with_spread_words for audio rendered without timings); a FillerClipStore maps ClipKeys to clips, is built once and is read-only afterwards. SessionBuilder::with_filler_clips(store) gives it to the runner's policy (as its cache) and to the TtsStage (with_clips); with_filler_policy sets phrases with clip keys. The path:

    1. The runner's filler round cues its RoundKey with the clip key in the PlayoutBook (cue_clip) before its LlmResponseStarted, then sends the phrase text as usual.
    2. The TTS stage, at the round's first text, finds the cue (clip_cue) and the clip in its store at the provider's rate, and opens a clip context: a context id of the current session (so an utterance id, register_utterance in the book) that the provider never hears of. The round's text is recorded verbatim (push_sent) and not sent. A cue it cannot play (no store, unknown key, other rate) falls back to the provider (TtsStats::clip_fallbacks).
    3. At the round's flush the clip goes to the session's reader on a bounded queue (CLIP_QUEUE = 4; a full queue drops it and marks the utterance lost, clips_dropped). The reader plays it once no earlier context is left in the ledger, so it comes after the audio before it: the same framer path as provider audio (UtteranceStarted, frames at the call rate, the Bytes shared and copied once per piece), then the clip's Words, then the end of the context (TtsEnded when it was the last one of the response).
    4. A provider context opened while a clip is pending waits for it (clip_waits; abandoned at a barge-in, bounded by CLIP_WAIT_LIMIT = 30 s), because a provider streams contexts independently and its audio would otherwise overtake the clip.
    5. Epochs: a clip whose epoch ended is dropped before it plays (clips_dropped), its frames are dropped like stale provider audio, and an interrupt never sends its context id to the provider's cancel channel. A barge-in in the middle of the clip is cut to the heard words like provider speech (the played filler stays in the context as its heard prefix).
  • In the runner: the policy from LlmConfig::filler, or LlmRunnerBuilder::with_filler_policy; with_phrases(filler, fallback) replaces the generic phrase of the current policy. The filler is its own round (own LlmResponseStarted, own ordinal in the PlayoutBook). Tools keep being polled while the filler is sent, so back-pressure does not stall them.

  • In the context, as played: a spoken filler is part of the conversation and is cut to what was heard like any round. It cannot be stored when it is spoken: a message between a tool call and its result breaks Context::validate, and a trailing assistant message would end the next request on an assistant turn (Anthropic rejects that prefill). So the runner keeps it aside (Inner::filler) and places it as an assistant message of its own once no call is open:

    • right before the next assistant message of the turn (the model's next response, or the fallback line): [assistant(calls), tool(result), assistant(filler), assistant(answer)]. The request that produced that answer was built without it (it is placed after it was sent); later requests see it (providers merge adjacent assistant messages);
    • or when the turn is settled (halt at a barge-in, the next user turn, Stop, panic recovery): after the open calls were closed with interrupted results, and before the user message.

    Its round gets its msg_index then (record_round), so the played-text truncation cuts it like any other: heard in full stays, a prefix is kept, nothing heard removes it (Removed). "The context holds what was played, and only that" holds for fillers too. Counter: LlmStats::fillers_in_context.

Interruption (item 8)

  • ReadOnly: cancelled. Its future is dropped when the turn's token fires, or when the caller's execute future is dropped or aborted (the runner aborts its round task at a barge-in). Both are counted in ToolStatsSnapshot::read_only_cancelled; a call that had not started is not_started_cancelled. Nothing is reported late and a repeated ReadOnly call is never suppressed.

  • SideEffect: runs to completion on its own task whatever the caller does, exactly once. Its result is recorded in the idempotency store and reaches the context exactly once, as the tool result when the caller was still waiting, otherwise as a late note (side_effect_interrupted, side_effect_late_results). The runner appends the note before the next user message, or after the results of the next tool round.

  • Regeneration (new): the idempotency key (session, epoch, call id) cannot recognise a response regenerated after a barge-in, because the new turn has a new epoch and new call ids. The engine therefore remembers recent SideEffect calls (ORPHAN_CAPACITY = 32 per session) with a digest of tool name and arguments (key order does not matter). A call is an orphan once its result can no longer reach the LLM as the tool result of its own turn: interrupted while running, timed out while running, its execute future was dropped, or its Execution was dropped before it reached the context (see "Delivery" below). A later call of another response run (each execute or commit is one: a later turn, or a later round of the same turn, such as a retry after a timeout that the LLM was told not to make) with the same tool and arguments within EngineConfig::orphan_window (120 s, ZERO turns it off) is answered without running the tool:

    • orphan finished: its recorded result, under the new call id (orphan_replayed). The orphan is consumed, and the late note of the original call is held back from that execution's late_notes (the LLM reads the result once), only when the execution reaches the context; an execution dropped before that leaves the orphan and its note in place, so the next regeneration is answered from the record too;
    • orphan finished and its late note already reached the context (taken by take_late_notes or carried by a delivered Execution; for example the barge-in came near the end of the tool and the note was appended when the regenerated turn started): already_reported ("its result is in the note above; do not repeat") (orphan_reported). The recorded result is not replayed, so the result is in the context once; later repeats within the window are answered the same way;
    • orphan still running: duplicate_call ("its result follows as a note; do not repeat") (orphan_in_flight); the original's note comes once when it finishes;
    • orphan ended without a result: unknown_outcome (orphan_lost), consumed the same way.

    A call whose result was delivered normally is not an orphan: a user who asks again later runs it again. Identical calls of one response are separate calls and both run.

  • Delivery: an Execution carries a Delivery guard. Execution::append_to (or mark_delivered, for a caller that wrote the messages itself) marks it delivered. An execution dropped undelivered (the runner discards it at a barge-in: the tool round finished while the filler was still being sent, or the turn was cancelled right after execute returned) never reached the LLM, so dropping it reports each SideEffect result it carried as a late note and marks the call an orphan, marks its other SideEffect calls orphans, and puts the late notes it took from the mailbox back. The result is in the context once (the note), and a regenerated identical call is answered from the record instead of running twice.

  • Counters: ToolEngine::stats() -> ToolStatsSnapshot { read_only_cancelled, not_started_cancelled, side_effect_interrupted, side_effect_late_results, side_effect_replays, orphan_replays, orphan_in_flight, orphan_lost, orphan_reported }. The runner's LlmStats keeps fillers, filler_clips, interrupted_tool_calls, late_notes.

  • Trace outcomes added: orphan_replayed, orphan_in_flight, orphan_lost, orphan_reported.

Tests

crates/brisvox-agent/tests/tool_ux.rs: policy (fixed vs per-tool delay, ties, rotation, per-tool phrases, clip availability, off switches, config); engine (ReadOnly cancelled and counted, not started, SideEffect completes once and is noted once, a regenerated call replayed or refused while the first runs, a dropped execute future, a timeout orphan, other arguments / expired window / window off / delivered result are not suppressed, a later round of the same turn is, same-key replay counted; a replay dropped with its round next to a slow sibling and one in a discarded execution keep the orphan and its note; a SideEffect result in a discarded execution is noted and not run again; notes taken by a discarded execution go back; a repeat after the orphan's note was taken or carried by a delivered execution is already_reported, while one after a discarded execution that carried the note is replayed; a same-turn retry after a timeout does not run again; two identical calls of one response both run; each clone of a policy rotates on its own). crates/brisvox-engine/tests/tool_ux.rs, through the runner on SimClock and MockLlm: the filler at exactly the policy's delay and not before, no filler for a fast tool, phrase rotation with a cached and an uncached clip, a disabled policy, the 1.2 s default from LlmConfig::filler and the per-tool and off settings of that config, a filler in the context after the results and before the answer (never in the request that followed it), a filler cut by a barge-in kept as its heard prefix ("One moment,") through the PlayoutTruncation and one never heard removed, a barge-in before the delay speaking none, ReadOnly cancelled and counted at a barge-in, SideEffect interrupted then regenerated (the body runs once, one note, one replayed result, valid context), regeneration while it still runs, an interruption during the filler, a booking that finished while the filler's frames were back-pressured and was dropped with its execution at the barge-in (noted once, not run again on regeneration), and a replay cut off with a slow sibling then regenerated again (the booking still runs once). crates/brisvox-engine/tests/tts_clips.rs, the TTS stage over MockTts: a cued clip is its own utterance after the audio before it and before the next round's (which waited for it), with the clip's words and book entries and one TtsEnded, the provider never hearing the phrase; a missing clip, a clip at another rate and a stage without a store fall back to the provider; a barge-in while the clip waits drops it and cancels only the provider's context. crates/brisvox-engine/tests/filler_sim.rs, a SimSession on the production pipeline (EngineFactory, audio in, loopback transport): a clip played whole is in the context whole; a barge-in in the middle of the clip while a 5 s SideEffect booking runs leaves the clip's heard prefix in the context, the regenerated identical booking is answered duplicate_call without running, and the booking's result reaches the context exactly once (its late note); a 2 s booking that finishes between the barge-in and the regenerated call (its note is appended when the regenerated turn starts) is answered already_reported, and its result is in the context once.

Tools in the production pipeline

Where the contract above runs in a call: the LlmRunner of brisvox-engine (llm_turn.rs, llm_runner.rs, llm_spec.rs; design and as-built notes in docs/PIPELINE.md sections 4 and 12). One round task per turn epoch, on the epoch's cancellation token:

  1. The request is built by RequestBuilder::build (build_window): the tool list is name-ordered (prompt-cache stability) and one turn's many tool rounds stay bounded.
  2. Text deltas are spoken while they stream; tool-call deltas only go to the assembler (item 3).
  3. At Done the assistant message, calls included, is pushed to the context once, before any call runs. With calls, a Flush makes the text before the tool speak while it runs.
  4. ToolEngine::execute races the filler timer (item 9; the filler is its own round). Results are appended in the LLM's order, then late notes (only when no call is open), then the next round. At most max_tool_rounds (6) rounds; past it, or when the retry budget for invalid calls is spent, a fixed fallback line is spoken (item 4).
  5. A barge-in cancels the token: ReadOnly calls are dropped, SideEffect calls finish on their own task and reach the next turn as late notes, and a regenerated identical call is answered from the record (item 8, "Regeneration"). The runner joins the round task before it touches the context, then cuts the context to what was played (docs/ARCHITECTURE.md, "Played-text truncation").
  6. Speculation is opt-in (EngineConfig::speculation, default off; item 7): a speculative run calls ToolEngine::speculate, which runs ReadOnly calls and holds SideEffect calls; only the commit at the real end of turn runs them (ToolEngine::commit), and a dropped speculation never runs them. Starting the LLM speculatively by default is a follow-up (it exists as an opt-in speculative LLM start), not part of the base round loop.
  7. Metrics: one timeline record per request round (next section); the engine's stage times (docs/LATENCY.md, "Engine stage times and memory") take the first round of a turn; the later rounds of a tool loop are not in the benchmark yet.

Remote tools

An agent process (docs/AGENT_PROTOCOL.md) declares tools in its session.accept; the link registers each one as an ordinary tool of the call's registry whose Tool::call is an RPC (brisvox_agent_link, tool.rs): a tool.call message to the agent, its tool.result back. Everything above applies unchanged: schema validation before the call leaves the server, the retry budget for malformed calls, the idempotency store (the idempotency_key in tool.call is the digest of (session, epoch, call id)), the result bounds, the filler policy (filler_after_ms), speculation (ReadOnly only).

  • Timeouts: the declared timeout_ms (default 5 s, at most 30 s) is the spec's timeout. A ReadOnly call is dropped by the engine at it or at a barge-in; dropping the RPC sends tool.cancel and a late tool.result is ignored.
  • SideEffect (item 8): the engine answers the slot timeout and keeps the future; the link keeps waiting up to side_effect_wait (60 s, under the 120 s backstop), so a late result becomes a late note. After that wait, or when the link goes down, the call ends with the message "the outcome is unknown" (a tool_failed result or late note): never retried, never reported as a plain failure.
  • Bounds: at most 64 declared tools, 32 calls waiting per link; a full control queue or a link that is down fails the call at once (tool_failed) instead of waiting.
  • A remote tool whose name collides with a tool of the agent definition is refused at the handshake and reported to the agent (error, stage agent_link).
  • tools.set narrows the offered tools from the next response on; a call to a tool that is not offered is answered {"error": "unknown_tool", ...} without running (the runner's screen, before the engine).

Per-request LLM metrics (brisvox_core::timeline)

The turn timeline timed only the first LLM request. A tool loop issues one request per round.

  • TimelineRecorder::llm_request(epoch, at) -> Option<usize>: opens the next round (returns its index) and marks TurnEvent::LlmRequest (first wins, as before).
  • TimelineRecorder::llm_first_token(epoch, round, at): the round's first token. Also marks TurnEvent::LlmFirstToken (first wins).
  • TimelineRecorder::llm_done(epoch, round, at): the round's completion.
  • TurnRecord::llm_rounds(), llm_round(i) and llm_round_count() give LlmRoundMetrics { round, time_to_first_token, duration }. Each is derived from its own marks.
  • At most MAX_LLM_ROUNDS (32) requests are timed per turn. A longer tool loop is beyond the metrics: its requests get no record and count in TimelineStats::llm_rounds_dropped. This deviates from "every request gets its own metrics" only past 32 rounds, which the turn loop should stop anyway. Round marks for a finished turn are late, as other marks.
  • Counters: each llm_* call counts once in TimelineStats (recorded if it changed the turn mark or opened its round, repeated if it changed nothing). llm_rounds_dropped counts requests past the cap.
  • Summaries: brisvox::timeline emits one llm request finished event per timed round.

In the server binary (brisvox_server::tools)

The binary's EngineRunner::with_tools takes a ToolSetup: every tool the server can offer, registered once at startup. An agent definition lists the ones it uses (agent.tools, docs/CONFIG.md); a listed tool the setup does not provide stops the start with its position in the file and what would provide it (fail closed: a call without its tools would answer wrongly). Each call gets its own ToolEngine (the idempotency store is per call) over the agent's tools only.

sourcetoolshow
applicationanythingthe plug-in point brisvox_server::tools::AppTools: fn register(&self, &mut ToolRegistry, &SharedClock), called once. Your own main is one line: brisvox_server::app::run_with_tools(Arc::new(MyTools)) (below). The tools are shared by every call: per-call state comes from ToolCallCtx (session, idempotency key)
demo (feature demo-tools, off)lookup_order, book_appointmentcanned answers labelled as demo data, so examples/agents run end to end; never for callers
telephony (feature call-control)transfer_call, send_dtmf, end_callregistered when BRISVOX_CALL_CONTROL names a carrier; destinations from [telephony] (docs/TELEPHONY.md 5); warm transfers, DTMF over Twilio and answering-machine handling

Application and demo tools share one registry, so a call pays no schema compile for them. The telephony tools hold per-call state (one transfer per call, the carrier's call id), so a call that uses them registers them on its own registry, on the inference pool (Offload::run, bounded at 2 s). The example agents name lookup_order and book_appointment as the application tools they stand for: a default build refuses them with the hint above, a build with demo-tools runs them.

Rust tools in your own binary

The stock brisvox-server binary has no tools of your own. To add Rust tools you write a small binary of your own that depends on the brisvox crate (one dependency: it re-exports brisvox-server as brisvox::server, the tools API as brisvox::agent::tools and the clock types as brisvox::core, and forwards the server's features) and hands your AppTools to the same program the stock binary runs; you do not copy its main:

use std::sync::Arc;

fn main() -> std::process::ExitCode {
    brisvox::run_with_tools(Arc::new(MyTools::default()))
}

brisvox::run_with_tools is brisvox_server::app::run_with_tools, and brisvox::run() (brisvox_server::app::run()) is the stock program (no tools of your own) and run_with_tools(tools) is the same with tools registered next to the built-in ones. Both take the settings from the environment exactly like brisvox-server (docs/OPERATIONS.md), keep its logging, its exit codes and its feature flags, and understand its command line: no arguments starts the server, check <agent-file> validates an agent file offline and knows your tools' names (docs/CONFIG.md, "Checking a file"), --help, --version. Enable in your Cargo.toml the brisvox-server features your agent file needs (provider-*, webrtc, smart-turn-ort, call-control). The stock binary's mimalloc global allocator is declared in its main.rs, not in the library: add your own #[global_allocator] if you want it.

From outside this repository. The brisvox crates are on crates.io. Use Rust 1.99 or newer (the crates use edition 2024) and depend on them by version; commit your own Cargo.lock to keep the dependency versions you tested:

[dependencies]
brisvox = { version = "0.1", features = [
    "provider-deepgram", "provider-openai", "provider-cartesia", "smart-turn-ort",
] }
serde_json = "1"

(cargo add brisvox --features provider-deepgram,provider-openai,provider-cartesia writes the same. The brisvox crate holds no logic of its own: brisvox::agent::tools has Tool and ToolSpec, brisvox::core::SharedClock the clock. Depending on brisvox-server, brisvox-agent and brisvox-core separately works the same.)

To follow the development branch instead, depend on the git repository ({ git = "https://github.com/bilal07karadeniz/brisvox", rev = "<commit>" }, pinning a commit) and copy rust-toolchain.toml and Cargo.lock next to your Cargo.toml.

A fully local agent (no API key) needs the features provider-sherpa-onnx, provider-openai-compatible, provider-openai-speech, smart-turn-ort and, for browsers, webrtc (book/src/quickstart/local.md). The whole standalone Cargo.toml is in examples/rust-tools/README.md.

examples/rust-tools is a complete, runnable project of exactly this shape, with its agent file:

  • get_time(timezone) is a ReadOnly tool: it changes nothing, so the engine may run it speculatively and cancels it when the caller barges in. It reads time from the injected clock (SharedClock::wall), and a bad argument is a ToolError whose text tells the model what to say or retry.
  • save_note(text) is a SideEffect tool: never speculative, never run twice for one call. It still keys its effect on ctx.idempotency_key(), so a redelivered request gets the same note back. Key your own store on the whole IdempotencyKey (distinct keys may share a digest); pass digest() only to an external API as its idempotency key.
  • State that outlives a call (here the notes) lives behind an Arc inside your AppTools; per-call data comes only from ToolCallCtx.

Run it with cargo run -p brisvox-example-rust-tools -- check examples/rust-tools/agent.toml, and with keys and BRISVOX_AGENT_FILE=examples/rust-tools/agent.toml set, without check, to serve calls. The crate's tests register the tools and load the agent file against them, so a renamed tool or a stale agent file fails the build.

If your tools live in another process or language, use the Python or TypeScript SDK (sdk/python, sdk/typescript) instead; the agent file then lists no agent.tools for them (the agent link supplies them).

Confirmation gate for SideEffect tools

Idempotency stops a SideEffect tool from running twice; it does not stop it from running wrongly. A caller (or text the caller reads out, or a document a tool returned) can talk the model into calling transfer_call to a number of their choice. A tool declared with confirm = true is therefore held: the model's call does not run it.

Declaring it. Rust: ToolSpec::new(..).with_confirm(true) (SideEffect tools only; registering a ReadOnly tool with confirm is RegistryError::ConfirmNeedsSideEffect). Agent definition (TOML or YAML): list the tool in agent.confirm_tools (a subset of agent.tools), which turns the gate on for tools the server registered without the option, built-in telephony tools included (confirm_tools = ["transfer_call"]). Agent link: "confirm": true in the tool declaration (docs/AGENT_PROTOCOL.md). Registering a confirm tool adds the built-in tool confirm_pending to the registry (the name is reserved); a registry with no confirm tool has none.

What happens.

  1. The model calls the tool. Arguments are validated as always. The engine does not run it; it records a pending confirmation and answers with this result (not an error): {"status":"confirmation_required","tool":"<name>","call_id":"t3.0","message":"Not done yet: this action needs the user's confirmation. Tell the user exactly what you are about to do and ask them to confirm. Only if the user clearly agrees in their NEXT reply, call confirm_pending with this call_id. If they decline, change the request or say anything else, do not call it. Never confirm on your own."}
  2. The model asks the user. The user answers in a later user turn.
  3. If the model judges that the reply clearly agrees, it calls confirm_pending(call_id). The engine then runs the held call, with the arguments of the original request (the confirmation cannot change them), through the normal SideEffect path: its own idempotency key, timeout, backstop and late note. Its result is the result of the confirm_pending call.

What the engine enforces (the meaning of "yes" is the model's judgment; the order of events is not):

  • A later user turn. A request and its confirmation can never belong to the same user turn, however many tool rounds the model makes inside it. The runner reports each ended user turn (Confirmations::note_user_turn, called when the user's message enters the context; a conversation edit made by an agent is not a user turn); a confirm_pending made before one has ended since the request is refused ("the user has not replied since you asked") and the request stays pending. In a speech-to-speech session the provider's "speech stopped" event can arrive after the model already answered and called the tool: the speech that was still going on when the request was made does not count as the reply (Confirmations::note_user_speech_started marks where a speech began), so a request and its confirm cannot share a turn even then.
  • Epoch. A request records the turn epoch it was made in; a confirmation from an older epoch is refused as stale.
  • Expiry. A request lives EngineConfig::confirm_ttl (default 120 s) on the injected clock.
  • Barge-in clears. An interruption of the bot drops every pending request (Confirmations::clear): the user may not have heard what they would confirm, and the context after an interruption holds only what was played. The cost is one more question when a caller talks over the question.
  • Once. Taking a request removes it, so a second confirm_pending finds nothing; a replay of the same confirming call returns the recorded result (the idempotency store).
  • Bounded. At most 8 requests wait at once (the oldest is dropped); a repeated call with the same tool and arguments replaces its pending request.
  • Per session. The pending set lives in the call's ToolEngine; nothing is shared between calls.
  • Speculation. Like any SideEffect call, a gated call is held by a speculative response and recorded only when the response is committed.

Logging. Every SideEffect execution is logged (brisvox::tools, info) with its tool name, epoch and idempotency key digest, and a confirmation additionally with the id of the request it confirms. Arguments are never logged (a phone number is personal data, docs/PRIVACY.md).

Known limits. A confirmation is the model's decision, so a model that is itself fully controlled by an attacker and a caller who really says "yes" still passes: the gate protects against the model acting without the user's say-so, not against the user. S2S sessions report user turns by speech end and barge-ins by their own events. A gated call takes no part in the orphan replay (the orphan window, TOOLS.md item 7), and its result is never reported as a late note: a request whose result did not reach the model is cleared by the barge-in, and when the model repeats the identical call it records a fresh request with a fresh call_id, which the user's "yes" then confirms. confirm_pending itself cannot be listed in confirm_tools (the name is reserved).

Verification: parallel and chained calls

Double and triple tool calls and parallel tool calls get their own verification (2026-10-14). Everything above is tested piece by piece; crates/brisvox-engine/tests/tool_scenarios/ tests the pieces together, on the production path: LlmRunner with its ToolEngine, filler policy and interruption handling, in the runner's pipeline (the rig of tests/common/llm.rs), over a provider. One scenario is one function (scenarios.rs) that runs against

  • scripted providers, in the normal suite (mock.rs): the simulator's MockLlm on a paused clock (calls arriving one after the other: plain, and with interleaved argument fragments: interleaved) and the real adapters OpenAiCompatibleLlm, AnthropicLlm and GeminiLlm over loopback servers (wire.rs) that speak each vendor's streaming format for parallel calls, in real time. The script says what the model does, so the assertions are exact: wall time of the tool phase, concurrency (every call started before the first finished), result order in the context and on the provider's wire (one tool message per result for OpenAI; the tool_result blocks of one user message for Anthropic; the functionResponse parts of one user content, with the thought signature echoed, for Gemini), the number of requests (a bug that adds a round fails), the runner's counters;
  • real providers with BRISVOX_LIVE_TESTS=1 (live.rs), with loose assertions that hold whatever a real model chooses. The loose assertions are themselves run against the scripts (mock::loose), so a mistake in them shows up in the suite and not on the first run with keys.
#ScenarioToolsWhat the exact (scripted) run pins
S1three independent lookups in one responseget_weather x3, 600/400/200 msone tool round, three calls, tool phase 600 ms (the longest, not the sum 1200), results in issue order though Cairo finishes first, one spoken answer, no filler
S2chain of three roundsget_order -> get_customer -> get_weatherfour requests, three rounds, each tool starts after the previous one finished, each request carries one more result (also on the wire), final answer names the city and temperature
S3a booking next to a lookup, the model repeats the bookingbook_table (SideEffect), get_weatherthe table is booked once (BK-1001, never BK-1002); the repeat is answered already_done; three results in issue order
S4arguments that break the schemaget_forecast (days max 3)the invalid call never reaches the tool; structured invalid_arguments with the JSON pointer /days; the valid retry runs; one unit of retry budget
S4bthe model never gets it rightget_forecastexactly three requests, nothing runs, budget_exhausted, one spoken fallback, no endless loop
S4cbroken calls among good onesget_weather + an unknown toolresults ok / invalid_arguments / unknown_tool / ok in issue order, the two good calls run together, the retry budget is spent once
S5a slow toolget_weather 2 s, filler after 1.2 sexactly one filler, at the policy's delay (+/- 30 ms of simulated time), before the tool ends, then the answer
S5bthree slow callsget_weather x3, 2 to 3 sstill one filler; phase length is the slowest call
S5ca tool past its timeoutget_weather 5 s, timeout 600 msthe future is dropped, timeout result, one filler, the model apologises, the next request goes out at the timeout and not at 5 s
S6barge-in while the tools runbook_table 600 ms + get_weather 5 sthe ReadOnly call is dropped (never finishes), the booking finishes once, both open calls closed interrupted, one late note with BK-1001 before the caller's next words, and in the next request

Speech-to-speech (s2s.rs, the S2sStage with a scripted provider that sends what Gemini Live sends): S1 (three ToolCalls of one response run together; their three results go to the provider in call order and one CreateResponse follows), S2 (a three-round chain: one result and one CreateResponse per round), S3 (a repeated booking is answered already_done).

Mock results (normal suite, 2026-10-14)

cargo nextest run -p brisvox-engine --test tool_scenarios: 77 tests run without keys and all pass (plus 4 live tests that skip without BRISVOX_LIVE_TESTS=1). Each scenario x shape cell below is one test (plain and interleaved on MockLlm, the three adapters over loopback); the last two rows are the loose (live) assertions against the scripts and the speech-to-speech stage.

ScenarioMockLlm plainMockLlm interleavedOpenAI-compatibleAnthropicGemini
S1 parallelpasspasspasspasspass
S2 chainpasspasspasspasspass
S3 repeated bookingpasspasspasspasspass
S4 invalid then validpasspasspasspasspass
S4b invalid foreverpasspasspasspasspass
S4c broken among goodpasspasspasspasspass
S5 slow tool + fillerpasspasspasspasspass
S5b slow parallel, one fillerpasspasspasspasspass
S5c tool timeoutpasspasspasspasspass
S6 barge-inpasspasspasspasspass
loose (live) assertions, S1 to S6pass-passpasspass
S2S stage: S1, S2, S3scripted Gemini Live events: pass

Bug found and fixed (S3). Before the fix the model's repeat of book_table ran the tool again: two bookings, BK-1001 and BK-1002. Call ids are unique within a turn, so the idempotency key (session, epoch, call id) never matched, and the orphan check covers only calls whose result the model did not read. The engine now refuses an identical SideEffect call of a later response of the same turn when the first one succeeded (already_done, see "Repeats within a turn" above; ToolSpec::repeatable opts out). Regression tests: crates/brisvox-agent/tests/tools.rs (an_identical_side_effect_call_of_a_later_response_of_the_same_turn_is_refused and four more for the edges), mutation-checked (disabling the check, dropping the epoch check and counting failed calls as successes each fail tests); and S3 above on every shape. Two fixture changes in existing tests: the store-bound tests of tools.rs used one set of arguments for many call ids and now use distinct ones, and the flow tests' act tool is repeatable.

Running the live variants

BRISVOX_LIVE_TESTS=1 cargo test -p brisvox-engine --test tool_scenarios live_ -- --nocapture --test-threads=1

One test per provider (live_openrouter, live_anthropic, live_gemini, live_gemini_live); a provider without its key skips with a message. The keys come from the environment (never printed):

ProviderVariables
OpenRouter (OpenAI-compatible adapter)OPENAI_COMPATIBLE_API_KEY; optional OPENAI_COMPATIBLE_BASE_URL (default OpenRouter), OPENAI_COMPATIBLE_MODEL (default openai/gpt-6-luna), OPENAI_COMPATIBLE_REASONING_EFFORT (default none, empty omits it)
AnthropicANTHROPIC_API_KEY; optional ANTHROPIC_MODEL
GeminiGEMINI_API_KEY (or GOOGLE_API_KEY); optional GEMINI_MODEL (default gemini-3.1-flash-lite). Free tier: about 5 requests a minute and 20 a day per model, so requests are spaced 13 s, a 429 skips the scenario with a message, and only S1 to S3 run by default
Ollama (OpenAI-compatible adapter, Preset::Ollama, no key)OLLAMA_MODEL (required, one model per run; the test skips without it); optional OLLAMA_BASE_URL (default http://127.0.0.1:11434/v1), OLLAMA_REASONING_EFFORT (default none, empty omits the field), OLLAMA_MAX_TOKENS (default 600), OLLAMA_SCENARIOS. The test is live_ollama; the latency test is ollama_voice_latency in crates/brisvox-providers/tests/openai_compatible/live.rs (OLLAMA_RUNS, default 5)
Gemini Live (S2S stage)the Gemini key; optional GEMINI_LIVE_MODEL (default gemini-3.8-live); S1 and S2 only

BRISVOX_LIVE_SCENARIOS=1,2,6 picks scenarios for every provider; OPENROUTER_SCENARIOS, ANTHROPIC_SCENARIOS, GEMINI_SCENARIOS and GEMINI_LIVE_SCENARIOS override per provider. A run prints, per scenario, every round (time to first event and to first text, total, calls, text bytes), every tool (start, finish, dropped), the runner's counters and what was spoken, then LIVE RESULT <provider> scenario <n>: ok|FAILED (<seconds> s). The live scenarios 5 and 6 use real time (1 s tool, 0.4 s filler; 0.6 s booking). Gemini Live drives the engine's S2S stage with a recorded sentence of the FLEURS fixtures ("The East African Islands are in the Indian Ocean off the eastern coast of Africa."): the prompt turns it into three independent lookups (S1) or a country -> capital -> weather chain (S2).

Live results

Run 2026-10-14 (BRISVOX_LIVE_TESTS=1 cargo test -p brisvox-engine --all-features --test tool_scenarios live_ -- --nocapture --test-threads=1; Gemini text requests spaced 13 s for the free tier). Cells: result, the issuing round's time to first event, rounds.

ScenarioOpenRouter openai/gpt-6-lunaAnthropic (Haiku 5.5)Gemini gemini-3.1-flash-liteGemini Live gemini-3.8-live
S1 parallel (3 calls in one response)ok: 3 calls, TTFT 2.0 s, 1 tool round, total 4.0 sok: 3 calls, 1.4 s, 1 round, 3.3 sok: 3 calls, 1.8 s, 1 roundok: 3 calls run together (307 ms phase), spoken answer
S2 chain (3 rounds)ok: 3 rounds, 1.1-1.2 s per round, 6.1 sok: 3 rounds, 0.6-1.1 s per round, 5.1 sok: 3 rounds (spaced 13 s)ok: Africa -> Algeria -> Algiers -> weather, 3 rounds
S3 booking repeatedok: booked onceok: booked onceokn/a
S4 invalid argsok: retried with valid argsokn/an/a
S5 slow tool + fillerok: 1 fillerok: 1 fillern/an/a
S6 barge-inok: late note next turnok: late note next turnn/an/a

Gemini Live first failed every connect: the adapter sent the raw JSON Schema in parameters, which takes only Gemini's OpenAPI subset and refuses the setup on additionalProperties; it now sends parametersJsonSchema (fixed with this run).

Local models through Ollama

Run 2026-10-14 against Ollama 0.34.1 on Windows, an RTX 5070 (12 GB), through the same engine path as the table above (LlmRunner + ToolEngine, the OpenAI-compatible adapter with preset = "ollama", reasoning_effort = "none", sampling left at each model's Ollama defaults, the server's default context of 4096 tokens). One model at a time:

OLLAMA_MODEL=gemma4:12b-it-qat BRISVOX_LIVE_TESTS=1 cargo test -p brisvox-engine --all-features   --test tool_scenarios live_ollama -- --nocapture --test-threads=1
OLLAMA_MODEL=gemma4:12b-it-qat BRISVOX_LIVE_TESTS=1 cargo test -p brisvox-providers --all-features   --test openai_compatible ollama_voice_latency -- --nocapture

Each model ran the six scenarios three times (18 runs). Latency: a one-to-two sentence voice prompt on a warm model, five runs, p50 of the time to the first text token and to the end of the first sentence. The GPU had room for every model (no other large GPU process was resident), so "GPU memory" is the full footprint that /api/ps reports at the 4096 context.

ModelParams, quantGPU memoryScenarios passed (of 18)TTFT p50First sentence p50
gemma4:12b-it-qat11.9B, Q4_07.7 GB (all on GPU)18/18: S1 to S6 each 3/369 ms267 ms
gemma4:e4b-it-qat7.5B (4B effective), Q4_03.1 GB12/18: S2 0/3, S4 0/3, rest 3/342 ms157 ms
qwen3.5:4b4.7B, Q4_K_M3.1 GB12/18: S2 0/3, S4 0/3, rest 3/365 ms208 ms
qwen3.5:9b9.7B, Q4_K_M5.5 GB12/18: S1 2/3, S2 2/3, S4 0/3, S5 2/3, S3 and S6 3/379 ms291 ms
qwen2.5-coder:7b7.6B, Q4_K_M4.7 GB0/1834 ms220 ms

How the others fail (the failures are the models', not the engine's; the engine refused every bad call and ran every good one):

  • S2, a chain whose second call needs the first result. qwen3.5:4b and, once in three, qwen3.5:9b call get_customer and get_weather in the same response with a guessed city (Rome, Paris) instead of waiting for get_customer. gemma4:e4b-it-qat answered nothing at all (an empty response, no text and no call).
  • S4, an invalid argument (days above its maximum). Every model but gemma4:12b-it-qat read the structured invalid_arguments result, said "I will ask for three days instead" and ended the turn without calling the tool again. The caller hears a promise that is never kept.
  • S1 and S5. qwen3.5:9b once answered the three-city question and once the slow Izmir question with made-up weather, without calling any tool (a hallucinated answer, the worst failure for a voice agent).
  • qwen2.5-coder:7b never emits a native tool call: it writes the call as JSON text ({"name": "get_weather", "arguments": ...}), which the agent would speak aloud. S6 also waited its full 120 s for tools that never started. Do not use it for tools.

Thinking. qwen3.5 and gemma4 are thinking models that think by default on Ollama. reasoning_effort = "none" on the OpenAI-compatible endpoint turns that off and is what the table used (a model without a thinking mode, qwen2.5-coder, accepts the field). The native field think: false is ignored on /v1/chat/completions (qwen3.5:4b spent 300 of 300 tokens on reasoning and returned an empty answer). Without the setting, qwen3.5:4b produced no spoken text inside a 200-token budget. With reasoning_effort = "medium", qwen3.5:9b passed 12/12 scenario runs, but the plain chat prompts took 14 to 35 s to the first spoken word (TTFT p50 22 s), and qwen3.5:4b still failed S2: thinking buys tool reliability at a price a call cannot pay.

Partial offload. gemma4:12b-it-qat needs 7.7 GB. With only 5 GB of GPU memory free (the Kokoro voice server resident next to it), 30 of its 48 layers on the GPU (4.96 GB) gave TTFT p50 376 ms and first sentence p50 1.66 s on the same prompts (measured through Ollama's native /api/chat with num_gpu = 30 and think = false, because /v1 has no per-request options; the model is the same, the adapter adds a few ms). That is usable for a call but no longer fast: either give the language model the GPU alone (Kokoro's CPU image), or accept the 1.7 s. The first request after a model was idle paid its load: 7 to 50 s (cold) in these runs, so warm the model before a call (OLLAMA_KEEP_ALIVE=-1 keeps it resident).

Recommendation: gemma4:12b-it-qat for any agent with tools, reasoning_effort = "none" always. Without tools, gemma4:e4b-it-qat is the fastest (157 ms to the first sentence in 3.1 GB). The example agents in examples/agents/local/ use the first. Three runs per model is a small sample: a model that passed 3/3 here can still fail on a prompt the scenarios do not cover, so try your own tools.

Speech-to-speech models as a stage

A speech-to-speech (S2S) model takes the caller's audio and answers with audio, calling tools in between. One S2S session therefore replaces the STT, turn driver, LLM runner, segmenter and TTS stages of docs/PIPELINE.md. This file is the design of that replacement and the reference for the code (crates/brisvox-providers/src/s2s.rs, crates/brisvox-providers/src/openai_realtime/, crates/brisvox-engine/src/s2s/). Facts about the first provider are in docs/providers/openai-realtime.md.

Status: OpenAI Realtime adapter and the stage are implemented and tested against mocks. Not done: live runs with a key, and the follow-ups at the end. The Gemini Live adapter (crates/brisvox-providers/src/gemini_live/, facts in docs/providers/gemini-live.md), re-seeding of a new session from the local mirror and the rotation that survives the provider's session limit (section 7a) came later.

1. The graph

classic:  AudioIn -> SttStage -> TurnDriver -> LlmRunner -> SegmenterStage -> TtsStage -> TransportOut
S2S:      AudioIn -> S2sStage ------------------------------------------------------> TransportOut

AudioIn (resample to the model rate, optional local VAD) and TransportOut (pacing, marks, playout book) are unchanged. S2sStage speaks to the transport stage in the same frame vocabulary as TtsStage (UtteranceStarted, Audio at the call's output rate, TtsEnded) and to TransportOut's upstream feed (TtsStarted, TtsEnded when the client played the end mark), so the pump, the marks, the position reports and the clear path all work as they do for TTS audio.

Assembly: SessionBuilder::new_s2s(config, clock, timeline, offload, provider, S2sOptions) (the three classic provider type parameters are Unused), or with_s2s(provider, options) on any builder. build_session_pipeline builds the classic stages anyway for their counters and the shared Context, and only puts S2sStage in the pipeline. SessionHandles::s2s carries the stage's counters. The system prompt is the model's instructions, with_voice its voice, with_tools its tools. Ignored in S2S mode (documented on with_s2s): smart turn, interruption policy, flows, summarizer, filler and phrases, truncation wrapper, text/say input, conversation edits, speculation.

2. The provider-neutral interface

brisvox_providers::s2s, shaped like SttSession/TtsSession:

  • S2sProvider::connect(S2sConfig) -> S2sSession { audio, control, events } (+ warm).
  • S2sConfig: instructions, voice, tools (ToolSpec), S2sTurnDetection::{Provider, Manual}, input/output rate, input transcription, language.
  • Audio in: PCM16 mono Bytes frames on a bounded channel (the engine uses try_send).
  • S2sControl: Commit, ClearInput, CreateResponse, CancelResponse, Truncate { item, audio_end }, ToolResult { call_id, output }, Note { text }.
  • S2sEvent: SpeechStarted/Stopped, ResponseStarted, Audio { response, item, pcm }, AudioDone, AssistantTranscript, UserTranscript, ToolCall (once per call_id), ResponseDone { status }, Warning, Error (fatal, last).

What a provider cannot do fails connect; nothing is emulated silently. Gemini Live maps onto the same trait (additive extensions: S2sConfig::history with S2sHistoryItem to seed a new session, S2sProvider::session_limit, S2sEvent::SessionEnding). Where Gemini has no equivalent of a control it is documented in gemini_live/mod.rs and docs/providers/gemini-live.md: Truncate is accepted and ignored (the server cannot cut its history to the heard share; the mirror is exact), CancelResponse ends the response locally and the next activityStart interrupts the server, CreateResponse after Commit or a tool result is absorbed because the server answers by itself, response and item ids are minted by the adapter.

3. Who decides when the caller's turn ends (decision)

Two modes, S2sTurnMode:

  • Provider (default). The provider's VAD (server_vad/semantic_vad) ends turns, creates the response and cancels a running one. Audio is streamed continuously (the VAD needs the silence). The barge-in decision is the provider's SpeechStarted. Why default: it is the lowest latency (no hop to our VAD, the provider's turn detector is tuned for its model) and needs no local model. Cost: one provider-VAD false trigger (echo, noise) cuts the bot, and the decision cannot use our Smart Turn model or the transcript-based echo guard.
  • Local. The provider's VAD is off (turn_detection: null). The call's local VAD (with_vad) decides: UserStartedSpeaking is the barge-in decision, UserStoppedSpeaking commits the buffer and creates a response. Audio is sent only while the caller speaks plus a 300 ms pre-roll, so the provider's buffer holds one utterance. Why: our VAD thresholds, the same VAD as the classic pipeline (so behaviour and tests carry over), no provider VAD cost. Not included yet: Smart Turn and the finals gate (needs transcripts), i.e. the end of turn is the VAD's minimum silence.

In both modes barge-in is VAD-only: the backchannel and echo rules of InterruptionPolicy need STT words, which an S2S call does not have until the provider's transcript arrives.

4. Epochs and the barge-in path

S2sStage is the sole epoch authority, like TurnDriver: the epoch moves at every start of caller speech. If the bot was busy at that moment (a response generating or requested, a tool running, or audio of the epoch not yet reported played by the client) it is a barge-in:

  1. hook(new_epoch): note_clear on the PlayoutBook, then the transport Clear (fast path), the same clear_hook as the turn driver (bounded by timeouts.send).
  2. Interrupt{new_epoch} downstream; TransportOut marks AudioOutStopped and drops stale frames.
  3. The stage's own queued audio is discarded (counted); framer reset; current response forgotten (later events of it are dropped as stale and counted); tools of the old turn are cancelled (CancellationToken); in Local mode CancelResponse is sent (the provider's VAD does it itself in Provider mode).
  4. The truncation job starts (section 5). It never delays 1 to 3.

Frames of a response carry the epoch current when ResponseStarted arrived. The epoch is per user turn: a tool round's second response is in the same epoch, so the transport's "Clear before the first audio of a newer epoch" rule cannot cut the first answer's audio. TtsEnded (the end mark) is sent once, when nothing more is coming: no response generating or requested, no tool running.

5. What was played: truncation

The model keeps its own history, so "after an interruption the context contains only what was played" is enforced there with S2sControl::Truncate { item, audio_end }. On a WebSocket the server cannot know what the caller heard (it generates far ahead of real time; audio waits in our queue, the transport's queue and the client's buffer), so the client must say.

audio_end is computed exactly like the played-text truncation does, from the PlayoutBook: the stage registers one round and one utterance per assistant audio item (RoundKey(epoch, n)); the pump writes positions and mark echoes; TransportOut adds the audio handed over. After the clear the job waits for the book to settle (wait_settled_through, bounded by settle_wait), then snapshot(key): an item whose end mark was echoed (or that a later one followed) is whole; else heard_position(lead_margin) = min(released, total) minus the client's lead, clamped to the audio received for the item. An item heard to within one frame of everything is not truncated. The cut text is estimated for the local Context mirror (transcript prefix at a word boundary in the heard share; the model's own history is cut with the exact duration). An item whose round the book no longer knows is left alone, never cut to zero.

Ordering: the provider's own VAD may create the next response before the truncate arrives. The job starts at the barge-in and the caller's speech takes hundreds of milliseconds, so the truncate wins in practice; in Local mode the commit waits for the job (bounded), so it always wins. Accepted risk in Provider mode, to be measured in a live run.

6. Tools

Tool calls arrive as S2sEvent::ToolCall and run through the call's ToolEngine when their response is done (ResponseDone(Completed)) and the turn is still current. This reuses the engine's semantics unchanged: read-only tools are cancelled by a barge-in (interrupted); side-effect tools never run speculatively, never run twice (idempotency key (session, epoch, call id), plus the orphan window), and report late results as notes (S2sControl::Note, flushed before the next turn). Each call gets a stage-minted ToolCallId::new(epoch, seq) with a session-wide seq; the provider's call_id is mapped back for the answer. A call id seen before is ignored (the adapter and the stage both de-duplicate).

Answers go back as ToolResult (a function_call_output item). Then, only if the turn is still current, CreateResponse. A result of a superseded turn is added to the history and no response is requested (the caller is already talking). Calls of a cancelled or superseded response are not run and are answered interrupted, so the model's history stays well formed. Limits: calls per response (16), tool rounds per turn (llm.max_tool_rounds), the engine's own argument and result bounds. Past the round limit the calls are answered tool_round_limit without running, the model gets a note ("answer without tools") and one CreateResponse per turn, so the caller does not hear silence; a model that keeps calling tools after that is not asked again. The same note goes out when the ToolEngine reports an exhausted retry budget. A response that ends Failed, Cancelled or Incomplete has its calls answered interrupted and no new response is requested (a failure is reported as a non-fatal error frame).

7. Bounds, timeouts, failure

  • Caller audio: stage to task queue 64 frames, drop-newest, counted; task to provider 64 frames, drop-newest, counted; Local mode keeps a 300 ms pre-roll ring. Commands: 64, counted if lost. Local-mode commands carry the number of audio chunks queued when they were issued, and the task takes that audio from its queue first, so the end of an utterance is not clipped by a command that overtakes queued frames. The adapter sends the audio queued before a Commit ahead of it.
  • Liveness: the OpenAI adapter pings after keepalive_interval without any inbound frame and fails the session with a timeout error when no frame (Pong) arrives within io_timeout; the stage then runs its usual lost-session path (settle, reconnect with backoff).
  • Provider events: 128, backpressure on the adapter's socket reader. Output audio: a VecDeque in the task, bounded by S2sOptions::max_buffered_audio (default 60 s); beyond it chunks are dropped and the utterance is marked lost (counted). The select loop never blocks on the downstream: a barge-in empties the queue first.
  • Every network wait has a timeout: connect (timeouts.connect, and the adapter's io_timeout for the handshake, each setup answer, each write and the event hand-over), the clear and interrupt sends (timeouts.send), the resampler build (pool, timeouts.join), the response wait (response_deadline).
  • A dead session (error, closed channels, stuck control queue) is replaced with a doubling backoff (reconnect_backoff, cap 5 s) up to max_reconnects consecutive failures (restored after a 10 s session). The model's history is lost with the session, so the new session is seeded from the local mirror (7a). When the budget is spent the stage emits one fatal error frame (an EngineEvent::Error with fatal) and drops caller audio, counted. The task is a TaskGuard: it dies with the stage.
  • No unwrap/panic/indexing in the library code; time only through the injected clock.

7a. Re-seeding and rotation

Seed. Every connect (first, reconnect, rotation) builds S2sConfig::history from the stage's local Context mirror (s2s/seed.rs): caller transcripts, the assistant text that was actually played (cut at a barge-in, so the invariant "the context holds only what was played" carries over to the new session), tool calls with their results, late-result notes. Leading system messages (the instructions) are skipped because the configuration carries them. A tool call without a result (running or interrupted) and a result without its call are left out: providers refuse both. Bounds: the newest 128 items, 32000 characters in total, 4000 characters per text (a larger tool result is cut; arguments that are too large become {}). Caller turns are in the mirror only through the provider's transcripts, so the stage requests them whenever the provider has a session_limit (rotation), whatever S2sOptions::input_transcription says; for a provider without a limit a reconnect seed holds the assistant's text but not the caller's unless the option is set. The OpenAI adapter sends one conversation.item.create per item after session.updated; the Gemini adapter one clientContent (see the provider docs for what is UNVERIFIED).

Rotation. A provider session has a limit (S2sProvider::session_limit: OpenAI Realtime 60 minutes, Gemini Live about 10 minutes per connection). S2sStageConfig::rotate_margin (default 2 minutes, never more than half the limit) before it, or at once when the provider announces the end (S2sEvent::SessionEnding, Gemini goAway), the task connects a replacement seeded from the mirror while the old session keeps serving. It switches at the first moment that is quiet: no response, tool or playback, the caller silent, and the mirror still the length it had when the seed was taken. A due rotation re-checks every 250 ms while the bot is busy; a replacement that arrives at a busy moment is dropped (rotations_deferred) and retried; a failed connect is retried with the reconnect backoff (rotation_failures). The old session is closed by dropping its channels. The call hears nothing, no error frame is emitted and session_failures stays 0. If the session dies before the switch (limit reached while the bot was busy, a network error) the ordinary reconnect applies, seeded the same way; only the response in flight at that moment is lost.

Guards. (1) A seeded session that dies within 5 s of opening is taken to have refused its seed (Gemini closes the socket on a history it dislikes): the next session opens without history; if that one outlives the grace until its own rotation the stage stops seeding for the rest of the call, otherwise the seed was not the cause and seeding resumes. (2) Provider speech events are not trusted alone for "the caller is silent": loud caller audio (mean amplitude 500 or more) within the last 700 ms also blocks the swap, so a provider that sends no speech-start event is not rotated mid-utterance. (3) The Gemini mapper ignores an output-transcript chunk that arrives after the turn ended instead of opening a response for it. Counters: rotations, rotations_deferred, rotation_failures.

8. Tests

  • Adapter contract (crates/brisvox-providers/tests/openai_realtime.rs, mock Realtime WebSocket server on a loopback socket): bearer auth and no beta header, session.update shape, base64 audio, every control event, event mapping, a tool call delivered once from either event, function_call_output before response.create, barge-in truncation (conversation.item.truncate with audio_end_ms), racy errors swallowed and other errors surfaced, setup rejection and setup timeout, a lost connection, a malformed message.
  • Stage (crates/brisvox-engine/tests/s2s.rs, a whole call over the loopback transport with a scripted mock provider, paused time): session configuration, one epoch and one end mark, barge-in clear with no stale audio and truncation to the played duration, superseded-response events dropped, queue discard, tool round trip, side-effect de-duplication, superseded tool results, local turn detection and local barge-in, reconnect, refused connect, connect timeout, budget exhaustion, bounded output queue. Fault-injection mutations of each (the truncation send, the stale check, the end-of-epoch rule, the clear hook, the interrupt, the queue discard, the tool-result response rule, the call de-duplication) fail at least one test.

9. Open items

  • Live run with an OpenAI key: real latency (voice-to-voice, barge-in to silence), Turkish quality, the truncate-versus-next-response race in Provider mode, session.update fields marked UNVERIFIED in the provider doc (input transcription block, G.711 formats, idle behaviour).
  • Live run with a Gemini key: Gemini Live latency and Turkish quality, the UNVERIFIED items of docs/providers/gemini-live.md (tool schema field, clientContent mid-session, empty turnComplete as a response request, binary frames, voiceActivity by default, header key), and what OpenAI does at the 60 minute limit and with an assistant output_text seed item.
  • Gemini session resumption handles (keep the audio-native context across the 10 minute connection limit) instead of a text re-seed; only if the live run shows the re-seed is audibly worse.
  • Smart Turn and a transcript-aware echo guard in Local mode; a greeting (the model speaks first).
  • Telephony formats without double resampling (G.711 passthrough, a 24 kHz AudioIn target).
  • brisvox-server config switch and docs/CONFIG.md entry for choosing an S2S provider.

Protocols

brisvox speaks three protocols to the outside, each with its own reference page:

ProtocolForDirectionReference
Websocket protocol (version 1)Browsers, apps and test clients: one websocket connection is one call. Binary frames carry audio, text frames carry JSON control messages (start, started, clear, mark, dtmf, stop, ...).Client connects to the server (BRISVOX_WS_ADDR)WebSocket protocol
Agent protocol (version 1)Your application logic in another process: tools, events, commands. The Python and TypeScript SDKs implement it.The server connects out to your agent (BRISVOX_AGENT_URL)Agent protocol
RTVIPipecat's client SDKs talking to brisvox.Client connects to the server (BRISVOX_RTVI_ADDR)RTVI

Carrier and browser transports have their own pages: Twilio Media Streams, Telnyx media streaming, SIP trunk and WebRTC. All listeners and their settings are in Operations.

Brisvox websocket protocol, version 1

Status: version 1. Code: brisvox_transport::websocket (protocol.rs is the executable definition, connection.rs the server side, client.rs the reference client). Design and bounds: docs/ARCHITECTURE.md, "WebSocket transport".

One websocket connection is one call. Binary frames carry audio, text frames carry one JSON object each with a type field. The server speaks plain ws://; terminate TLS (wss://) in front of it (a reverse proxy). The server does not look at the request path. The protocol has no authentication of its own, but the upgrade request can be checked ( docs/SECURITY.md): with BRISVOX_SESSION_SECRET the client must present a session token your backend minted (aud brisvox-ws, short-lived, single use), with BRISVOX_WS_TOKEN a static one (development). The token travels in Authorization: Bearer <token>, in the WebSocket subprotocol brisvox-token.<token> (a browser: new WebSocket(url, ["brisvox", "brisvox-token." + token]); the server selects brisvox), or in ?token=<token>. A missing token is HTTP 401, an invalid, expired, reused or wrong-audience one 403, an oversize one (over 4 KiB) 413; every answer carries Cache-Control: no-store. The token's sid, agent, max_s and meta reach the call's metadata as session_id, session.agent, session.max_s and session.meta.<key> (the client's own start.metadata cannot set those keys; max_s is reported, not enforced, on this listener). The server binary keeps this listener on loopback, or off next to another listener, unless BRISVOX_WS_ADDR says otherwise, and refuses a non-loopback BRISVOX_WS_ADDR without BRISVOX_SESSION_SECRET or BRISVOX_WS_TOKEN unless BRISVOX_INSECURE_LISTENERS=1 (docs/OPERATIONS.md).

Also on the WebRTC signaling port. With brisvox-server --features webrtc, the signaling listener (BRISVOX_WEBRTC_ADDR) serves this same protocol on a WebSocket upgrade at BRISVOX_WEBRTC_WS_PATH (default /ws), so a browser whose network cannot carry WebRTC media can still call over the one HTTP(S) port. The connection and call code is this transport's; what differs is the listener around it. The upgrade is judged like POST /whip: the signaling listener's token (static BRISVOX_WEBRTC_TOKEN or a session token with aud brisvox-webrtc, in the same three places as above), its Origin rule, its offer rate, per-address call cap and handshake pool, and the call limit (HTTP 401, 403, 413, 429, 503 before the upgrade), and a session token's max_s is enforced here (the call ends with stop{ended}), unlike on the standalone BRISVOX_WS_ADDR listener above, which only reports it. The call's metadata is as above with transport webrtc-ws (the server's logs and metrics label the call the same way). The browser module sdk/web/brisvox-call.js (and with it the bundled pages /demo and examples/web-assistant/web/index.html) use it as their fallback with 24 kHz pcm16, 20 ms frames, an 80 ms playback buffer, and echo mark once the audio before it has played.

Audio

  • Mono, in both directions, at the sample_rate and encoding the client gave in start:
    • pcm16: 16-bit signed little-endian samples. A frame must be whole samples (even length).
    • mulaw: G.711 mu-law, one byte per sample (telephony; typically 8000 Hz).
  • Sample rates: 8000, 16000, 22050, 24000, 44100 or 48000 Hz.
  • A frame may hold any amount of audio up to max_audio_bytes (default 64 KiB). Send 20 ms frames: that is the unit the engine paces and clears at. An empty binary frame is ignored.
  • The server sends bot audio at real time (one 20 ms frame per 20 ms), already in the call's rate and encoding. The transport itself does not resample: audio at another rate than the call's is dropped, with one output_rate_mismatch notice per call. The engine never sends such audio: it reads the call's rate from the transport's Started event and its TTS stage resamples the provider's audio (24 kHz by default) to it in exact 20 ms frames, so any client rate works with any provider rate (an 8 kHz mu-law call with a 24 kHz TTS: 160-byte frames, about 7 us per frame in release; crates/brisvox-engine/tests/ws_mulaw_call.rs).
  • Allocation: pcm16 frames pass without a copy in both directions; the mu-law conversion writes into a per-connection buffer allocated in 16 KiB chunks. A chunk serves 51 decoded frames (320 bytes: 20 ms at 8 kHz) or 102 encoded frames (160 bytes), so about one allocation per 50 frames from the client and one per 100 frames to it (unit-tested in connection.rs).

Client to server

typefieldsmeaning
startversion (1), sample_rate, encoding (pcm16 default, or mulaw), call_id (optional, at most 128 bytes), metadata (optional string map, at most 32 entries, keys up to 64 bytes, values up to 256 bytes)Opens the call. Must be the first message. A client should send nothing else before it has started; the server does not enforce that: audio, dtmf and mark that arrive between start and started are accepted and passed on. The rate keys of the call's metadata belong to the server: a client-supplied sample_rate is overwritten and input_sample_rate / output_sample_rate are removed (the call has one symmetric rate).
dtmfdigit: one of 0-9 * # A-D (a-d are accepted too and mean the same)A key press.
markid (at most 256 bytes)Echo of a server mark: everything the server sent before it has been played.
stopThe client hangs up.

Unknown fields are ignored. A text frame larger than max_control_bytes (default 16 KiB) is an error.

Server to client

typefieldsmeaning
startedversion, call_id, sample_rate, encodingThe call is open. Bot audio can follow from here.
clearepochBarge-in. Drop everything not yet played, including audio and marks buffered on the client; audio sent after this message belongs to the new epoch. Marks that were cut are never echoed (an echo of one is ignored anyway).
markid (at most 256 bytes)Echo this id as a mark once all audio sent before it has played. The server does not send a mark with a longer id (non-fatal mark_id_too_long instead; the engine gets no MarkPlayed for it), because the client's echo of it would be refused.
stopreason: ended (the engine hung up, or the client's own stop was processed) or shutdown (the server stops)The call is over; the server closes the connection next.
errorcode, message, fatalA protocol fault. A fatal error is followed by a close frame; a non-fatal one is informational.

message is for humans and never contains credentials or audio.

Lifecycle

  1. The client connects (HTTP upgrade). If the server is at max_calls (or, in brisvox-server, at BRISVOX_MAX_SESSIONS), the answer is HTTP 503 with Retry-After: 1, and no websocket exists. A brisvox-server whose inference pool is overloaded (5) or whose provider budget is exhausted (2) answers 503 with a longer Retry-After the same way. A brisvox-server that is draining for a shutdown answers HTTP 503 without Retry-After: connect to another instance (docs/OPERATIONS.md).
  2. The client sends start within start_timeout (default 5 s).
  3. The server validates it and answers started (or a fatal error, then a close).
  4. Audio and control flow both ways. The server pings every ping_interval (default 10 s); a standard client answers pongs automatically. Any frame from the client counts as life. No frame for ping_timeout (default 30 s) ends the call with ping_timeout.
  5. The call ends when:
    • the client sends stop: the server answers stop{ended} and closes (engine: PeerHangup);
    • the client sends a close frame (engine: PeerHangup). The server answers with a close frame (RFC 6455 5.5.1), but it cannot send stop after it read the client's close frame, so a client that wants the stop message should send stop and wait for the server's close. stop followed at once by a close frame (a browser's send(stop); close()) works: the server holds back reading the close frame until it has sent stop{ended}, and the engine sees PeerHangup;
    • the engine hangs up: stop{ended}, close 1000;
    • the server shuts down: stop{shutdown}, close 1001;
    • the connection breaks without a close frame, an EOF or a reset (engine: PeerDisconnected, and no error event: a vanished peer is not a protocol violation, there is nobody to report it to);
    • a fatal error, a ping timeout or a stuck client (below).

Errors and close codes

The close codes below are asserted by tests/websocket.rs (a client branches on them: 1013 means retry later); 1000 is the engine's hang-up and 1001 the server's shutdown.

codefatalclose codewhen
invalid_jsonyes1002a text frame that is not a JSON object with a string type
invalid_messageyes1002 / 1008a known type with missing or invalid fields (bad digit, bad start, too long id, start not first)
unknown_messageno (yes before start)an unknown type; ignored once the call is open. More than 16 faults on one call end it with too_many_errors
unsupported_versionyes1008start.version is not 1
unsupported_sample_rateyes1008start.sample_rate is not a supported rate
unexpected_startyes1008a second start
audio_before_startyes1008a binary frame before start
start_timeoutyes1008no start in time
bad_audioyes1008a pcm16 frame with an odd byte count
frame_too_largeyes1009a binary or text frame over its limit (or over the websocket layer's limit; that error may be lost to a connection reset)
too_many_errorsyes1008see unknown_message
output_rate_mismatchnothe engine produced audio at another rate than the call's; dropped, reported once per call
mark_id_too_longnothe engine asked for a mark with an id over 256 bytes; the mark is not sent
server_busyyes1013the engine is not taking new calls
overloadedyes1013the engine takes no input from the call for send_timeout
ping_timeoutyes1008see Lifecycle

A client that does not take bot audio (its socket stays full for write_timeout, default 5 s) is disconnected without a message, since nothing more can be written to it. Playout is real time, so the server never buffers more than a few frames for a slow client. When the engine hangs up or the server shuts down while the client is stuck, the blocked write is abandoned at once and the goodbye gets at most min(write_timeout, close_grace).

Server configuration

ServerConfig::validate (called when the server starts) refuses: ping_timeout not longer than ping_interval, ping_interval under 10 ms, a zero timeout or close_grace, max_calls or accept_queue of 0, max_audio_bytes under 2, max_control_bytes under 512. The websocket layer itself accepts frames up to the larger of max_audio_bytes and max_control_bytes (a frame above that is refused as frame_too_large by the layer, with a close that may reach the client as a reset when much of the frame is unread); the smaller limit is enforced by the connection.

Marks and barge-in, from the client's side

A phone-like client keeps a playout buffer. It plays binary frames in order. When it receives mark, it remembers the id and sends mark{id} back when the audio received before the mark has finished playing. On clear it empties the buffer at once and forgets pending marks. This is what the reference client does, and what makes MarkPlayed mean "the caller heard it" and what lets the engine trim its LLM context to what was actually spoken.

Reference client

cargo run -p brisvox-transport --bin ws_client -- <URL> <INPUT.wav> <OUTPUT.wav> [options]

Streams a mono 16-bit WAV (for instance fixtures/en_two_utterances.wav, 16 kHz) as real-time 20 ms frames, answers marks, and writes the bot audio it received to OUTPUT.wav (the concatenation of what played; a clear removes the audio that had not played yet). After the input ends it waits until the server has been silent for --linger-ms (default 2000) (pending marks are echoed first, even if that takes longer), sends stop and waits for the server to close.

Options: --encoding pcm16|mulaw, --no-realtime (send as fast as the server takes it; the client keeps reading its socket between and during the sends, so a server that is already producing audio is not stalled by it), --linger-ms N, --dtmf KEYS (pressed right after started), --meta KEY=VALUE (repeatable). Exit codes: 0 success, 1 the call failed, 2 bad usage or an unreadable or unwritable file. The call failed (ClientReport::failure) when the server reported a fatal error, the connection broke (a read or write error, or the stream ended without a close frame) before the server stopped the call, the server closed the connection without stop before all input was sent, or max_duration passed. The recording is written in every case, and the error is printed. A server stop while input was still being sent (the bot hung up) is not a failure.

Compatibility

The version is the integer in start.version/started.version. A change that an old peer cannot ignore (a new required field, a changed meaning) bumps it. Adding message types and fields does not: peers ignore unknown fields, and an unknown type is non-fatal.

Brisvox agent protocol, version 1

Status: version 1, extended with reconnecting, routing and transfer through the carrier (every addition is optional and marked "optional extension"; the SDKs speak reconnecting too: an agent written against the first version 1 keeps working unchanged). Code: crates/brisvox-agent-link (protocol.rs is the executable definition of every message, link.rs the server side, tool.rs the remote tools, client.rs the reference agent in Rust). Engine inputs it uses: brisvox_engine::agent_input (bot lines, conversation edits) and brisvox_engine::events.

The conversation of a call runs in brisvox (audio, VAD, STT, turn taking, LLM, TTS, barge-ins, the tool engine). The agent process holds the application logic: it watches the call, speaks lines, edits the conversation, transfers or ends the call, and implements tools. It can be written in any language (Python SDK: sdk/python, see its README; TypeScript SDK: sdk/typescript) and runs out of the server's process, on the same host or elsewhere.

Transport and connection

  • The server connects out to the agent: one WebSocket connection per call to BRISVOX_AGENT_URL (ws:// or wss://; TLS with the server's built-in webpki roots, plus the private CA of BRISVOX_AGENT_TLS_CA if set). With BRISVOX_AGENT_TOKEN set, the handshake carries Authorization: Bearer <token>. The agent is the WebSocket server; it accepts many connections at once (one per live call).
  • The link carries transcripts, the LLM context and tool calls, so plaintext is refused off the private network. A ws:// URL is accepted only when its host is a loopback, RFC 1918 (10/8, 172.16/12, 192.168/16) or link-local (169.254/16) IPv4 address, an IPv6 ::1, unique-local (fc00::/7) or link-local (fe80::/10) address (an IPv4-mapped one counts by its IPv4 part; an IPv6 zone id such as fe80::1%25eth0 counts only on a link-local address, so a public address with a zone is still public), localhost or a name under .localhost, .internal (Railway, AWS and GCP private DNS) or .local (mDNS, Kubernetes *.cluster.local), or a single-label name without a dot (a Docker Compose or Kubernetes service name such as agent). A single-label name is accepted but logged once as a warning: the resolver's search list can turn agent into agent.example.com, so prefer the full .internal/.local name. The rule reads the URL text only; it does not resolve names. The server logs and reports the agent URL as scheme://host:port only (no userinfo, path or query). Any other host with ws:// fails the configuration at startup; use wss://, or set BRISVOX_AGENT_ALLOW_INSECURE=1 ([agent_link] allow_insecure = true, or allow_insecure = true on one route) when another layer protects the path (a VPN, a service mesh). Allowing it logs a warning at startup. wss:// to any host is always accepted.
  • Private CA. BRISVOX_AGENT_TLS_CA=/path/ca.pem (a PEM bundle, at most 16 certificates and 256 KiB, read at startup) adds those certificates to the webpki roots for every wss:// agent URL of the server, so an internal agent behind a private CA verifies like a public one. A missing, empty or malformed file fails the startup. SPKI pinning is not implemented.
  • Every message is one text frame holding one JSON object with a type field. Binary frames are not part of the protocol (answered with an error). Largest message: 64 KiB either way; the server closes a link that sends a larger one.
  • Unknown fields are ignored by both sides (additions within version 1 stay compatible). An unknown type from the agent is answered with unknown_type.
  • The server sends a WebSocket ping every 10 s; standard WebSocket stacks answer it by themselves. A link from which nothing (no message, no pong) arrived for 30 s is considered down.

Version negotiation and handshake

  1. The server connects (bound: connect_timeout, 2 s) and sends session.start, offering the versions it speaks in versions ([1] in this build).
  2. The agent answers within handshake_timeout (2 s, counted from the connection: the session.start write is part of it) with either
    • session.accept with the version it picked from versions, its tools, and optionally a system prompt, an initial context, a greeting and an event subscription; or
    • session.reject (code, message): the agent does not take this call. An agent that speaks none of the offered versions rejects with code unsupported_version.
  3. A version not in versions, any other first message, a late answer or a closed connection fail the handshake: the server logs it and the call runs without the agent (see "Failure and fallback").

The call's pipeline starts only after the handshake, so the agent's prompt, tools and greeting apply from the first word. The caller hears silence meanwhile: at most connect_timeout + handshake_timeout (4 s with the defaults) when the agent URL is unreachable, black-holed or slow, after which the call starts on the agent definition alone. handshake_timeout bounds the session.start write and the answer together, so no other bound adds to it.

{"seq":1,"t_ms":0,"type":"session.start","protocol":"brisvox.agent","versions":[1],
 "session_id":"42","agent":"luigi","capabilities":{"transfer":true,"tools":true}}

{"type":"session.accept","version":1,
 "system_prompt":"You are Luigi's host.",
 "context":[{"role":"system","text":"The caller is a regular."}],
 "greeting":"Hi, Luigi's here. How can I help?",
 "events":["user.text","turn.bot_ended"],
 "tools":[{"name":"book_table","description":"Books a table.",
           "parameters":{"type":"object","properties":{"time":{"type":"string"}},"required":["time"]},
           "kind":"side_effect","timeout_ms":4000}]}

session.accept fields:

fieldtypemeaning
versionintegerThe version picked. Required.
toolsarray of tool declarationsRemote tools (below). At most 64.
system_promptstringReplaces the agent definition's prompt (at most 64 KiB).
contextarray of {role, text}Messages the conversation starts with (at most 32; roles system, user, assistant).
greetingstringA line the bot speaks at once (a bot line, below).
resumebooleanOptional extension (reconnecting). true: the agent keeps the call's state after a lost connection and can take the call up again; the server then reconnects (see "Reconnecting"). Absent or false: the link is not reopened.
eventsarray of event typesOnly these events are sent (absent: all). session.*, tool.*, command.result, events.lost and error are always sent.

session.start.capabilities.transfer is true when the call's transport has a carrier binding (Twilio or Telnyx call control, docs/TELEPHONY.md) that can transfer it: transfer then goes through the same binding as the transfer_call tool, whether or not the agent definition names that tool. It is false for calls no carrier owns (generic websocket, RTVI, SIP) and in a build without the call-control feature.

session.start may also carry call, who is on the line, when the agent definition opted in (see "Caller identity" below); it is absent otherwise.

Problems in an accepted handshake that do not prevent the call (a refused tool, an unknown event type, an empty context message) are reported as error events with stage: "agent_link".

Server to agent

Every server message has an envelope: seq (1 for session.start, then one more for every message written on the link) and t_ms (milliseconds since the link opened, on the call's clock). The fields below are the body.

Events

typefieldsmeaning
turn.user_startedepochA user turn started (speech; also a confirmed barge-in).
turn.user_endedepochThe user turn ended; the engine answers it now.
user.transcripttext, finalA transcript segment from the STT (interim when final is false).
user.textepoch, textThe whole text of the user turn the LLM answers (spoken finals and typed text).
bot.llm_startedepochThe LLM started a response round (a tool loop has several).
bot.textepoch, textA streamed LLM delta. A bot line arrives as one delta.
bot.llm_stoppedepochThe round ended.
bot.tts_textepoch, textA chunk sent to the TTS.
bot.tts_stoppedepochThe TTS produced the response's last audio.
turn.bot_startedepochThe bot's audio of epoch started playing.
turn.bot_endedepochThe bot's audio of epoch was played to its end.
turn.interruptedepochA barge-in: the call moved to epoch; everything older was dropped.
turn.metricsepoch, metrics (end_of_turn_ms, stt_final_ms, llm_ttft_ms, tts_ttfb_ms, response_ms; absent when unknown)The turn's latencies when its first audio went out.
errorstage, message, fatalA stage of the call reported an error, or the link refused something without a command id (stage: "agent_link", the message starts with the error code).
events.lostcountEvents the agent did not get (see "Backpressure").
session.endreason (call_ended, hangup, ended_by_agent, ...)The last message; the server closes the link after it.

The epochs are the engine's turn numbers: a bot event of an epoch older than the newest turn.interrupted or turn.user_started belongs to a response that was cut.

Tool calls

typefieldsmeaning
tool.callid (integer), call_id, name, arguments (object, valid against the declared schema), kind, epoch, idempotency_key (16 hex digits), timeout_msRun the tool and answer with tool.result echoing id.
tool.cancelid, reason (interrupted, timeout, abandoned)The server stopped waiting: a later tool.result is ignored. Advice only for a side_effect tool.
command.resultid, ok, error ({code, message}, when not ok)The outcome of a command that carried an id.

Agent to server

Commands

Every command may carry id (string, at most 64 bytes; echoed in command.result, which is sent only when id is present) and re: the seq of the event the command reacts to (the server measures the hop latency with it, see docs/LATENCY.md).

typefieldsmeaning
saytext (at most 4 KiB), interrupt (default false)A bot line: the bot speaks text verbatim, without an LLM request.
endsay (optional farewell), reasonEnd the call: speak the farewell (a plain line), wait until the bot is silent, then end the call. Bounded by end_grace (30 s). command.result comes when the call is being ended.
transferdestination (E.164: + and 8 to 15 digits), mode (cold default, warm), summary, reason (each at most 512 bytes)Transfer through the carrier's call-control (Twilio/Telnyx REST, docs/TELEPHONY.md). Waits for the bot to finish its sentence first. unsupported without a call-control (session.start.capabilities.transfer), and for warm when warm transfers are not on for the agent ([telephony] warm, the callback listener and a binding that can do them: the same gate as the tool). After a successful cold transfer the caller is on the destination's line and the bot leaves the call (the link ends with session.end). The carrier's idempotency key is derived from the call and the command id: a repeated transfer with the same id (after a lost result or outcome_unknown) carries the same key, so a carrier that deduplicates (Telnyx command_id) ignores it. Always give a transfer an id: without one, each command is a new transfer.
context.updatesystem_prompt, append (at most 32 {role, text})Edit the conversation (all of the command or nothing).
tools.setenabled: array of tool names, or nullOffer only these tools (local and remote) to the LLM; null: all again.
tool.resultid (the tool.call's), result (any JSON) or error (string)The answer of a remote tool. A string result reaches the LLM as it is; any other JSON in compact form; error as a tool_failed error result.

How the commands act on the call

They go through the engine's own inputs; none of them touches the pipeline directly.

  • Bot lines (say, end.say, the greeting) go to the turn driver, the epoch authority. A line is a bot turn: when nobody speaks and no response runs, the driver starts a new epoch and the LLM runner speaks the text as that epoch's response (bot.llm_started, one bot.text, turn.bot_started ... turn.bot_ended). It enters the context as an assistant message and is cut to what was heard on a barge-in, like any response; the caller can barge in on it. A line waits (in order, at most 8) while the user speaks or a response runs. With interrupt: true the line drops the lines waiting before it and cuts a running response (never the user: while the user speaks every line waits) like a barge-in.
  • Conversation edits (context.update, tools.set, the initial context) are applied at the LLM runner's next safe point: right before the next response starts (a user turn's end, a bot line, a speculative start), never while a response runs, so tool calls always stay paired with their results. An edit made while the bot speaks shapes the next request. With a flow (docs/ARCHITECTURE.md, flows) the system prompt is the flow node's and the next transition replaces it again; tools.set then narrows the node's tools. A tool call to a tool that is not offered is answered unknown_tool without running.
  • end: the server stops the call gracefully (as at a drain), then sends session.end with reason ended_by_agent.

Remote tools

A tool declaration (session.accept.tools[]):

fieldtypemeaning
namestring1 to 64 of A-Z a-z 0-9 _ -; must not collide with a tool of the agent definition.
descriptionstringFor the LLM (at most 4 KiB kept).
parametersJSON Schema"type": "object"; compiled at the handshake (no remote $ref).
kindread_only / side_effectTOOLS.md items 7 and 8.
timeout_msintegerThe engine's wait for the result: default 5000, at most 30 000.
filler_after_msintegerA filler phrase after this long without a result: default 700; 0 disables.
confirmbooleanside_effect only, default false. Hold the tool for the user's confirmation (below, and docs/TOOLS.md, "Confirmation gate"). A confirm on a read_only tool refuses that declaration.
repeatablebooleanside_effect only, default false. By default the engine refuses an identical call (same tool, same arguments) from a later response of the same turn once the first one succeeded, answering already_done without sending a tool.call: a model that read the result and asks again is repeating itself (a second booking). repeatable: true lets such a repeat through, for a tool where doing the same thing twice is legitimate, such as pressing the same IVR digit twice (docs/TOOLS.md, "What "never twice" covers"). Two identical calls in one response, a new turn and a retry of a failed call always run. A repeatable on a read_only tool refuses that declaration, like confirm: a read-only tool may always repeat, so the flag would mean nothing. An agent written before the field existed declares no repeatable and keeps the default.

A remote tool is an ordinary tool of the call's tool engine (docs/TOOLS.md) whose execution is an RPC: the engine validates the arguments, applies the retry budget, the idempotency store, the filler policy and the result bounds, then the link sends tool.call and waits for tool.result.

  • read_only: the engine stops waiting at timeout_ms or when the caller barges in; the link then sends tool.cancel (abandoned or interrupted) and ignores a late result. The LLM gets a timeout / interrupted error result. May run during a speculative response.
  • side_effect: never speculative, never run twice for one call id (idempotency_key is stable for that tool call, not across phone calls or turns: pass it to APIs that deduplicate a redelivery, and use a key of your own, such as the order id, for "once ever" rules; docs/TOOLS.md, "What "never twice" covers"). At timeout_ms the engine answers the LLM with a timeout error but the call keeps waiting: the link waits up to side_effect_wait (60 s, under the engine's 120 s backstop) and a result that arrives then reaches the conversation as a late note. After that wait, or when the link goes down, the outcome is recorded as unknown (the outcome is unknown), never as a failure, and the call is not repeated.
  • At most 32 calls wait at once per link; more fail at once (tool_failed).
  • confirm: true (a prompt-injection guard): when the model calls the tool, the server does not send tool.call. It records a pending confirmation and gives the model a confirmation_required result; the tool is offered to the agent as a tool.call only when the model calls the built-in confirm_pending tool (added to the model's tool list automatically) after the user's next reply. The agent sees one tool.call, with the arguments of the original request, and never sees a held request that was not confirmed. The rules (a later user turn, expiry, barge-in clears, once only) are in docs/TOOLS.md.

Ordering

  • The server writes messages in one task: seq order is write order. Events keep the engine's order (the order the stages handled them, which follows the pipeline's causal order). Control messages (tool.call, tool.cancel, command.result, error) are written before queued events, so a tool call is never stuck behind a burst of text deltas.
  • The agent's messages are applied in arrival order by one task; command.results come back in that order, except for end and transfer, which run on a separate task (one at a time) and answer when done.
  • A tool.result may come at any time, in any order.

Backpressure and bounds

queueboundwhen full
events to the agent256the newest event is dropped and counted; the next event that fits is preceded by events.lost with the count
engine event ring1024 (shared with the RTVI bridge)the oldest are lost, reported the same way
control messages to the agent64a tool call fails at once; a command.result is dropped (counted)
bot lines into the call16 queued + 8 held by the turn driverqueue_full
conversation edits into the call64queue_full (nothing of the command is applied)
end / transfer4queue_full
remote calls waiting32tool_failed at once

The server never waits on the agent: every write is bounded by write_timeout (2 s; a slower write takes the link down) and every command is queued without waiting. A slow agent loses events, never stalls the call. Use session.accept.events to receive only what you need (for example without bot.text and bot.tts_text deltas).

Error codes

codemeaning
bad_messageNot JSON, a missing field, a field of the wrong type.
unknown_typeA type this version does not know.
invalid_argumentA value breaks a rule (an empty or too long text, a bad phone number).
too_largeA count bound was hit (messages of one context.update, tool names).
queue_fullThe call's input queue is full: retry later.
unsupportedThe call cannot do this (no call-control for transfer).
call_endedThe call is over.
not_readyA handshake message after the handshake.
rejectedThe carrier refused the transfer; nothing changed.
unavailableThe carrier could not be reached; nothing changed.
outcome_unknownThe transfer may or may not have started (a timeout): do not repeat it blindly.
unsupported_versionFor session.reject: the agent speaks none of the offered versions.
unknown_sessionFor session.reject of a resumption: the agent does not know the call (it restarted, or the parked call expired).

Reconnecting

Optional extension. An agent that declares "resume": true in session.accept promises to keep the call's state (keyed by session_id) when its connection is lost, for a while, and to take the call up again on a new connection. For such an agent a lost link (the agent or the network closes the socket, a write times out, 30 s of silence) is reopened instead of being given up:

  1. The call goes on at once as in "Failure and fallback": remote tool calls waiting end (read_only: tool_failed; side_effect: outcome unknown, never repeated), the LLM is offered the agent definition's tools only, events are not delivered, commands cannot arrive.
  2. The server waits (backoff: doubling from 100 ms up to 2 s, AgentLinkConfig::reconnect_backoff, scaled by a stable random factor of 0.5 to 1 so the calls of a crashed agent do not return in the same instant) and opens a new connection to the same URL, with the same token.
  3. It sends session.start again, carrying resume:
{"seq":57,"t_ms":41200,"type":"session.start","protocol":"brisvox.agent","versions":[1],
 "session_id":"42","agent":"luigi","capabilities":{"transfer":true,"tools":true},
 "resume":{"attempt":1,"previous_seq":55,"down_ms":120}}

seq and t_ms continue the call's count (previous_seq is the last message written on the lost connection), so a seq is never repeated within a call. attempt counts the attempts of this outage; down_ms is the time since the loss. 4. The agent answers within the handshake timeout with session.accept (version; optionally events to re-select the subscription, and "resume": true, required) or session.reject. An accept of a resumption without "resume": true (an agent that restarted and kept no state) ends reconnecting like a reject. In the accept of a resumption tools, system_prompt, context and greeting are ignored: the call keeps what it has (the remote tools declared first stay registered; a restarted agent must declare the same tools). A session.reject (use the code unknown_session for an agent that does not know the call), an accept without resume: true or a protocol error ends reconnecting: the call goes on without the agent. 5. On success the link is up again: the remote tools are offered to the LLM again (a tools.set made before the loss is not restored: send it again), events flow, and the first event is preceded by events.lost with the number of events missed meanwhile. Ids of tool.call never repeat within a call, so a late tool.result for a call that was failed at the loss is simply ignored.

Bounds: at most BRISVOX_AGENT_RECONNECT_ATTEMPTS attempts (default 5; 0 turns reconnecting off) in one series, no new attempt more than BRISVOX_AGENT_RECONNECT_WINDOW_MS (default 15 s) after the loss, each attempt bounded by the connect and handshake timeouts. A connection that lived 5 s starts a new series. When the attempts or the window run out, the fallback below is final for the call. An end command stops reconnecting at once (an agent leaves right after end; the farewell is still spoken). What the server had queued for the lost connection (tool calls, command results, events) is dropped, never delivered late. Commands that were in flight at the loss may or may not have been applied; give a transfer an id and repeat it with the same id if its result was lost (the carrier key is stable per id).

In the SDKs (sdk/python Agent(resume=True), sdk/typescript new Agent({ resume: true }), both opt-in and off by default): the SDK declares resume in session.accept and, when a link is lost, parks the call's session instead of ending it. The session keeps its data, its queued events and its place in the handlers' order; commands fail with the SDK's closed-session error while it is parked, and running tools hear link_closed as before. A session.start with resume for a parked session_id is answered with session.accept (version, the first accept's events, "resume": true; no tools, prompt, context or greeting) and the session moves onto the new link: no second on_session_start / onSessionStart, no greeting, seq continuing. The handlers then see an SDK-made session.resumed event (on_resumed / onResumed: attempt, previous_seq, seen_seq (the last seq the SDK itself read on the lost link), down_ms; it is not on the wire and not subscribable), after the events queued before the loss, followed by the server's events.lost. A parked session lives for the resume window (default 15 s, the server's own BRISVOX_AGENT_RECONNECT_WINDOW_MS); then the handlers get one session.end (link_closed), the session is forgotten and a later resumption is answered with session.reject (unknown_session). A session.end from the server is final at once and is never parked. A session.start with resume for a call whose old link the agent still holds (half-open TCP) takes the session over and closes the old link.

Routing

Optional extension. The agent process of a call is picked in this order:

  1. The route the call's start message names: the metadata key agent_route of a generic websocket start message, the TwiML <Parameter name="agent_route" .../> of a Twilio stream. The name must be one of the definition's [[agent_link.routes]] (docs/CONFIG.md); a start message never carries a URL, so a caller cannot make the server connect to an address of their choosing. An unknown route falls back to the next rule (and is logged).
  2. The definition's [agent_link] url.
  3. The server-wide BRISVOX_AGENT_URL.

Each route has its own token_env; the default token (BRISVOX_AGENT_TOKEN, or the definition's token_env) is only sent to the default process. With routes configured, a call waits at most 1 s for its start message before it links (a carrier sends it at once).

Caller identity

An addition within version 1, off by default. A telephony call can tell the agent process who is on the line: session.start then carries a call object (and so does the session.start of a resumption). Without the opt-in the message is byte for byte what it was before.

{"seq":1,"t_ms":0,"type":"session.start","protocol":"brisvox.agent","versions":[1],
 "session_id":"42","agent":"luigi","capabilities":{"transfer":true,"tools":true},
 "call":{"carrier":"twilio","carrier_call_id":"CA0123456789abcdef0123456789abcdef",
         "from":"+15551230001","to":"+15551230002"}}
fieldtypemeaning
carrierstringtwilio, telnyx or sip.
carrier_call_idstringThe carrier's id of the call: the Twilio CallSid, the Telnyx call_control_id, the SIP Call-ID. It is the id the carrier's own API and your carrier logs use.
fromstringThe caller's number. Absent when the carrier did not say, and when the numbers are withheld (below).
tostringThe called number, with the same absence rules.
pii_redactedbooleanPresent and true when from and to were withheld because the server redacts phone numbers. Absent means false.

Where each value comes from:

  • Twilio. The media stream's start message carries the CallSid but no numbers. from and to are the stream parameters named from and to (From and To also work), so the TwiML that opens the stream passes them: <Parameter name="from" value="..."/> and <Parameter name="to" value="..."/>, with the values taken from the voice webhook's From and To (docs/TELEPHONY.md). Without those parameters only carrier_call_id is sent.
  • Telnyx. The stream's start message carries from, to and the call_control_id.
  • SIP. The user parts of the INVITE's From and To headers (+15551230001 in "Ann" <sip:+15551230001@trunk.example>; a tel: URI works too; a URI without a user part gives no number) and the Call-ID. On an outbound call from is the number the call is placed from and to the callee. The SIP headers are the caller's own, unauthenticated text, and the user part of a SIP URI is free text. The server therefore passes a user part on only when it is made of 0-9 A-Z a-z + * # . _ - (at most 64 characters) and drops the field otherwise. Even so it is data from outside: an agent must never put from or to unquoted into an LLM prompt (a user part such as ann is a name the caller chose, not a fact), and should treat them as an untrusted lookup key.
  • No carrier id, no identity. carrier_call_id is always the carrier's own id. When the carrier's start message did not give one (a Twilio start without CallSid, say), call is absent; the server does not substitute its own stream or session id.
  • The generic websocket, RTVI and WebRTC have no carrier: call is absent even when the definition opted in.

The opt-in is the definition's [agent_link] caller_identity = true (docs/CONFIG.md), or BRISVOX_AGENT_CALLER_IDENTITY=1 for the default agent process of the server; the file wins when both are set. A named route has its own caller_identity and never inherits the section's or the environment's, since a route may be another team's agent. With the opt-in on, a call waits up to 1 s for its carrier start message before it links (as with routes); a carrier sends it at once. This includes a generic websocket call when the default process is opted in: the wait is for the client's start message (call stays absent, since a websocket has no carrier), so a client that connects and stays silent is linked after 1 s with no identity, not at once.

The numbers are personal data (docs/PRIVACY.md). They are withheld, and pii_redacted says so, while the server redacts phone numbers anywhere: recording with redaction on and the phone kind (or the number kind, for long digit runs, which counts as covering phone numbers) selected (the default whenever BRISVOX_RECORDING_DIR is set), or BRISVOX_LOG_REDACT_PII=true with the phone or number kind. carrier and carrier_call_id are still sent. Keep the numbers out of your own logs, and remember that a caller ID is what the carrier reports: it can be spoofed, so it is a hint for the caller, not a proof of identity.

Failure and fallback

The agent link never fails the call. The configured local behaviour is the agent definition (docs/CONFIG.md): its prompt, its local tools, its providers.

  • Connection refused, handshake timeout, session.reject, an unsupported version, a bad first message: the call runs on the agent definition alone (logged as a warning).
  • The link fails during the call (the agent closes or crashes, a write times out, 30 s of silence) and the agent did not declare resume (or the attempts are used up, see "Reconnecting"): the call goes on. Waiting remote calls end at once (read_only: tool_failed; side_effect: outcome unknown); events and commands stop. The call goes back to its local tools: from the next response on, the LLM is offered the agent definition's tools only (the remote tools, which would all fail now, and any tools.set of the agent are undone). The agent's system prompt and the conversation edits already applied stay: they are the conversation the caller had. The LLM keeps answering with that context. The link is not reopened (unless the agent declared resume).
  • An end received before the failure still completes: the farewell is spoken and the call ends when it was played (the agent may close its socket right after end).
  • The call ends (hang-up, drain, shutdown): the server sends session.end and closes the link (bounded by close_timeout, 1 s).

Server configuration

variabledefaultmeaning
BRISVOX_AGENT_URLunset (no agent)The agent's ws:// / wss:// URL; every call links to it.
BRISVOX_AGENT_TOKENunsetSent as Authorization: Bearer <token>. Never logged.
BRISVOX_AGENT_ALLOW_INSECUREoff1 accepts ws:// to a host outside the private networks (see "Transport and connection"). Logs a warning.
BRISVOX_AGENT_CALLER_IDENTITYoff1 sends the caller identity of telephony calls to the default agent process (see "Caller identity"). The definition's caller_identity wins; routes never inherit it.
BRISVOX_AGENT_TLS_CAunsetPEM bundle of a private CA, trusted besides the webpki roots for wss:// agent URLs.
BRISVOX_AGENT_CONNECT_TIMEOUT_MS2000Bound on the connection and WebSocket handshake.
BRISVOX_AGENT_HANDSHAKE_TIMEOUT_MS2000Bound on the agent's session.accept.
BRISVOX_AGENT_RECONNECT_ATTEMPTS5Reconnection attempts per series for an agent that declared resume (0: never reconnect).
BRISVOX_AGENT_RECONNECT_WINDOW_MS15000No new attempt starts later than this after the loss.

The definition's [agent_link] section (url, token_env, allow_insecure, caller_identity, [[routes]], docs/CONFIG.md) refines the URL and token per agent definition and per route (see "Routing").

The other bounds (AgentLinkConfig: queues, keepalive, write and close timeouts, tool timeout bounds, side_effect_wait, ops_timeout, end_grace) are library settings.

A minimal agent

The reference agent (brisvox_agent_link::client) in a few lines: accept the session, answer every user turn with a line, and implement one tool.

#![allow(unused)]
fn main() {
let server = AgentServer::bind("127.0.0.1:8765".parse()?, SystemClock::shared()).await?;
let mut session = server.accept().await?;
session.accept_session(SessionAccept { version: 1, tools: vec![lookup()], ..Default::default() }).await?;
while let Some(message) = session.recv().await? {
    match message.body {
        ServerBody::UserText { text, .. } if text.contains("hours") => {
            session.send(&AgentMessage::Say { id: None, re: Some(message.seq),
                text: "We open at noon.".into(), interrupt: false }).await?;
        }
        ServerBody::ToolCall { id, arguments, .. } => {
            session.send(&AgentMessage::ToolResult { id, result: Some(lookup_run(arguments)), error: None }).await?;
        }
        _ => {}
    }
}
}

Known limits (follow-ups)

  • A resumed link does not replay what the agent missed (only events.lost says how much); an agent that needs the conversation after a restart reads it from its own store or asks for it with context.update.
  • The SDKs keep a parked call's state in the agent's own process memory: an agent that restarts answers a resumption with unknown_session, and a load balancer in front of several agent processes must route a resumption to the process that holds the call. The Rust reference agent (brisvox_agent_link::client) shows the handshake.
  • transfer needs a call-control binding on the call (TelephonyOps, such as brisvox_transport::call_control::BoundCall): the server binary passes the call's own binding (Twilio, Telnyx; build with call-control) and reports it in capabilities.transfer.
  • The agent cannot cancel a running LLM response without speaking (an interrupting say always speaks); a plain "stop talking" command is a candidate for version 1 additions.

RTVI: connecting a Pipecat client to brisvox

brisvox_transport::rtvi (feature rtvi, on by default) lets the Pipecat client SDKs talk to a brisvox server: @pipecat-ai/client-js with @pipecat-ai/websocket-transport. Protocol facts, sources and the list of unverified points are in docs/providers/rtvi.md (RTVI 2.1.0, fetched and fact-checked 2026-10-08). The module documentation (crates/brisvox-transport/src/rtvi/mod.rs) is the reference for behavior; this page is how to use it and how to check it by hand.

Not the same listener. The WebSocket fallback of the WebRTC signaling port (docs/DEPLOY.md) carries the generic protocol of docs/WEBSOCKET_PROTOCOL.md, not RTVI. A Pipecat client SDK keeps using BRISVOX_RTVI_ADDR, which is already one HTTP(S) port and works on one-port hosts (such as Railway) without further steps.

Server side

#![allow(unused)]
fn main() {
let mut server = RtviServer::bind(ServerConfig::default(), RtviConfig::default(),
                                  SystemClock::shared(), &cancel).await?;
while let Some(call) = server.accept_call(&cancel).await? {
    // call.link   -> TransportSession::open(clock, call.link, SessionConfig::default(), &cancel)
    // call.sender -> RtviSender: send(RtviEvent::..), respond(id, t, d), respond_error(id, msg)
    // call.inbox  -> RtviInbox: client-message / send-text / llm-function-call-result requests
}
}
  • One WebSocket path (RtviConfig::path, default /rtvi); the client accepts any path.
  • Audio: 16 kHz in (the JS client's recorderSampleRate default), 24 kHz out (its playerSampleRate default). The JS client ignores the sample_rate of bot audio and plays at its own rate, so RtviConfig::output_rate must equal the client's playerSampleRate. The engine reads both rates from CallInfo::sample_rate() (input_sample_rate / output_sample_rate metadata).
  • Transport::accept also works and gives only the CallLink (no RTVI events, client requests are answered with errors).
  • Terminate TLS in front of the listener (wss://). The protocol has no authentication of its own; the upgrade checks are RtviConfig::sessions (short-lived single-use session tokens, BRISVOX_SESSION_SECRET, the production way), RtviConfig::token (a shared static token, development) and allowed_origins. The server binary refuses to bind a non-loopback RTVI address with neither a session secret nor a token (BRISVOX_INSECURE_LISTENERS=1 overrides; docs/SECURITY.md).
  • The token travels in ?token= (what the Pipecat client SDKs append; it lands in proxy logs and browser history), in Authorization: Bearer <token>, or in the WebSocket subprotocol brisvox-token.<token>: a browser page offers ["brisvox", "brisvox-token." + token] and the server selects brisvox. A missing token is HTTP 401, an invalid, expired, reused or wrong-audience one 403, an oversize one (over 4 KiB) 413. Every refusal and the accepted upgrade carry Cache-Control: no-store. A session token's claims reach the call's metadata (session_id, session.agent, session.meta.<key>); max_s is reported, not enforced, here.

With the engine

brisvox_engine::rtvi::RtviBridge connects a call's RtviSender and RtviInbox to the engine:

#![allow(unused)]
fn main() {
let config = RtviConfig::default(); // the bridge switches the adapter's speaking events off per call
// ... accept_call as above, then for each call:
let (builder, bridge) = RtviBridge::attach(builder, call.sender, call.inbox,
                                           RtviBridgeConfig::default(), Some(handler), clock.clone(), &cancel);
let session = TransportSession::open(clock, call.link, SessionConfig::default(), &cancel).await?;
let call = run_call(session, builder, &cancel).await?; // keep `bridge` alive as long as the call
}
  • The client gets user-started/stopped-speaking (the user's turn), user-transcription, user-llm-text, bot-llm-started/text/stopped, bot-tts-started/text/stopped, bot-output, bot-started/stopped-speaking (the transport's playout), bot-interrupted, metrics (one per turn) and error, all on one queue. The mapping table is in the module docs (crates/brisvox-engine/src/rtvi.rs); the events come from brisvox_engine::events. Every user-started-speaking gets its user-stopped-speaking, also for a turn that ends without words (noise, an empty STT final), a turn superseded while its end waited for a final, and the hang-up.
  • send-text becomes a user turn (brisvox_engine::text_input): it interrupts the bot if it is thinking or speaking, the runner appends the text to the context as a user message and answers it; while the user is speaking it joins that turn; run_immediately: false holds it for the next turn (Pipecat appends it to the context at once instead). A typed turn sends no user-started/stopped-speaking (Pipecat neither), only bot-interrupted if the bot was busy and the user-llm-text. client-message goes to the ClientMessageHandler (server-response with its answer, error-response on its error, its timeout or without a handler); up to 8 replies run at once beside the inbox, so a slow handler never delays a send-text barge-in.
  • With RtviBridgeConfig::bot_speaking_events on (the default), attach switches the adapter's own speaking events off for the call (RtviSender::set_auto_events(false)), so the client never gets them twice whatever RtviConfig::auto_events says. With it off the adapter's derivation stays as configured.
  • RtviBridgeConfig::event_capacity is clamped to MAX_EVENT_CAPACITY (65 536) and text_capacity to MAX_TEXT_CAPACITY (256).

Run the demo bot (no provider keys, energy-based speech detection, answers with a beep):

cargo run -p brisvox-transport --example rtvi_server -- 127.0.0.1:8765 /rtvi

Client side (@pipecat-ai/client-js 1.13.x, @pipecat-ai/websocket-transport 1.7.x)

import { PipecatClient, RTVIEvent } from "@pipecat-ai/client-js";
import {
  WebSocketTransport,
  ProtobufFrameSerializer,
} from "@pipecat-ai/websocket-transport";

const client = new PipecatClient({
  transport: new WebSocketTransport({
    serializer: new ProtobufFrameSerializer(), // the default
    recorderSampleRate: 16000,                 // must match what the server expects as input
    playerSampleRate: 24000,                   // must equal RtviConfig::output_rate
  }),
  enableMic: true,
  callbacks: {
    onBotReady: () => console.log("bot ready"),
    onUserTranscript: (t) => console.log("user:", t.text, t.final),
    onBotOutput: (o) => console.log("bot:", o.text),
    onBotStartedSpeaking: () => console.log("bot speaking"),
    onError: (e) => console.error(e),
  },
});

// Direct connection: the connection parameters are exactly { wsUrl } and optionally { token }.
await client.connect({ wsUrl: "ws://127.0.0.1:8765/rtvi" });
// With RtviConfig::token or ::sessions set: { wsUrl: "wss://host/rtvi", token: "..." }
// (sent as ?token=). A session token is minted by your backend per call and is single use.

// Application messages (answered by RtviRequest::ClientMessage on the server):
const reply = await client.sendClientRequest("ping", { n: 1 });
client.sendText("hello");

await client.disconnect(); // closes the socket; the server sees a hang-up

The snippet is a sketch, not a tested app. The names ProtobufFrameSerializer, recorderSampleRate, playerSampleRate, connect({ wsUrl }), sendClientRequest, sendText and the callbacks come from the sources recorded in docs/providers/rtvi.md; the exact shape of the WebSocketTransport constructor options and of the PipecatClient options (enableMic, callbacks) was not fetched (UNVERIFIED), so check them against the types of the installed package versions.

Any other key in the connection parameters makes the client throw Unrecognized connection parameter. If the app starts the bot through an HTTP endpoint (startBotAndConnect({ endpoint })), that endpoint has to return { wsUrl } for the URL above; the response shape is not verified (docs/providers/rtvi.md, UNVERIFIED 8).

What the tests prove

crates/brisvox-transport/tests/rtvi.rs plays the JS client with a scripted browser that has its own protobuf codec, so it checks the bytes of docs/providers/rtvi.md and not just a round trip:

  • every WebSocket message is one binary frame with one pipecat.Frame; RTVI JSON is the string in MessageFrame.data (field 4, field 1); audio is AudioRawFrame (field 2; audio 3, sample_rate 4, num_channels 5); the server never sends a text frame or InterruptionFrame (field 5);
  • client-ready with the exact RTVIMessage.clientReady() shape is answered with bot-ready echoing the id, version 2.1.0; version warnings follow Pipecat; the call's metadata carries both sample rates;
  • 16 kHz client audio reaches the engine as PCM16; 24 kHz bot audio reaches the client as exactly one 960-byte (20 ms) message per engine frame; a frame larger than max_audio_bytes is split into whole PCM16 samples (448 bytes per message at a limit of 512) that reassemble to the original; bot audio at the wrong rate is dropped and reported to the engine exactly once per call (three bad frames, one error);
  • every RtviEvent has the JSON of the doc, server events carry no id and payload-less events no data;
  • barge-in: bot-interrupted and bot-stopped-speaking are sent, no audio of the old epoch (a distinct fill byte) reaches the client after bot-interrupted, stale audio sent after the clear never goes out, marks are reported as cut and the engine is told the lead is zero again;
  • a control message above max_control_bytes (a 4 KiB client-message against a 1 KiB limit, so the websocket layer lets it through) closes the call with 1009, before and after client-ready;
  • the token check rejects near misses (a prefix, the secret plus a character, plus a NUL, another case) with 403 and an undecodable value (%zz, a truncated or signed escape) with 401;
  • the writer drops audio of an epoch older than the last clear on its own, even when it arrives behind WireOut::Clear (a crate-internal test, because the session layer normally drops it first), and the playout lead reported to the engine is positive after audio is sent, never above what was sent, and runs down to zero;
  • two calls side by side stay apart: neither browser receives the other call's messages, audio or speaking events, and caller audio reaches only its own engine;
  • text frames are rejected (1003 before the handshake, a fault after), bad JSON, a missing id, a foreign label and unsupported types are answered as Pipecat answers them;
  • client-message / send-text / llm-function-call-result reach the inbox, dtmf becomes DTMF events, disconnect-bot a hang-up; token, origin, path, call limit, timeouts and shutdown (1001 also for a browser that has not sent client-ready yet);
  • the bounds: a client that stops reading ends the call after write_timeout, RtviSender::send times out on a full queue, a full inbox answers a client-message with "server busy" (and drops send-text silently), the reply queue keeps exactly its 16 replies and drops the rest, an engine that takes no calls or no input is told 1013, an oversized audio frame closes with 1009, a flood before client-ready closes with 1008, pings are sent and a quiet client that answers them stays connected.

Manual check with a real JS client (cannot run in CI)

The automated tests play the client with a scripted browser written from the same document as the server (docs/providers/rtvi.md), so they cannot catch a misreading of that document. The check below is still pending and needs a person with a browser and Node. Versions: @pipecat-ai/client-js 1.13.x and @pipecat-ai/websocket-transport 1.7.x (the versions the protocol notes were taken from); record the versions actually installed.

  1. Start the server: cargo run -p brisvox-transport --example rtvi_server -- 127.0.0.1:8765 /rtvi (it logs rtvi call started / rtvi call ended).
  2. In an empty directory: npm init -y && npm i @pipecat-ai/client-js @pipecat-ai/websocket-transport vite, put the TypeScript above in main.ts with wsUrl: "ws://127.0.0.1:8765/rtvi", recorderSampleRate: 16000, playerSampleRate: 24000, run npx vite and open http://localhost:5173 (a microphone needs a secure context; localhost counts). Open the browser console.
  3. Expected, in this order:
    • connect() resolves and onBotReady fires (the bot-ready answer carries version 2.1.0); no Unrecognized connection parameter and no console warning about the protocol version;
    • speaking into the microphone logs onUserStartedSpeaking / onUserStoppedSpeaking;
    • the page plays the server's two-tone beep at the right pitch, logs onBotStartedSpeaking when it begins and onBotStoppedSpeaking about when it ends, and logs a bot-output text;
    • await client.sendClientRequest("ping", { n: 1 }) resolves with the same payload;
    • client.sendText("hello") produces no error;
    • client.disconnect() ends the call; the server logs rtvi call ended.
  4. If the beep is pitched wrong or the page plays chipmunk audio, playerSampleRate does not match RtviConfig::output_rate. If the server logs a mismatched-input-rate error, recorderSampleRate does not match RtviConfig::input_rate.
  5. Record the answers to the open points (each is UNVERIFIED in docs/providers/rtvi.md until then), in docs/providers/rtvi.md with the date and package versions:
    • does the client send a Sec-WebSocket-Protocol header (the server accepts none and would answer without a subprotocol)? Look at the upgrade request in the browser's network tab;
    • does the client survive a protobuf field it does not know (the server only sends fields 2 and 4, so this is expected to be a non-issue; note any Unknown frame kind);
    • barge-in: speak while the bot beeps and note whether the already buffered beep stops (WavStreamPlayer.interrupt()) or plays out; the server sends bot-interrupted;
    • the default timeout of sendClientRequest (call it with a client-message type the example does not answer and time the rejection). Count the check as done only when steps 3 and 5 are recorded; any deviation becomes a fix.

The points that only a real client run can settle are listed in docs/providers/rtvi.md (UNVERIFIED): whether the client demands a subprotocol, what it does with a protobuf field it does not know, whether WavStreamPlayer.interrupt() drops buffered audio on barge-in, and the default timeout of sendClientRequest.

Known gaps

  • Ordering: RTVI messages from the RtviSender are ordered among themselves only. They are not ordered against bot audio or the auto-derived bot-started-speaking / bot-stopped-speaking (separate queues, RTVI messages first), so an event can overtake the audio queued before it. Turn auto_events off and send the speaking events yourself if the UI needs a strict order (the engine's RtviBridge does exactly that).

  • With the engine bridge: send-text with audio_response: false is answered with audio anyway (counted as text_only_requests), and the llm-function-call-* events are not sent (the engine runs its tools itself; a client's llm-function-call-result is counted as unsupported).

  • Input rate: client audio at a rate other than input_rate is passed on at its own rate (no resampling); the first such frame is reported once as an engine error. Set the client's recorderSampleRate to the configured input rate.

  • TransportEvent has no variant for client-message, send-text and llm-function-call-result (adding one would break every exhaustive match in the engine), so they travel on RtviInbox. A follow-up can fold them into the core's event enum.

  • RTVI has no playback acknowledgement and no way to flush audio the browser already holds: marks and the lead are estimates (rtvi::playout), and a barge-in cannot cut the client's buffer. The core's real-time pacing keeps the tail short.

  • No resampling: the bot's audio rate must equal output_rate.

  • raw-audio / raw-audio-batch JSON messages (Python serializer carrier, unused by the JS client) are answered with Unsupported type; ui-* messages are ignored; bot-transcription (deprecated) is not sent.

Providers

Rule: facts here and in docs/providers/*.md come from documentation fetched during development (URL + date). Never from memory.

Priority

  1. v1: Deepgram (STT), OpenAI (LLM), Cartesia (TTS)
  2. Next: Anthropic, Gemini, OpenAI-compatible (Groq/Together/Fireworks/vLLM/Ollama), ElevenLabs
  3. Backlog: further STT, TTS and LLM providers (the lists below and docs/providers/)

Per-provider checklist

  • streaming protocol and auth · keepalive · finalize/flush semantics · cancel semantics
  • word/char timestamps (TTS) · interim results (STT) · tool-call delta format (LLM)
  • rate/concurrency limits · regions/endpoints · Turkish support · pricing page link

Template: docs/providers/_TEMPLATE.md.

Media formats: G.711 μ-law/A-law (telephony, Twilio/Telnyx) — docs/providers/g711.md; codec in brisvox_audio::codec.

Dialing (happy eyeballs)

tokio_tungstenite::connect_async* resolves the host and tries the addresses one after the other, so an IPv6-first host behind a route that silently drops packets costs the whole handshake deadline (found by the Cartesia live run: api.cartesia.ai has 8 IPv6 + 4 IPv4 addresses). Every WebSocket connect therefore goes through brisvox_providers::net::connect_ws (feature ws; deepgram, deepgram-aura, elevenlabs, cartesia, assemblyai, gladia, gemini-live, openai-realtime, sherpa-onnx, openai-realtime-stt and the agent link):

  • net::dial(host, port, clock) resolves and races the connects (RFC 8305): families alternate starting with the family of the first answer, a new attempt starts 250 ms after the previous one or at once when it failed, at most 8 addresses, the first connected socket wins and the rest are dropped; Nagle is off. The delay comes from the injected Clock.
  • net::connect_ws(request, ws_config, connector, clock) dials, then runs TLS and the WebSocket handshake (client_async_tls_with_config) over that socket and returns the same (WebSocketStream<MaybeTlsStream<TcpStream>>, Response) / tungstenite::Error as connect_async_tls_with_config, so each adapter's error mapping is unchanged.
  • There is no deadline inside: each adapter wraps the call in its own timeout on the injected clock (name lookup, dial, TLS and handshake share it). Dropping the future cancels every attempt. localhost mock servers keep working (::1 is refused at once, 127.0.0.1 wins).
  • HTTP/SSE adapters use reqwest 0.13.5, whose hyper-util 0.1.21 HttpConnector races the two address families by default (happy_eyeballs_timeout 300 ms); the shared client sets no local_address (that would disable it), so no change was needed.

Anthropic LLM adapter

Code: brisvox_providers::anthropic (feature provider-anthropic), facts in docs/providers/anthropic.md. POST /v1/messages with stream: true, stateless full-history replay. Defaults for a voice loop: model claude-haiku-5-5, thinking: disabled, max_tokens 1024, cache_control on the last tool, the last system block and the last stable history turn (5-minute TTL). Thinking blocks round-trip through Message::provider_data; usage reports input = input + cache_creation + cache_read and cached = cache_read. Not retried by the adapter; ping events never count as activity; warm() is an unauthenticated HEAD (a max_tokens: 0 cache warm is unverified with streaming and is not done). A late System message is folded into the next user turn by default; mid_conversation_system (opt-in, model dependent, wire shape unverified) sends it as a role:"system" message when the docs allow the position. An open thinking block is allowed first_output_timeout of silence (display omitted streams nothing until the signature). xhigh/max effort with thinking disabled or between_tools is refused at construction. Live check: tests/anthropic/live.rs (BRISVOX_LIVE_TESTS=1 + ANTHROPIC_API_KEY).

Deepgram Aura TTS adapter

Code: brisvox_providers::deepgram_aura (feature provider-deepgram-aura), facts in docs/providers/deepgram-aura.md (fetched 2026-10-08). One WebSocket per session to wss://api.deepgram.com/v1/speak?model=<TtsConfig.voice>&encoding=linear16&sample_rate=<rate>, Authorization: Token <key>. Aura has one text buffer and one unlabelled audio stream per connection, so brisvox contexts are served strictly one at a time (a FIFO; the next context's text goes out after the previous Flushed). Summary of the choices (details in the module docs):

  • No words. /v1/speak has no timestamps: the adapter never emits TtsEvent::Word; played-text tracking falls back to its char-rate estimate (barge-in truncation is coarse). The contract suite is told so by TtsHarness::emits_words() == false.
  • No Turkish. Aura-2 covers en/es/de/fr/nl/it/ja only (2026-10-08). A Turkish voice (aura-2-..-tr) is refused at connect, DeepgramAuraConfig::language = "tr" at new (Error::Protocol). Use Cartesia for Turkish.
  • Cancel is local first. Clear is the only cancel and Cleared comes after the remaining audio: Cancelled is emitted at once, audio and Flushed are dropped until Cleared, and the next context is held until then (a lost Cleared is given up on after io_timeout).
  • Flush once per turn (FlushPolicy::PerTurn, default; Deepgram allows 20 per 60 s and warns about quality). PerText flushes after every text for servers that do not auto-generate and so that a silent server is a stall from the first text; it keeps 4 flushes in reserve. The 20/60 s flush and 2400 chars/min budgets are tracked client-side (sliding windows on the injected clock); a send over budget waits, it is never dropped. When more than 1800 characters of one context would sit between two flushes, a flush is made before the next Speak (the 2000-character limit may be per flush; 1009 would end the call). Under PerTurn the silence of a server that holds unflushed text is bounded by a liveness Ping (half an io_timeout after the last sign of life; a Pong restarts the deadline). Whether Aura generates without a Flush (what PerTurn bets on) is open item 3: the live test prints it; if it does not, switch the default to PerText.
  • Clear only when the server may hold text (written on this connection, unflushed or with a Flushed owed): a cancel during a reconnect, or of a fully answered context, sends none.
  • Text is sent as Speak pieces of at most 1000 characters (sentence, then space boundaries, spaces kept). No keepalive message (none documented); idle closes reconnect lazily (also with a flushed PerText context still open: nothing is owed); connections are replaced after 55 minutes (server limit 60). Connect, send, close and the shutdown token are served together with cancels, so neither a barge-in nor a shutdown waits for a stalled socket.
  • warm() parks a socket for the latest session's voice and rate (Aura fixes both per socket; DeepgramAuraConfig::warm_target covers the first call). Live check: live_deepgram_aura_session in tests/deepgram_aura.rs (BRISVOX_LIVE_TESTS=1 + DEEPGRAM_API_KEY, optional DEEPGRAM_AURA_MODEL); it prints the measurements for the open items at the end of the provider doc.

OpenAI-compatible speech TTS adapter

Code: brisvox_providers::openai_speech (feature provider-openai-speech, config name openai_speech), facts in docs/providers/openai-speech.md (fetched 2026-10-14; wire shapes read from sources, nothing run yet; a live check is pending). POST {base_url}/audio/speech for local servers: Kokoro-FastAPI, Speaches, Chatterbox-TTS-Server (WAV only, no streaming), vLLM-Omni. One HTTP request per text piece the TTS stage sends, run in order (pending pieces of a context are joined while the server is busy), Audio events as the chunked body arrives (whole PCM16 samples, 24 kHz; wav header read and rate-checked for Chatterbox). connect opens nothing, so refusals and dead servers are Error events after the first text (TtsHarness::faults_at_connect() == false, the contract suite judges them there); warm() is a HEAD that pools a connection. Cancel drops the response (Kokoro-FastAPI stops generating on disconnect) and acknowledges at once; 400/422 skip a piece (two in a row end the session); no Word events (estimated downstream); the API key is optional and plain http:// is allowed to any host while no key is set. Presets: generic, kokoro, chatterbox, speaches, vllm_omni. Live check: live_local_tts_server in tests/openai_speech.rs (BRISVOX_LIVE_TESTS=1 + LOCAL_TTS_BASE_URL; prints time to first audio).

AssemblyAI STT adapter

Code: brisvox_providers::assemblyai (feature provider-assemblyai), facts and adapter notes in docs/providers/assemblyai.md. v3 universal streaming (wss://streaming.assemblyai.com/v3/ws), model universal-3-6-pro (Turkish), Authorization: <key> without Bearer. Turn partials are interims, the final turn is one speech_final transcript; Finalize is ForceEndpoint with a bounded wait and a synthetic final. Audio is batched to >= 50 ms chunks (the server closes on shorter ones), session_heartbeat feeds the silence watchdog, reconnect replays the unfinished turn's audio, hang-up sends Terminate and drains until Termination. Live check: tests/assemblyai.rs (BRISVOX_LIVE_TESTS=1 + ASSEMBLYAI_API_KEY).

sherpa-onnx STT adapter

Code: brisvox_providers::sherpa_onnx (feature provider-sherpa-onnx, provider = "sherpa_onnx"), facts and the Finalize design in docs/providers/sherpa-onnx.md (server source read 2026-10-14). Self-hosted, CPU-first streaming transducers (English zipformers, the community Turkish seda-v0.1), no key. Audio is float32 LE at the model rate (resampled here), results are the open segment's hypothesis (Python {text, segment}, C++ with is_final/is_eof), the server's own endpoint becomes a final segment. The protocol has no mid-stream finalize: Finalize sends Done, the last result is the answer and the spent connection is replaced when audio follows (audio that arrives meanwhile is held and replayed in order); an unanswered Done ends with the latest hypothesis after finalize_timeout. Mock servers of both flavours + the provider contract suite: crates/brisvox-providers/tests/sherpa_onnx.rs. Not live-tested (needs a running server).

OpenAI-Realtime-transcription STT adapter

Code: brisvox_providers::openai_realtime_stt (feature provider-openai-realtime-stt, provider = "openai_realtime_stt"), the dialect table and per-dialect facts in docs/providers/openai-realtime-stt.md (documentation and server source read 2026-10-14). One client for OpenAI (24 kHz, gpt-live-transcribe), NVIDIA Speech NIM (16 kHz, ?intent=transcription), Speaches (24 kHz in, completed-only, VAD that cannot be disabled) and vLLM (16 kHz, transcription.delta/done, commit = start, final commit = end). Our VAD drives turns: Finalize is input_audio_buffer.commit, answered by the completed of the committed item (empty buffer or too little audio is answered empty without a round trip). Liveness uses WebSocket pings because these servers do not answer every chunk. Tests: crates/brisvox-providers/tests/openai_realtime_stt.rs (mocks of the four dialects, contract suite for OpenAI, NIM and vLLM). Not live-tested.

Shared WebSocket STT session core

brisvox_providers::wsstt (private; compiled with either local-speech feature). One task per session owns the socket, a bounded replay ring (replay_window, oldest dropped first, one chunk per engine frame), the finalize queue and the timers; an adapter implements only Wire (setup messages, audio encoding, finalize, server messages, close handling). The core does: resampling to the wire rate, in-order finalize requests answered exactly once (protocol answer or timeout answer), liveness (sign of life within io_timeout after audio, a ping half-way, ping + suspended wait after keepalive_interval without audio), reconnect with replay and a pending finalize asked again after the replay, a rotate request (sherpa-onnx's spent connection) that waits for the next input before dialing, bounded event delivery (interims dropped when the channel is full, finals wait io_timeout), hang-up. Connects go through net::connect_ws (happy eyeballs).

Provider interface

Code: brisvox_providers::{stt, llm, tts}. Sessions are channel-based so any task can drive them and dropping a handle cancels the provider work. Every trait has warm() (default no-op).

  • STT: connect(SttConfig) -> SttSession { input: Sender<SttInput>, events: Receiver<SttEvent> }; inputs Audio(Bytes) / Finalize; events Transcript { text, is_final, speech_final, from_finalize } / Error. An utterance can have several final segments; speech_final marks its end (provider endpoint or the end of a requested finalize).
  • LLM: stream(LlmRequest) -> LlmStream; events TextDelta, ToolCallDelta { index, id?, name?, arguments } (fragments of different calls may interleave), Done { finish, usage }, Error. A stream that ends without Done/Error was cut off. Errors before streaming (e.g. Rejected { status }) come from stream() itself.
  • TTS: connect(TtsConfig) -> TtsSession { input, cancel, events }. Utterances are identified by an engine-chosen ContextId echoed on every event (Audio, Word { start } relative to the context's first sample, Flushed, Cancelled), so stale output after a cancel is detectable. cancel is a separate small channel providers handle before queued text (barge-in never waits behind text).
  • Channels: input/events 64 (backpressure), TTS cancel 8. Errors: Rejected, Disconnected, Protocol (never containing credentials), plus core timeouts/cancellation.

OpenAI-compatible LLM

brisvox_providers::openai_compatible::{OpenAiCompatibleLlm, OpenAiCompatibleConfig, Preset, Quirks} (feature provider-openai-compatible): Chat Completions streaming for Groq, Together, Fireworks, vLLM, Ollama, OpenRouter and any other server with that API. OpenAiCompatibleConfig::preset(p) fills in the base URL, a model from the vendor's docs and the quirks; base URL, auth header and scheme, extra headers, extra body fields and quirks are all configurable. Details and the per-vendor deviations: docs/providers/openai-compatible.md. Shared HTTP/SSE helpers live in src/http_llm.rs and src/sse.rs (also used by the OpenAI adapter).

OpenRouter (and any other hosted OpenAI-compatible service)

OpenRouter is the openai_compatible provider with preset = "openrouter" (it fills in the base URL https://openrouter.ai/api/v1 and a default model) and its key in an environment variable of your choice. Setting base_url and model without a preset is the same thing spelled out:

[llm]
provider = "openai_compatible"
api_key_env = "OPENROUTER_API_KEY"          # any variable name; sent as `Authorization: Bearer`

[llm.openai_compatible]
preset = "openrouter"                       # base_url = "https://openrouter.ai/api/v1" without it
model = "openai/gpt-6-luna"                 # OpenRouter's id: <author>/<model>, as on its model page
max_tokens = 120
reasoning_effort = "none"                   # only for a reasoning model that thinks by default
  • base_url is https://openrouter.ai/api/v1 (the adapter appends /chat/completions); plain http:// is accepted only for loopback, a hosted service is always https://.
  • model is passed through verbatim. OpenRouter ids are <author>/<model> (openai/gpt-6-luna, the model the repository's benchmark and live tests use); copy the id from OpenRouter's model page. The adapter does not check it: a wrong id is the service's 4xx on the first turn.
  • api_key_env names the variable that holds the key (export OPENROUTER_API_KEY=...); the key is never read from the agent file. brisvox-server check <file> --require-secrets reports an unset one before the first call.
  • reasoning_effort = "none" is sent as the request field of that name. A reasoning model that thinks by default adds about a second to the first token, which a voice turn cannot afford; a model that does not know the field may refuse the request, so leave it out for those.
  • openrouter_order = ["openai"] with openrouter_allow_fallbacks = false pins the upstream. These two keys are OpenRouter-only: without preset = "openrouter" or a base_url on openrouter.ai the agent file is rejected (brisvox-server check reports the error with the key's line), so a pin can never be silently ignored by another server. The request body gets "provider": {"order": ["openai"], "allow_fallbacks": false} (OpenRouter's provider routing; slugs as on the model's page, a base slug such as openai does not select the openai/fast or openai/flex service-tier endpoints). A wrong slug is a 404 from OpenRouter on the first turn, never a silent fallback. The adapter logs, once per response, which upstream the gateway reports on the first stream chunk (brisvox::llm, llm upstream, fields upstream_provider and upstream_model).
  • What has been run through this path: streaming, parallel and chained tool calls, retried invalid arguments, a slow tool with a filler and a barge-in, with openai/gpt-6-luna (docs/TOOLS.md, "Live results"; a full agent file is bench/pipecat_baseline/agent_live_tr.toml). Whether a given model handles your language and your tools well (Turkish included) is not established by that run: choose by a live test with your prompt. First-token time depends on the upstream provider OpenRouter routes to and varies between runs (docs/LATENCY.md, the live section).
  • The same shape fits any other hosted service with the Chat Completions API: change base_url, model and api_key_env (the vendors with a preset are listed above).

Mocks (brisvox-sim)

brisvox_sim::latency::{LatencyProfile, LatencySampler} (fixed, normal, log-normal with seed, scripted per call; SplitMix64 PRNG, so runs are reproducible) and brisvox_sim::mock_llm (MockLlm + LlmScript with connect / first-token / inter-token latency and faults ErrorResponse, Disconnect, ProtocolError, Stall, MalformedToolArguments, InterleavedToolDeltas; usage estimated at 4 chars/token). brisvox_sim::mock_stt::MockStt (SttUtterance scripts: interims spaced by the interim latency after the first audio; Finalize drops pending interims and emits the finals after the final latency, flagged from_finalize, last one speech_final) and brisvox_sim::mock_tts::MockTts (synthetic audio of ms_per_char per character in chunk pieces, Word offsets, Flushed after a context's audio, priority cancel -> Cancelled, late input for cancelled contexts ignored). Both take StreamFaults per session (ConnectError; Disconnect right after the N-th event; Stall, during which input, cancel and drop stay live) and connect latency. A Word event is not "spoken": it carries the word's offset into the context audio and is delivered with the text's first audio. Consumers must drop events of a cancelled context already queued before Cancelled, and keep draining events. MockStt ignores a Finalize without new audio and has no provider-side endpointing.

Resilience layer

Code: brisvox_providers::resilience (no feature: it wraps any adapter). ResilientStt, ResilientLlm and ResilientTts implement the provider traits over a primary and an optional secondary provider of the same stage, with a process-wide circuit breaker per provider, retry budgets (per call and per process), mid-call recovery and the idle refresh of TTS sessions (TtsProvider::idle_limit, which Cartesia sets to its documented 5 minutes). The full contract is in the module docs and docs/PIPELINE.md ("Resilience layer"). Simulator outages: brisvox_sim::outage::{Outage, OutageSwitch} (refused with 503 while down, or blackholed: pending until the caller's deadline while hanging). The server wires it for every call ( brisvox_server::resilient::ResilientProviders, docs/CONFIG.md "Secondary providers").

OpenAI Realtime speech-to-speech adapter

Code: brisvox_providers::openai_realtime (feature provider-openai-realtime) behind the provider-neutral brisvox_providers::s2s interface; facts in docs/providers/openai-realtime.md (fetched 2026-10-13), design and the engine stage in docs/S2S.md. One WebSocket per session (wss://api.openai.com/v1/realtime?model=gpt-realtime-2.1, Authorization: Bearer, no beta header), 24 kHz PCM16 both ways, one session.update at connect. The adapter maps events, delivers a tool call once per call_id and swallows the expected races of response.cancel and conversation.item.truncate; it does not decide turns, truncate by itself or reconnect (the engine does; after a reconnect or rotation the engine seeds the new session with S2sConfig::history, which the adapter sends as conversation.item.create items, and session_limit is 60 minutes). Not live-tested (needs OPENAI_API_KEY).

Gemini Live speech-to-speech adapter

Code: brisvox_providers::gemini_live (feature provider-gemini-live) on the same brisvox_providers::s2s interface; facts in docs/providers/gemini-live.md (fetched 2026-10-13, UNVERIFIED items marked), mapping and limits in docs/S2S.md. One BidiGenerateContent WebSocket per session (?key= query by default, x-goog-api-key header opt-in), model gemini-3.8-live, caller audio audio/pcm;rate=<hz>, model audio 24 kHz, JSON in text or binary frames. Gemini has no response ids, no cancel and no history truncation: the adapter mints ids, ends a cancelled response locally and ignores Truncate (the engine's mirror is the truth). The connection lives about 10 minutes (goAway becomes S2sEvent::SessionEnding); the engine rotates to a re-seeded session. Contract tests against a mock server: crates/brisvox-providers/tests/gemini_live.rs. Not live-tested (needs a GEMINI_API_KEY).

Deepgram — STT (live streaming over WebSocket)

Trait: brisvox_providers::stt::SttProvider (SttInput::{Audio, Finalize}, SttEvent::{Transcript{text,is_final,speech_final,from_finalize}, Error}).

  • Docs (all fetched 2026-10-08 unless noted; everything below comes from these pages):
    • Live API reference: https://developers.deepgram.com/reference/speech-to-text/listen-streaming.md
    • Streaming overview: https://developers.deepgram.com/docs/live-streaming-audio and https://developers.deepgram.com/docs/stt-streaming-feature-overview
    • Finalize: https://developers.deepgram.com/docs/finalize
    • KeepAlive: https://developers.deepgram.com/docs/audio-keep-alive
    • CloseStream: https://developers.deepgram.com/docs/close-stream
    • Endpointing: https://developers.deepgram.com/docs/endpointing
    • Interim results / is_final vs speech_final: https://developers.deepgram.com/docs/interim-results and https://developers.deepgram.com/docs/understand-endpointing-interim-results
    • UtteranceEnd: https://developers.deepgram.com/docs/utterance-end
    • SpeechStarted: https://developers.deepgram.com/docs/speech-started
    • Encoding: https://developers.deepgram.com/docs/encoding.md
    • Keyterm: https://developers.deepgram.com/docs/keyterm
    • Models and languages: https://developers.deepgram.com/docs/models-languages-overview.md
    • Errors / close codes: https://developers.deepgram.com/docs/stt-troubleshooting-websocket-data-and-net-errors.md and https://developers.deepgram.com/docs/errors.md
    • Reconnect guide: https://developers.deepgram.com/docs/recovering-from-connection-errors-and-timeouts-when-live-streaming-audio.md
    • Regional endpoints: https://developers.deepgram.com/reference/regional-endpoints.md
    • Rate limits: https://developers.deepgram.com/reference/api-rate-limits
    • Auth: https://developers.deepgram.com/reference/authentication.md and https://developers.deepgram.com/guides/fundamentals/token-based-authentication.md
    • Latency: https://developers.deepgram.com/docs/measuring-streaming-latency.md
    • Flux: https://developers.deepgram.com/docs/flux/quickstart.md and https://developers.deepgram.com/docs/flux/language-prompting.md
    • Pricing: https://deepgram.com/pricing (fetched 2026-10-08)

Auth

  • Authorization: Token <DEEPGRAM_API_KEY> (the authentication page documents exactly this form, Authorization: Token <YOUR_DEEPGRAM_API_KEY>; the Flux quickstart shows the same form for the WebSocket). The live streaming reference spec only says "an Authorization header" without the prefix.
  • Short-lived JWT alternative: Authorization: Bearer <JWT>; /listen WebSocket accepts temporary tokens, valid only during the initial connection; default TTL 30 s, max ttl_seconds 3600 (token-based-authentication page).
  • Browser sec-websocket-protocol subprotocol auth: UNVERIFIED (not covered by any fetched page; brisvox is a server-side client so the header is enough).
  • Failed upgrade: HTTP 4xx/5xx instead of 101; response headers dg-request-id (always present) and dg-error (present on failed upgrades, contains the error message) (STT troubleshooting page); the same message in the HTTP body is from Deepgram's debugging blog/changelog, not the troubleshooting page.

Endpoint(s) / regions

  • Global: wss://api.deepgram.com/v1/listen (Nova/Base/Enhanced family).
  • Regional hostnames (same API keys, same paths, strict routing, "Requests to the EU endpoint are never routed outside the EU"; if a region is down, requests fail rather than fall back): EU api.eu.deepgram.com, AU api.au.deepgram.com, India api.in.deepgram.com. Pattern wss://api.{region}.deepgram.com/v1/listen. All three regions support /v1/listen and /v2/listen (excluding Whisper). Plan constraints for regional endpoints: the page lists none (it only suggests Deepgram Dedicated for hosting in a specific EU country).
  • Flux (turn-taking model) uses a different endpoint: wss://api.deepgram.com/v2/listen (/v1/listen will not work with Flux). See Flux section.

Query parameters (v1/listen; table from the live reference)

Defaults as stated by the reference. Brisvox-relevant ones:

ParamValues / default
modelnova-3 (also nova-3-general, nova-3-medical), nova-2*, nova, enhanced, base, ... ; no default listed in reference or models page; the live-streaming-audio page says omitting it uses "the default model, which is currently model=base"
languagedefault en; Turkish = tr (Nova-3 also lists tr-TR)
encodinglinear16, linear32, flac, alaw, mulaw, amr-nb, amr-wb, opus, ogg-opus, speex, g729; for raw headerless audio encoding AND sample_rate are both required
sample_rateinteger Hz, required together with encoding for raw audio
channelsdefault 1
interim_resultstrue/false, default false
endpointingms integer, or false to disable; reference default 10
utterance_end_ms1000..5000 ms (min 1000 on cloud); requires interim_results=true
vad_eventstrue/false, default false (enables SpeechStarted)
smart_formattrue/false, default false
punctuatetrue/false, default false
keytermNova-3 keyterm prompting (limit 500 tokens per request, also applies to each mid-stream update; keyterm page says it also works with Flux); keywords is legacy (other models such as Nova-2)
numerals, diarize_model, redact, replace, search, tag, extra, callback, multichannel, profanity_filter, detect_entities, dictationnot needed for brisvox v1 (names not individually re-verified)
versiondefault latest
mip_opt_outmodel improvement program opt-out, default false
Recommended brisvox set (derived from docs, not copied): `model=nova-3&language=tr&encoding=linear16&sample_rate=16000
&channels=1&interim_results=true&endpointing=300&vad_events=true&smart_format=true(+utterance_end_ms=1000`
only as a safety net, see caveats). For telephony pass-through: encoding=mulaw&sample_rate=8000
(the encoding page gives no mulaw/alaw rate guidance; 8000 for G.711 telephony is UNVERIFIED in Deepgram docs).
Audio chunk size: docs say streaming buffers "between 20 and 100 milliseconds" (latency page); Flux page strongly
recommends 80 ms.

Client -> server messages

Audio: binary WebSocket frames (raw bytes for the chosen encoding). Control messages are TEXT frames (JSON):

{"type": "KeepAlive"}
{"type": "Finalize"}
{"type": "Finalize", "channel": 0}
{"type": "CloseStream"}
  • Finalize with optional channel (0-based; omitted = all channels). KeepAlive must be a text frame; sending it as binary "may result in incorrect handling and potential connection issues".
  • The reference page gives only field names/types, no literal JSON examples (the shapes above and below are derived from its schema, except the Finalize channel example which is on the finalize page).
  • Configure (Nova-3 only) — shape from the reference schema (not a verbatim docs example): {"type":"Configure","keyterms":["term one","term two"],"features":{"numerals":true}}. keyterms replaces the whole list (including keyterms set via the keyterm query param); empty array clears; omitted/null keeps the current list. Other models return an Error with code KeytermsNotSupported. On the EU/AU/India regional endpoints, mid-stream keyterms in Configure is not available yet (returns an Error, stream keeps its keyterms; regional-endpoints page) — this is region-specific, not a contradiction; set keyterms via the keyterm query parameter there (and simplest everywhere).

Server -> client messages (all JSON text frames, discriminated by type)

  • Results: required fields type, channel_index ([idx, total]), duration, start, channel, metadata; optional is_final, speech_final, from_finalize, entities. channel.alternatives[] (transcript, confidence, languages[], words[] with word, start, end, confidence, language, punctuated_word, speaker only with diarization), metadata (request_id, model_uuid, model_info{name,version,arch}, diarize_info only when a diarizer ran), entities[] (label, value, raw_value, confidence, start_word, end_word) per the reference. Docs example (interim; example omits type):
{"channel_index":[0,1],"duration":1.039875,"start":0,"is_final":false,
 "channel":{"alternatives":[{"transcript":"another big","confidence":0.9600255,
 "words":[{"word":"another","start":0.2971154,"end":0.7971154,"confidence":0.9588303},
          {"word":"big","start":0.85173076,"end":1.039875,"confidence":0.9600255}]}]}}

Endpoint example (endpointing page, trimmed): {"duration":1.039875,"start":0.0,"is_final":false,"speech_final":true, ...} (note is_final is false there; see semantics).

  • UtteranceEnd (needs interim_results=true + utterance_end_ms):
{"channel": [0, 1], "last_word_end": 2.395, "type": "UtteranceEnd"}

last_word_end == -1 means the result was already finalized before the utterance_end_ms condition was met: ignore it.

  • SpeechStarted (needs vad_events=true):
{"type": "SpeechStarted", "channel": [0, 1], "timestamp": 9.54}

Its timestamp may differ from the first word's start (separate systems); complements, does not replace, VAD.

  • Metadata: type, transaction_key (deprecated), request_id, sha256, created, duration, channels. Sent as the summary after CloseStream.
  • Error (non-fatal; fatal failures close the socket): type, variant (required; SchemaError = unparsable client text message, message holds the original; InvalidConfigureMessage = rejected Configure), description (required), optional code (e.g. KeytermsNotSupported), optional message.

Result semantics (mapping to brisvox SttEvent::Transcript)

  • is_final=false: interim, replaces the in-progress text (not append). Words near the end may be truncated (word.end == duration).
  • is_final=true: this audio segment is fixed; it does not by itself mean the speaker has finished. One utterance can contain several is_final=true results.
  • speech_final=true: endpointing detected a pause (utterance end). Docs: "Do not use speech_final: true alone to capture full transcripts." Algorithm per docs: append every is_final:true transcript to a buffer; when speech_final:true arrives the buffer is the full utterance; clear it.
  • Docs' own endpointing example shows is_final:false together with speech_final:true; the page does not explain it. Treat speech_final with is_final=false as UNVERIFIED semantics (implementation should not emit a final text from such a message; safest: only treat is_final=true as text, and let a following/previous is_final flush the buffer).
  • from_finalize=true marks results produced in response to Finalize.

Keepalive / timeouts

  • Send {"type":"KeepAlive"} every 3-5 s when no audio is being sent; no server response.
  • Closes with NET-0001 if no audio or KeepAlive for 10 s (KeepAlive page). But the STT troubleshooting page (not the errors page) also says for NET-0001: send audio within 10 seconds of opening the connection; "KeepAlive messages alone won't prevent this closure, so you must send at least one audio message". For NET-0002 (no audio within the no-audio timeout window, close reason no_audio_timeout): send audio or KeepAlive; KeepAlive messages DO reset this timeout. The reconnect guide also says to stream audio within 10 s of opening. => Always send audio (even silence) soon after connect; KeepAlive is for pauses after audio has flowed. Whether a KeepAlive-only pause is indefinitely allowed after audio has flowed: the streaming overview says KeepAlive can "pause" the connection without closing it; the exact max pause (and the no-audio timeout duration for NET-0002) is UNVERIFIED.
  • Word timings are relative to the audio stream, not the connection lifetime.

Finalize / flush / cancel semantics

  • Finalize: "forces the server to immediately process any unprocessed audio data and return the final transcription results", ensuring interim results are treated as final; results may carry "from_finalize": true. The docs say this response "is not guaranteed if there is no significant amount of audio data to process" -> the client must not wait forever for a from_finalize result (use a timeout; the brisvox adapter should synthesise the end of utterance itself, e.g. after the last final received or a short deadline). Whether speech_final is set on the from_finalize result: UNVERIFIED (the finalize page does not mention speech_final; the brisvox trait says the last from_finalize transcript is speech_final, so the adapter must set that itself).
  • CloseStream: server processes all remaining audio, returns final results, sends a Metadata summary, then terminates the WebSocket. Docs say to always send it when done. Behaviour if sent before any audio: the page's example Metadata shows duration 0.0 / channels 0 for nothing-left-to-process; other behaviour UNVERIFIED.
  • There is no "cancel/discard buffered audio" message; to discard, close the socket and reconnect.

Timestamps / interim results

  • start/duration on Results and per-word start/end are seconds from the start of the audio sent on this connection. They are not guaranteed accurate at millisecond level (latency page). Timestamps restart at 0 on every new connection.
  • Interim results are "typically sent every second" (UtteranceEnd page) so utterance_end_ms < 1000 is pointless.

Endpointing config

  • endpointing=<ms> waits that much silence before finalizing and setting speech_final; endpointing=false disables it (transcripts then arrive at a cadence determined by Deepgram's chunking algorithms). Docs: enabled by default at 10 ms (endpointing page; the understand-endpointing page says this suits chatbots expecting short utterances); 300-500 ms suits conversations where speakers pause mid-thought (understand-endpointing page; the endpointing page's own code samples use 300). Whether 10 ms is the true live default: verify against live behaviour (UNVERIFIED beyond the docs).
  • UtteranceEnd fires when a gap of utterance_end_ms follows the last finalized word. Claim that it can fire while the speaker continues and that client-side logic may be better for voice agents: UNVERIFIED (not seen in the re-fetched UtteranceEnd page). Brisvox uses its own VAD (Silero + Smart Turn) and Finalize; provider endpointing is a fallback.

Concurrency / rate limits (streaming STT, per project; keys in one project share the pool)

  • Pay As You Go: up to 150 concurrent streams (Nova and Flux). Growth: North America 225, EU/AU/IN 150. Enterprise: Flux up to 300, Nova starting at 300. Secondary self-serve projects: 1 concurrent stream. (Streaming diarization has separate lower limits: 50 NA / 25 EU-AU-IN on PAYG and Growth.)
  • Over-limit response: HTTP 429 TOO_MANY_REQUESTS; the errors page recommends an exponential-backoff retry strategy for the Speech-to-Text 429; the rate-limit page itself does not state the behaviour and documents no rate-limit headers.
  • Audio ingest cap: Deepgram accepts audio at most 1.25x realtime (reconnect guide) — replay backlog is bounded.

Errors and close codes

  • WebSocket close: 1008 + DATA-0000 (payload cannot be decoded as audio; check encoding/sample_rate); 1011 + NET-0000 (service has not transmitted a Text frame to the client within the timeout window; may be an internal Deepgram issue or insufficient audio to transcribe; docs: "Retry your request"); 1011 + NET-0001 (service has not received a Binary or Text frame from the client within the timeout window); 1011 + NET-0002 (no audio within the no-audio timeout window, close reason no_audio_timeout).
  • HTTP errors at handshake (errors page): 400 (INVALID_QUERY_PARAMETER, Bad Request, ...), 401 INVALID_AUTH, 402 ASR_PAYMENT_REQUIRED, 403 INSUFFICIENT_PERMISSIONS (project lacks model access), 429 TOO_MANY_REQUESTS, 503 Service Unavailable. JSON body fields: err_code, err_msg, request_id (the TTS 503 example uses error_code: parse both). The 400 Invalid JSON example uses category, message, details, request_id.
  • Normal close code after CloseStream (1000?): UNVERIFIED (close-stream page specifies none).

Reconnect (per the recovery guide)

  • "your application must initialize a new WebSocket connection and start a new streaming session"; send audio within 10 s or it closes.
  • Audio produced during an outage is lost unless the client buffers it; replay the buffer after reconnect. Large backlogs add delay because Deepgram accepts audio at most 1.25x realtime.
  • Timestamps restart at 00:00:00 on the new connection: keep a start offset and add it to returned timestamps.
  • The guide lists no other state; implicitly lost: interim hypotheses, un-finalized audio in flight, the utterance buffer, Configured keyterms/feature changes (re-apply via query params). No retry/backoff values or close-code handling are specified by the guide: UNVERIFIED (use bounded exponential backoff for 429/503/1011).
  • Brisvox consequence: keep a bounded ring of audio sent since the last speech_final/finalize; on reconnect replay it (paced <= 1.25x realtime), discard in-flight interims, and add the offset to word timestamps.

Models and Turkish

  • Turkish: Nova-3 tr, tr-TR; Nova-2 tr; Base tr. Enhanced and Nova (1): not listed. Flux: not listed.
  • Nova-3 language=multi covers only English, Spanish, French, German, Hindi, Russian, Portuguese, Japanese, Italian, Dutch — NOT Turkish. For Turkish pass language=tr (monolingual).
  • Current Nova-3 name in docs: nova-3; the models page does not mention any "Nova-4" and calls Flux the "latest-generation streaming model".
  • Model version pinning via version (default latest).

Flux (turn-taking STT) — does NOT change the Turkish setup

  • Models flux-general-en (English) and flux-general-multi (10 languages: en, es, fr, de, hi, ru, pt, ja, it, nl). Turkish is NOT supported by Flux (language_hint=tr would give 400 INVALID_PARAMETER). Plain model=flux: not documented (UNVERIFIED; use the two ids above).
  • Endpoint wss://api.deepgram.com/v2/listen. Params: eot_threshold (0.5-1.0, default 0.7), eager_eot_threshold (0.3-0.9, off by default; required to enable EagerEndOfTurn and TurnResumed), eot_timeout_ms (500-60000, default 5000), language_hint (repeatable, only flux-general-multi; other models return 400), encoding/sample_rate for raw audio (linear16, linear32, mulaw, alaw, opus, ogg-opus; rates 8000/16000/24000/44100/48000; 16000 recommended; 80 ms chunks, 2560 bytes at 16 kHz linear16).
  • Messages: Connected, TurnInfo (transcript, per-word word/confidence/times, languages; events EndOfTurn, EagerEndOfTurn, TurnResumed; the quickstart does not mention an Update event, but the latency page says to count Flux interim results via "type": "Update" — the two pages disagree/are unclear: UNVERIFIED); control: Configure (updates eot_threshold, eager_eot_threshold, eot_timeout_ms mid-stream; per language-prompting page also language_hints plural, [] clears), ForceEndTurn (payload undocumented). Exact JSON/field names (e.g. event, turn_index): UNVERIFIED.
  • EagerEndOfTurn can raise LLM calls by 50-70% (speculative responses).
  • Decision input: brisvox v1 targets Turkish, so Nova-3 /v1/listen + own VAD/Smart Turn + Finalize. A Flux adapter could be a later option for English/multi languages only.

Pricing

  • https://deepgram.com/pricing (2026-10-08, streaming per minute; labelled "limited-time promotional rates"): Nova-3 monolingual PAYG $0.0048 (regular $0.0077), Growth $0.0042 (regular $0.0065); Nova-3 multilingual PAYG $0.0058 (regular $0.0092) / Growth $0.0050 (regular $0.0078); Flux English PAYG $0.0065 (regular $0.0077) / Growth $0.0057 (regular $0.0065); Flux Multilingual PAYG $0.0078 / Growth $0.0068 (single price listed). Recheck before quoting.

Gotchas / implementation notes

  • Reference lists Authorization header only; use the Token prefix for API keys.
  • Control messages must be text frames; audio binary frames. Never log the key or the Authorization header.
  • Connection closes at 10 s without audio even with KeepAlive if audio never started — send audio immediately (e.g. 20 ms of silence) after connect; warm() should therefore not hold an idle socket open.
  • speech_final can arrive on a result whose is_final is false per the endpointing example (unexplained).
  • UTF-8 Turkish text arrives in transcript; punctuated_word holds the punctuated form when formatting is on.
  • Deepgram does not store transcripts; the response is the only chance to get them (live-streaming-audio page).

UNVERIFIED summary

  1. sec-websocket-protocol browser auth. 2. Whether speech_final is set on from_finalize results and what Finalize does with little audio (docs: response not guaranteed). 3. Exact semantics of is_final:false + speech_final:true. 4. Max KeepAlive-only pause duration after audio started, and NET-0002 timeout duration.
  2. Normal close code after CloseStream. 6. mulaw/alaw sample_rate guidance (8000 assumed). 7. Flux exact JSON field names and whether an Update event exists. 8. Reconnect retry/backoff values and state list. 9. Reference lists endpointing default 10 ms; verify live. 10. UtteranceEnd "can fire while speaker continues / client-side logic better" wording. 11. Plain model=flux validity. 12. 8000 Hz for telephony. 13. Individual names of the "not needed" query parameters.

Fact-checked 2026-10-08: 62 claims checked, 15 corrected. Corrections: (1) auth Token prefix is documented on the authentication page (was "UNVERIFIED-ish"); (2) default model base attributed to live-streaming-audio page, not the overview/models page; (3) Configure keyterms "contradiction" resolved as region-specific (EU/AU/IN) behaviour; (4) KeepAlive-alone/NET-0001 statements re-attributed from errors page to STT troubleshooting page, and NET-0002 KeepAlive-resets added; (5) NET-0000 meaning corrected (service sent no Text frame to client; not just "internal issue"); (6) removed unsupported claim that the endpointing page flags 10 ms as surprising (10 ms default is on both pages; 300-500 ms advice is on the understand-endpointing page); (7) removed unsupported UtteranceEnd "can fire while speaker continues" claim, marked UNVERIFIED; (8) errors page does not contain the phrase "Retry with exponential backoff" (it recommends an exponential-backoff strategy for STT 429); (9) the "Retry your request" and dg-request-id/dg-error facts re-sourced to the troubleshooting page (HTTP-body copy of the error is from the Deepgram blog); (10) timestamps are "not guaranteed accurate at millisecond-level" (was "not millisecond accurate"); (11) Enterprise concurrency clarified (Flux up to 300, Nova starting at 300); (12) Flux Update event not in quickstart (latency page conflict noted); (13) pricing: added missing regular rates for Nova-3 multilingual and Flux English; (14) keyterm 500-token limit also applies to mid-stream updates and works on Flux, Configure also supports language_hints on Flux; (15) Results required/optional fields, Error required fields, and example-omits-type note added per reference.

Adapter: brisvox_providers::deepgram (feature provider-deepgram)

Code: crates/brisvox-providers/src/deepgram/ (config, protocol, assembler, replay, connection, session). Tests: crates/brisvox-providers/tests/deepgram.rs (local mock of the wire protocol on a loopback TcpListener, the shared contract suite, one live test).

API. DeepgramStt::new(DeepgramConfig, SharedClock) implements SttProvider. DeepgramConfig::new(Secret<String>) holds the defaults; fields: model (nova-3), language (overridden by SttConfig.language), encoding (Linear16 | Mulaw) and sample_rate_hz (default: SttConfig.sample_rate), interim_results (true), endpointing (Ms(300) | Off), utterance_end_ms, smart_format (true), punctuate, keyterms, region (Global | Eu | Au | India), base_url (tests; strictly host[:port] after the scheme, so no userinfo @, path, query or backslash; ws:// only for the loopback hosts localhost, 127.0.0.1, ::1), and the time settings io_timeout (10 s), keepalive_interval (4 s, must be below 10 s), finalize_timeout (800 ms), finalize_settle_gap (500 ms, ZERO disables), close_timeout (1 s), max_reconnects (1), reconnect_backoff (100 ms, doubling, cap 5 s), replay_window (8 s), replay_speed (1.25).

Query. model, language?, encoding, sample_rate, interim_results, endpointing, utterance_end_ms?, smart_format, punctuate?, keyterm* in that fixed order, values percent-encoded; model/language of the config are restricted to [A-Za-z0-9._-].

Mapping. Interims: forwarded as interim transcripts with the text of the current segment; blank interims are dropped; if the events channel is full an interim is dropped (finals and errors wait, bounded by io_timeout). Finals: every is_final result with text is one final segment (the consumer appends); speech_final is forwarded. from_finalize results always carry speech_final=true (the doc does not say Deepgram sets it). UtteranceEnd (last_word_end != -1) closes an open utterance (the last interim is promoted to a final if no final is pending). Blank finals with speech_final only matter as an utterance end somebody waits for (open finals or a Finalize) and are otherwise dropped. is_final=false + speech_final=true (unexplained in the docs): if finals of the utterance were already forwarded it closes the utterance with an empty speech_final final, otherwise the flag is given to the next final; it never turns interim text into final text. SpeechStarted, Metadata and unknown message types are ignored; a missing type is read as Results.

Finalize. {"type":"Finalize"} goes out at the position of the request in the audio stream. A second request while one is open is ignored. Every Finalize gets exactly one from_finalize transcript with speech_final, from one of three sources:

  1. Deepgram's from_finalize result (the discriminator; an ordinary endpointed final that arrives while the Finalize is open is forwarded as what it is, from_finalize=false, and does not answer it). A final whose end already reaches the Finalize position answers it too, since the server then has nothing left to flush. The tolerance depends on whether the Finalize has gone out: once it is on the wire the server holds it anyway, so a final ending within finalize_settle_gap of the position answers it; while it is still queued (behind a replay) only a final within 20 ms of the position does (timestamp rounding), otherwise the Finalize is still sent and the final is an ordinary segment. (A wide tolerance on a queued Finalize would drop it and strand the speech between the final's end and the Finalize position.)
  2. The adapter, after finalize_timeout counted from the request (not from when the message goes out), so a reconnect plus replay in between does not stretch the wait: Transcript{text: last interim or "", is_final, speech_final, from_finalize}. A Finalize that was still queued behind a replay stays queued and is sent later; its answer is then a late one.
  3. The adapter, at once and without sending anything, when the Finalize finds the recognizer idle: no open finals, no interim text, and the latest Results message of the connection ends within finalize_settle_gap of the end of the audio sent (the usual case when endpointing fired before the engine's VAD or Smart Turn stop; it avoids the full 800 ms). It requires at least one Results message on the connection, so a call that has heard nothing yet still waits for Deepgram. The gap is a guess (see below): speech newer than it is reported as a late utterance.

A late from_finalize result (after source 2 or 3) is blank: dropped; non-blank with the same words as the synthetic final (case, punctuation and spacing ignored; Turkish casing included: I/ı and İ/i compare equal, so a smart-formatted Işık geldi. matches the interim ışık geldi): dropped as a duplicate (also a real final with the same words when Deepgram ignored the Finalize and endpointing fired later; the record ends with any new interim, the next Finalize, or its first match); otherwise real text the synthetic final lacked, delivered as another utterance end.

Keepalive and silence. No audio for keepalive_interval -> {"type":"KeepAlive"} (repeated). A connection that has seen no audio at all gets 20 ms of digital silence once instead (zeros for linear16, 0xFF for mu-law), because KeepAlive alone does not prevent the NET-0001 close.

Watchdogs (all Clock-based). Connect (TCP+TLS+upgrade together) and every write: io_timeout (connect -> Core(Timeout), a failed or timed-out write -> connection lost). After audio was sent the server must send some message within io_timeout of the last one (or of the audio that started the wait): otherwise the connection is treated as lost, like a closed socket (a half-open socket or a server that stopped answering without a close frame is the commonest real drop, and NET-0000 for "no Text frame" is documented as retriable), and replaced with a replay. Only when no reconnect is left does the session end, with Core(Timeout{what: "deepgram response"}) instead of Disconnected. The watchdog is off once a KeepAlive had to be sent (a pause in the audio) until audio resumes. keepalive_interval must be below 10 s (NET-0001; rejected otherwise) and should be below io_timeout: otherwise a pause in the audio longer than io_timeout looks like a silent server (a warning is logged; it is not rejected because the shared contract suite needs a keepalive slower than its 600 ms deadline). A caller who is silent for longer than io_timeout while the server also sends nothing costs one reconnect (with a replay of at most replay_window); raise io_timeout if Deepgram turns out not to send blank interims during silence.

Errors. Upgrade answered with HTTP status N -> Rejected{N} (headers dg-request-id/dg-error are logged, never the request); close 1008 -> Rejected{1008} and not retried (undecodable audio); an unparsable message or invalid frame -> Protocol (no message content in the error); an Error message: documented as non-fatal, so only the two variants that mean the adapter sent something wrong (SchemaError, InvalidConfigureMessage) end the session, as Protocol with the variant and the (truncated, 200 chars) description in detail (never the echoed client message); any other variant is logged (warn, variant only) and the session continues; other closes (1011 NET-0000/1/2, 1000), socket errors, EOF -> connection lost.

Reconnect. A lost connection is replaced up to max_reconnects times (the counter restarts when a Results message arrives, the only message that proves a working connection: a server that accepts, sends Metadata and closes, over and over, is not followed forever; backoff reconnect_backoff * 2^attempt, capped at 5 s; the attempt times are asserted in the tests). Credentials errors (401-403) are surfaced as Rejected at once; otherwise, when the attempts run out, Disconnected. The adapter keeps a ring of the audio sent since the server last finalized anything (bounded by replay_window, oldest chunk first; refcounted Bytes, no copy). Final results trim the ring to the end of the finalized audio: start + duration seconds are bytes on this connection, shifted by the connection's base position in the session's audio stream (timestamps restart at 0 per connection) and minus the generated silence. A completed Finalize (answered by from_finalize, or given up on after finalize_timeout) trims to the position where it was sent; tests drop the connection after each and check that only newer audio is replayed. After a reconnect the ring is replayed paced at replay_speed (the docs: at most 1.25x; the ring's front is always on a sample boundary, also for odd-sized caller chunks), a pending Finalize is re-sent at its original position (its finalize_timeout deadline is not restarted), interims are forgotten, and audio/Finalize from the caller keep being accepted meanwhile. Backlog B s drains in B / (replay_speed - 1) s while live audio keeps arriving.

Hang-up. Dropping the session input (or the events receiver) closes events at once, then sends CloseStream and waits at most close_timeout for the server. Results the server flushes in answer to CloseStream are discarded (the contract: nothing follows a hang-up).

warm(). Builds the process-wide rustls config (net::tls_config()) and resolves the host within io_timeout. It does not pre-connect: an idle connection is closed after 10 s without audio. All sessions share the TLS config, so rustls' in-memory session store can resume TLS for later connects.

Adapter decisions that rest on UNVERIFIED points

  • speech_final on from_finalize results: not relied on (set by the adapter).
  • finalize_settle_gap (500 ms): chosen as roughly endpointing (300 ms) plus recognition latency; how far an endpointed final's start + duration lags the end of the speech, and how often blank interims arrive during silence, are not documented. Both only decide how often the shortcut applies.
  • is_final=false + speech_final=true: both readings handled (see Mapping).
  • Whether the server sends any text frame while it receives silent audio: not documented. The watchdog assumes it does (the NET-0000 close, "no Text frame to the client within the timeout window", suggests so). If it does not (e.g. interim_results=false and a very quiet caller), a quiet stretch longer than io_timeout costs a reconnect with replay (and a second one in a row ends the session with Core(Timeout)); raise io_timeout then.
  • The KeepAlive-only pause limit and the NET-0002 window: the adapter just sends KeepAlive.
  • Normal close code after CloseStream: not relied on (any close or EOF ends the wait).
  • 8000 Hz for mu-law: taken from SttConfig.sample_rate unless sample_rate_hz overrides it.

Gotchas found during implementation

  • tokio_tungstenite::Connector is a different type from tungstenite::Connector; connect_async_tls_with_config wants the tokio one.
  • The tungstenite defaults buffer writes (128 KiB) and the read buffer is 128 KiB: the adapter sets write_buffer_size(0) (each send flushes), read_buffer_size(16 KiB), max_message_size(1 MiB) and TCP_NODELAY.
  • The handshake future is large; polled inline it needed more than 4 MiB of stack in a debug build when nested in the contract suite's futures. It is Box::pinned in connection::open.
  • Timestamps are per connection, and the 20 ms of generated silence is part of that audio: the offset math must subtract it, or the trim after a reconnect lands too late and drops unfinalized audio.
  • A Results example in the docs has no type; do not require it.
  • Finalize without enough buffered audio is simply not answered (docs), and a late answer can still arrive after the synthetic final: a late blank one is dropped, a late one with the same words is a duplicate and dropped, a late one with other words is delivered as another utterance end.
  • Only from_finalize (or a final that reaches the Finalize position) answers a Finalize. Treating every speech_final as the answer mislabels a normal endpointed final and turns the real answer for the remaining audio into a phantom second utterance.
  • A server that closes with 1011 right after connect (NET-0001: no audio) must not make the adapter loop: the reconnect attempt counter is only reset by a Results message (a Metadata or ignored message does not count).
  • The contract suite's stall checks expect Core(Timeout) at the deadline, which a reconnect-on-silence adapter only gives when no reconnect is left: the harness in tests/deepgram.rs runs with max_reconnects = 0 (the reconnecting behaviour has its own tests).
  • Eviction from the replay ring cuts whole caller chunks; with odd-sized 16-bit chunks that would leave the replay start inside a sample (every following sample byte-swapped, then 1008), so the front is rounded up to the sample size.
  • A socket that disappears without a closing handshake surfaces as a tungstenite protocol/IO error or EOF; the adapter treats all of them as "connection lost".
  • The live test (BRISVOX_LIVE_TESTS=1, DEEPGRAM_API_KEY) settles UNVERIFIED items 2 and 3 (does Deepgram answer Finalize, and with speech_final?). It sets finalize_timeout to 8 s and finalize_settle_gap to zero, so the adapter's own fallbacks cannot answer, and requires the from_finalize + speech_final event within 3 s of the request; the measured delay is printed. Without the env vars it prints "skipped" and passes: it proves nothing until run live.
  • The shared contract suite takes about 6.6 s of real time because its task-leak checks wait for fixed 2 s quiet windows; the timeout checks run with a 600 ms adapter deadline instead of 2 s.

Live check, Turkish and interims (2026-10-13)

Source re-fetched 2026-10-13: https://developers.deepgram.com/docs/models-languages-overview lists Turkish for nova-3 / nova-3-general (tr, tr-TR, monolingual), nova-2 (tr) and base (tr); not for Flux, Enhanced or the Nova-3 medical variants. The adapter default nova-3 + language=tr is therefore the right Turkish setting.

Measured (crates/brisvox-providers/tests/live_checks.rs, nova-3, language=tr, linear16 16 kHz):

  • FLEURS tr_two_utterances.wav: WER 0.038 and 0.000 (two runs), en_two_utterances.wav (language=en): WER 0.103. Turkish TTS audio (Cartesia, ElevenLabs, resampled 24 -> 16 kHz): WER 0.00-0.31, mostly number and spacing effects (bench/results/live-checks-2026-10-13.md).
  • Interim cadence: one interim about every second (0.9-1.1 s), an is_final segment every 3-6 s on read speech that restarts the interim text (the interim holds only the text since the last final). Confirms "typically every second".
  • Finalize sent at a VAD stop is answered with is_final + speech_final + from_finalize (as the adapter delivers it) 290 ms (EN) / 330 ms (TR) later (median of 11 turns, max 373 ms). Whether the adapter's synthetic fallback or Deepgram produced each of them was not logged. Without Finalize, speech_final came ~600 ms after the stop (endpointing 300 ms plus the recognizer). An answer to Finalize on real speech arrives within the ~330 ms seen here (UNVERIFIED items 2 and 3 were checked only with a tone before).
  • The last interim before the final equals the final in only 1 of 17 utterances (EN and TR alike): see bench/results/speculation-live-deepgram-2026-10-13.md; on_partial speculation is not recommended.
  • Still UNVERIFIED: browser subprotocol auth, close codes, KeepAlive-only pause length.

AssemblyAI — STT (real-time streaming over WebSocket, v3 API)

Trait: brisvox_providers::stt::SttProvider (SttInput::{Audio, Finalize}, SttEvent::{Transcript{text,is_final,speech_final,from_finalize}, Error}).

  • Docs (all fetched 2026-10-08; everything below comes from these pages, via a fetch tool that summarises the page, so quoted strings are as reported by that summary):
    • Streaming WebSocket API reference: https://www.assemblyai.com/docs/api-reference/streaming-api/streaming-api (same content as https://www.assemblyai.com/docs/streaming/api-spec/streaming-websocket; the whole page was read in the 2026-10-08 fact-check, it ends with the llmGatewayResponse message and has no Error schema / close codes)
    • Streaming overview: https://www.assemblyai.com/docs/streaming
    • Message sequence: https://www.assemblyai.com/docs/streaming/message-sequence.md
    • Turn detection (Universal-3.x Pro): https://www.assemblyai.com/docs/streaming/turn-detection.md
    • Turn detection (Universal Streaming): https://www.assemblyai.com/docs/streaming/universal-streaming/turn-detection.md
    • Updating configuration mid-stream: https://www.assemblyai.com/docs/streaming/updating-configuration-mid-stream.md
    • Prompting and keyterms: https://www.assemblyai.com/docs/streaming/prompting-and-keyterms.md
    • Optimizing accuracy and latency: https://www.assemblyai.com/docs/streaming/getting-started/optimizing-accuracy-and-latency.md
    • Quickstart (raw WebSocket example): https://www.assemblyai.com/docs/streaming/getting-started/transcribe-streaming-audio.md
    • Stream a pre-recorded file in real time (pacing guide): https://www.assemblyai.com/docs/streaming/guides/stream_prerecorded_file_realtime.md
    • Benchmarks: https://www.assemblyai.com/docs/streaming/benchmarks
    • Model selection: https://www.assemblyai.com/docs/streaming/select-the-speech-model.md
    • Multilingual transcription: https://www.assemblyai.com/docs/streaming/multilingual-transcription.md (and the older path https://assemblyai.com/docs/streaming/universal-streaming/multilingual-transcription)
    • Language FAQ: https://assemblyai.com/docs/faq/language-support-for-real-time-transcription
    • Migration guide Universal Streaming -> Universal-3.6 Pro: https://www.assemblyai.com/docs/streaming/migration-guides/universal-to-universal-3-5-pro-streaming.md
    • Migration guide Deepgram -> AssemblyAI: https://www.assemblyai.com/docs/streaming/migration-guides/dg_to_aai_streaming
    • Common session errors and closures: https://www.assemblyai.com/docs/streaming/common-session-errors-and-closures.md
    • Rate limits: https://assemblyai.com/docs/streaming/rate-limits
    • Endpoints and data zones: https://www.assemblyai.com/docs/streaming/endpoints-and-data-zones.md
    • Temporary tokens: https://www.assemblyai.com/docs/streaming/authenticate-with-a-temporary-token.md
    • Models and pricing table: https://www.assemblyai.com/docs/getting-started/models and https://www.assemblyai.com/pricing ; billing: https://www.assemblyai.com/docs/billing-and-pricing.md
    • Doc index: https://assemblyai.com/docs/llms.txt

Auth

  • Authorization: <ASSEMBLYAI_API_KEY> header on the WebSocket upgrade, with NO Bearer prefix (reference, overview and quickstart all say so). Never put the permanent key in a URL.
  • Alternative for browsers: token=<temporary token> query parameter. Token endpoint https://streaming.assemblyai.com/v3/token with the API key in Authorization; query params expires_in_seconds (required, 1-600) and max_session_duration_seconds (optional, 60-10800, default 10800); the JSON response carries a token field. HTTP method: the page text says POST but its examples and endpoint card use GET: UNVERIFIED. A token is NOT single-use (valid until it expires, can start several sessions). Usage is billed to the key that created it. brisvox is server-side, so the header is enough.
  • Optional header AssemblyAI-Version pins the API version (default: latest). Value format: UNVERIFIED (the Begin example shows "api_version": "2025-05-12").
  • Failed auth closes the socket with 1008 (see Close codes).

Endpoint(s) / regions

EndpointURLProcessing
Edge routing (default)wss://streaming.assemblyai.com/v3/wsnearest region, any US or EU location
US data zonewss://streaming.us.assemblyai.com/v3/wsUS regions only (us-east-1, us-east-2, us-west-1, us-west-2)
EU data zonewss://streaming.eu.assemblyai.com/v3/wsEU regions only (eu-central-1, eu-north-1, eu-south-1, eu-south-2, eu-west-1, eu-west-3)
The v2 streaming API is retired (errors page: 410 "Deprecated endpoint"; use /v3/ws).

Models (query speech_model)

Model IDLanguagesNotes
universal-3-6-pro32 incl. Turkish (tr)"default, recommended" for voice agents; docs call it "Universal-3.6 Pro Realtime"; sends SpeechStarted; no format_turns / end_of_turn_confidence_threshold; $0.45/hr
universal-3-5-pro18 (models page and FAQ) or 19 (select-model page), incl. Turkish per models page + FAQolder Pro; $0.45/hr (models page); mode, prompt, interruption_delay, previous_context_n_turns are 3.5-Pro-specific per the reference
universal-streaming-englishEnglishformat_turns, end_of_turn_confidence_threshold; $0.15/hr
universal-streaming-multilingualen, es, de, fr, pt, it (NO Turkish)same turn params as Universal Streaming; $0.15/hr
Reference and select-model page say the default when speech_model is omitted is universal-3-6-pro, but the
migration guide says Universal Streaming "defaults to English" (i.e. the default may differ for old integrations):
always pass speech_model explicitly and check Begin.configuration.model. Unrecognised query parameters are
silently ignored, which is why the check matters.

Query parameters (from the reference table; all optional)

ParamValues / default
speech_modelsee above; effective default universal-3-6-pro
encodingpcm_s16le (default), pcm_mulaw, opus, ogg_opus, aac
sample_rateinteger 8000-96000, default 16000; ignored for opus, ogg_opus, aac
language_codeslist e.g. ["en","es"]; steers (does not restrict) recognition; unset = native code switching; 3.6/3.5 Pro only; single element = monolingual session. URL serialisation (JSON array string like keyterms_prompt?): UNVERIFIED (docs show only a Python dict)
language_detectionbool, default false; adds language_code, language_confidence to turns (reference + select-model page: 3.6 Pro, 3.5 Pro and Universal Streaming Multilingual; the multilingual page lists only 3.6 Pro and Multilingual)
format_turnsbool, default false; Universal Streaming only (3.6 Pro always formats)
end_of_turn_confidence_thresholdfloat 0.0-1.0, default 0.4; Universal Streaming only (deprecated per migration guide). 1 = acoustic-only, 0 = silence-only (not recommended)
min_turn_silencems, clamped 50-10000. Defaults: Universal Streaming 400; 3.6/3.5 Pro by mode (see turn-detection page: min_latency 128, balanced 128, max_accuracy 512). The migration guide says 100 for U3.6 Pro and the LiveKit page says 100: the docs disagree, UNVERIFIED which is live
max_turn_silencems. Defaults: Universal Streaming 1280; Pro by mode (640 / 1280 / 2560 on the turn-detection page; 1000 per migration guide, 1536 per reference for 3.5 Pro): docs disagree
modemin_latency / balanced / max_accuracy (reference says 3.5 Pro only, yet its Begin example shows model 3.6 Pro with "mode": "balanced"; the mid-stream page says "U3 Pro"; the optimizing page shows it with 3.6 Pro; treat 3.6 support as UNVERIFIED)
vad_thresholdfloat 0-1; Universal Streaming 0.4; Pro 0.2 (reference, turn-detection; 0.5 with speaker_labels per reference) or 0.3 (migration guide)
interruption_delay0-1000 ms, Pro only, server adds a fixed 256 ms (reference)
include_partial_turnsbool, default true (false when redact_pii on)
keyterms_promptlist, max 100 terms, each max 50 chars (longer ignored), >100 -> error; raw-connection examples JSON-serialise it into a string (json.dumps([...])); percent-encoding not described. Included in price for 3.6 Pro and Multilingual; +$0.04/hr on Universal-Streaming English
promptstring max 1750 chars (reference), context not instructions (Pro; reference says 3.5 Pro)
agent_contextstring max 1750 chars; the agent's last TTS reply as context (3.6/3.5 Pro)
session_heartbeatbool, default false; Heartbeat message every 5 s
inactivity_timeoutinteger seconds 5-3600; no timeout if unset
speaker_labels, max_speakers, voice_focus, redact_pii*, filter_profanity, domain, llm_gateway, previous_context_n_turns (default 5, 0-100, 3.5 Pro)not needed for brisvox v1
The reference marks most parameters required: true in its machine spec (and types them all as string) while the
intro says they are all optional: the spec is wrong or loose; treat all as optional.
Recommended brisvox set for Turkish (derived, not copied): `speech_model=universal-3-6-pro&sample_rate=16000
&encoding=pcm_s16le&language_codes=["tr"](serialisation UNVERIFIED) +session_heartbeat` off. Telephony pass-through:
encoding=pcm_mulaw&sample_rate=8000 (8000 for G.711 is not stated for this API: UNVERIFIED).

Client -> server messages

Audio: BINARY frames of raw bytes (no JSON/base64). Control messages are TEXT frames (JSON):

{ "type": "ForceEndpoint" }
{ "type": "Terminate" }
{ "type": "KeepAlive" }
{
  "type": "UpdateConfiguration",
  "prompt": "Transcribe product names accurately.",
  "keyterms_prompt": ["AssemblyAI", "Krabby Patty"],
  "min_turn_silence": 700,
  "max_turn_silence": 1600,
  "agent_context": "Sure — what date would you like to book?"
}
  • Audio chunk rule: each chunk must be 50 ms to 1000 ms of audio (1600 bytes = 50 ms at 16 kHz 16-bit mono); a chunk outside that range closes the session with 3007. A text frame that is not valid JSON closes the session (3006).
  • Pace sends at real time (the pre-recorded-file streaming guide: send "at the same pace it was recorded"; faster causes "degraded transcription accuracy, unexpected session closures, or other errors"). Faster-than-real-time input is listed as a 3007 cause ("Audio Transmission Rate Exceeded: Received
  • opus: exactly one Opus packet per binary frame; ogg_opus: arbitrary chunks of the Ogg stream; aac: ADTS framing only (ADTS: UNVERIFIED, the reference says only "arbitrary chunks"). brisvox sends PCM, so these do not matter.
  • UpdateConfiguration is a delta (omitted fields unchanged). Updatable: prompt, keyterms_prompt (replaces list; [] clears: UNVERIFIED), min_turn_silence, max_turn_silence, end_of_turn_confidence_threshold (Universal Streaming only), vad_threshold, interruption_delay, mode, language_codes (empty list clears steering; effective next turn), session_heartbeat (reference only), agent_context (each update replaces the previous: UNVERIFIED). Model support differs (mid-stream page: mode/prompt/agent_context/interruption_delay/ language_codes are "U3 Pro" only). No acknowledgment message or error behaviour for invalid values is documented: UNVERIFIED.
  • KeepAlive: resets the inactivity_timeout timer; only needed when inactivity_timeout is set and no audio is flowing.

Server -> client messages (JSON text frames, discriminated by type)

Begin (once, right after connect):

{
  "type": "Begin",
  "id": "3207b601-2054-48df-ba77-8784dfcf9fb8",
  "expires_at": 1772570132,
  "configuration": {
    "model": "universal-3-6-pro", "mode": "balanced", "api_version": "2025-05-12",
    "speaker_labels": false, "redact_pii": false, "filter_profanity": false,
    "domain": null, "voice_focus": null
  }
}

SpeechStarted (message-sequence page: Universal-3.6 Pro only, Universal Streaming does not send it; whether 3.5 Pro sends it: UNVERIFIED; once per turn, immediately before the first Turn; only after the model produced an actual transcript, so noise alone does not trigger it; confidence = average word confidence of the initial transcript):

{ "type": "SpeechStarted", "timestamp": 1216, "confidence": 0.98 }

Turn (reference example; the reference's own example omits word_is_final, which the schema says is required):

{
  "type": "Turn",
  "turn_order": 0,
  "turn_is_formatted": true,
  "end_of_turn": true,
  "transcript": "Hello world.",
  "end_of_turn_confidence": 1,
  "words": [
    { "text": "Hello", "start": 0, "end": 500, "confidence": 0.99 },
    { "text": "world.", "start": 500, "end": 1000, "confidence": 0.98 }
  ]
}

Partial (turn-detection page; note it shows no words):

{ "type": "Turn", "turn_order": 0, "end_of_turn": false, "turn_is_formatted": false,
  "transcript": "my number is five five five", "end_of_turn_confidence": 0.0 }

Required Turn fields (reference schema): type, turn_order (int), turn_is_formatted, end_of_turn, transcript, end_of_turn_confidence, words. Other Turn fields: utterance (only on end_of_turn messages); language_code + language_confidence (only with language detection and a final turn; automatic on U3.6 Pro per the migration guide); speaker fields only with speaker_labels. Each word: text, start, end (milliseconds), confidence, and a required boolean word_is_final ("Whether the word is final"); speaker/speaker_confidence optional. Finer semantics of word_is_final (e.g. whether partial turns carry non-final words): UNVERIFIED. Heartbeat: {"type":"Heartbeat","total_audio_received_ms":45000,"total_duration_ms":45205,"realtime_factor":0.9964, "max_speech_probability":0.999954} (realtime_factor near 1.0 = sending in real time). SpeakerRevision: only with speaker_labels; ignore. Termination (always the last message; nothing follows; server then closes with code 1000; session_duration_seconds is the billed duration):

{ "type": "Termination", "audio_duration_seconds": 45, "session_duration_seconds": 47 }

Error (text frame sent just before the socket closes, per the message-sequence and errors pages). Its field schema (error_code, error) is NOT in the reference text re-fetched on 2026-10-08: field names UNVERIFIED, no JSON example. The close reason string is limited to 123 bytes, so longer errors carry the reason "See Error message for details" (errors page, verified). LLMGatewayResponse (type constant; per finalized turn, only when llm_gateway is configured; fields turn_order, transcript, data): ignore.

Turn semantics (mapping to brisvox SttEvent::Transcript)

  • Each turn = one SpeechStarted (3.6 Pro), one or more partial Turns (end_of_turn:false), one final Turn (end_of_turn:true). Every Turn re-transcribes the whole turn and supersedes the previous one of the same turn_order: REPLACE, do not append. turn_order increases monotonically; all messages of a turn arrive before the next turn's first message (the "before the next turn" ordering is from the earlier revision, UNVERIFIED).
  • Pro (U3.6 / U3.5): the final Turn has end_of_turn:true, is fully formatted (turn_is_formatted:true), end_of_turn_confidence 1.0. Partials have end_of_turn:false (3.5 Pro: turn_is_formatted always equals end_of_turn). Partials come early (timed by interruption_delay), on a pause when the turn reads incomplete, and roughly every second during continuous speech (partial cadence: UNVERIFIED in the 2026-10-08 re-check). Detection is punctuation/semantic based: at min_turn_silence a terminal mark (. ? !) ends the turn; max_turn_silence forces the end.
  • Universal Streaming: with format_turns=true you get an UNFORMATTED final (end_of_turn:true, turn_is_formatted:false) followed by a FORMATTED final for the same turn_order (confirmed by message-sequence page; the earlier quoted sentence "Treating the first as complete would process the turn twice" was not re-found verbatim: UNVERIFIED wording). turn_is_formatted is true on all turns, partials included, per the migration guide (conflicts with the previous sentence for the no-format_turns case): UNVERIFIED. A turn is complete only when end_of_turn:true AND turn_is_formatted:true (message-sequence page: "both end_of_turn and turn_is_formatted are true", written for Universal Streaming + format_turns). With format_turns=false the final has turn_is_formatted:false and there is no second message (message-sequence page, confirmed).
  • brisvox mapping proposal (derived): partial -> Transcript{is_final:false}; final turn -> one Transcript{is_final:true, speech_final:true} (an AssemblyAI turn is the utterance, so each final turn ends the utterance); a final produced after a ForceEndpoint sets from_finalize:true. Concerning from_finalize: there is no marker on the wire (no equivalent of Deepgram's from_finalize), so the adapter must itself remember that a ForceEndpoint is outstanding and flag the next end_of_turn Turn (UNVERIFIED which final answers it when a natural endpoint fires at the same moment).

Finalize / flush / cancel semantics

  • ForceEndpoint = brisvox Finalize. Docs: "ends the current turn immediately, without waiting for silence"; "without waiting for silence or punctuation"; the server responds with the end_of_turn: true message(s) for that turn immediately (turn-detection and message-sequence pages). Intended for "your own VAD or turn detection". It is documented on both the Universal Streaming and the U3.x Pro turn-detection pages. UNVERIFIED: behaviour when no turn is open / no speech heard since the last final (no answer? an empty-transcript final?); whether the audio still buffered in the server is included in the final; whether the final is formatted on Universal Streaming (message-sequence says "message(s)", i.e. possibly the two-message form). The adapter must time-box the wait (like the Deepgram adapter) and not assume an answer.
  • Terminate = hang-up/finish. After it the server flushes in-flight messages before Termination: the final formatted Turn for audio already sent; on Universal Streaming a closing Turn with empty transcript for an unfinished turn. "Closing the socket early discards your last transcript" (message-sequence page). Keep reading until Termination. Always send it: billing is by connection time and unterminated sessions auto-close at 3 h and are billed in full. Since the brisvox contract says nothing follows a hang-up, drain and discard after Terminate with a timeout (like close_timeout in the Deepgram adapter).
  • No "cancel/discard buffered audio" message. Discarding = close the socket and reconnect.
  • Mid-stream tuning (UpdateConfiguration) is the alternative to reconnecting for turn-silence changes.

Keepalive / timeouts

  • The quickstart page documents no ping/pong or keepalive requirement (silence on the topic, not a guarantee).
  • There is NO idle timeout by default (inactivity_timeout "no timeout if unset"). If set (5-3600 s) and nothing is received for that long, the session closes with 3006 "Session terminated due to inactivity: No messages received for seconds"; KeepAlive messages prevent it. Default brisvox plan: leave inactivity_timeout unset, but then an idle open socket is still BILLED (session-duration billing): keep the connection only for the call.
  • Whether the server drops a connection that never receives any audio: UNVERIFIED (not documented).
  • Max session length 3 hours (close 3008 "Session Expired: Maximum session duration exceeded", cause "3 hours or the maximum set by a temporary token"). The earlier claim that 3008 is "delivered after the current turn ends or within 60 s of the limit" and that extra time is billed was not re-found: UNVERIFIED. Temporary tokens can set a shorter limit via max_session_duration_seconds. Begin.expires_at is a Unix time (presumably that limit: UNVERIFIED). Calls longer than 3 h need a planned reconnect before expires_at.
  • session_heartbeat=true gives a server message every 5 s of wall-clock time: useful as liveness for a client watchdog (the Deepgram adapter's io_timeout pattern), at the cost of a few bytes. Whether Heartbeats continue while no audio flows: UNVERIFIED.

Timestamps / interim results

  • Word start/end and SpeechStarted.timestamp are milliseconds. SpeechStarted.timestamp is "ms from the start of the stream". Whether word times are relative to the connection start (they restart on a new connection): presumed, UNVERIFIED.
  • Latency: https://www.assemblyai.com/docs/streaming/benchmarks (fetched 2026-10-08) has no figures for 3.6 Pro ("not yet available") and nothing for Turkish; for Universal-3.5 Pro Streaming it gives Time to Complete Turn (end of speaker turn to finalized transcript) P50 568 ms, P90 829 ms. Its multilingual WER table lists only de, en, es, fr, it, pt. mode=min_latency is "Returns text as quickly as possible"; balanced is the default mode and the optimizing page presents it as aimed at voice agents.
  • Billing counts connection time, not audio, so a silent connection costs the same.

Concurrency / rate limits

  • No hard cap on total open streaming sessions for paid accounts; the limit is NEW sessions per minute: Free starts at 5/min, Paid at 100+/min. Auto-scaling: at >= 70% use the next minute's limit grows by 10%; 50-69% unchanged; below 50% it drifts back to the account's starting value (so after quiet periods you may not open many at once). Custom limits via sales/support (stated as no extra cost: UNVERIFIED in the 2026-10-08 re-check). Limits are per account (all API keys share them).
  • Exceeding it: the rate-limits page says close code 1008 with "Unauthorized connection: Too many concurrent sessions"; the errors page lists the same text under code 3009. Docs conflict: handle BOTH 1008 and 3009 with that reason as "rate limited, back off" (UNVERIFIED which one the server sends). Sessions that are not closed properly still count.
  • HTTP 429 for streaming: not documented.
  • Default $50 of free credit for new accounts (billing page; credits do not expire).

Close codes (errors page, v3)

CodeReasonCause / handling
1000(normal)after Termination (message-sequence page)
3005"Session Cancelled: An error occurred"unknown server error; also the catch-all
3006"Invalid Message Type: " / "Invalid JSON: " / "Invalid Message: " (e.g. []) / "Session terminated due to inactivity: No messages received for seconds"adapter bug or inactivity_timeout
3007"Input duration violation: chunk size outside 50-1000 ms, or sending faster than real time
3008"Session Expired: Maximum session duration exceeded"3 h or token limit; reconnect
3009"Unauthorized Connection: Too many concurrent sessions"rate limit exceeded (see above)
1008"Unauthorized Connection: Missing Authorization header"key missing/invalid
1008"Unauthorized Connection: "account issue e.g. insufficient balance or disabled account
1011"Internal error"unexpected server-side error while connecting (e.g. in auth); "Retry. If it persists, contact support"
410"Deprecated endpoint"v2 retired
An Error text frame precedes the close for error terminations; known closures such as 3008 do not trigger SDK
on_error (UNVERIFIED in the 2026-10-08 re-check). The old real-time API reference lists 4xxx codes (4001, 4008,
4029): those belong to the retired v2 API; do not use them. Retry guidance in the docs is only "retry" for 1011;
retry/backoff values elsewhere are UNVERIFIED (use bounded exponential backoff with jitter for 1011/3005/3009/1008-rate).
Whether a failed upgrade is reported as an HTTP status or as a close after 101: UNVERIFIED (1008 and 1011 are listed as
WebSocket close codes).

Reconnect

  • A new WebSocket is a new session with a new Begin.id, new turn_order numbering (presumed) and timestamps from zero (presumed): UNVERIFIED, no reconnect/resume guide was found. Interim hypotheses and any un-ended turn are lost.
  • Audio sent faster than real time can close the session with 3007; the tolerated factor is UNVERIFIED (no official figure; the earlier "1.25x / 5 min buffered" numbers were not found in the docs), so pace a replay of buffered audio after a reconnect at ~real time (same shape as the Deepgram replay ring). Replay chunks must respect the 50-1000 ms rule (re-chunk a replay buffer; a short tail chunk below 50 ms would trigger 3007).
  • Config mid-stream changes (UpdateConfiguration, keyterms) are lost on reconnect: re-apply via the query string.

Turkish

  • Turkish (tr) is supported in streaming ONLY by the Pro models: universal-3-6-pro (32-language list on the multilingual page, Turkish confirmed "Yes" there; the earlier enumerated list: af, ar, yue, ca, da, nl, en, et, fi, fr, gl, de, he, hi, it, ja, ko, zh, mr, no, nn, fa, pt, ro, ru, es, sv, tr, ur, vi, xh, zu was not re-verified item by item). universal-3-5-pro also lists Turkish (models page, language FAQ). universal-streaming-multilingual (en, es, de, fr, pt, it) and universal-streaming-english do NOT.
  • Caveats: the select-model page itself does not list Turkish (it only links to the full list); the models page and FAQ say 3.5 Pro has 18 languages while the select-model page says 19; the multilingual page says 3.6 adds 13 languages to the 3.5 Pro set and Turkish is not among the 13. The multilingual page lists no per-language accuracy figures or Turkish-specific limitations. The reference says 3.5 Pro excludes af, yue, et, gl, ko, mr, nn, fa, ro, ru, ur, xh, zu (13 codes; Turkish is not excluded).
  • Language steering: language_codes: ["tr"] for a monolingual Turkish session (the page says single element is "intended for monolingual sessions"; other effects not described). Without it the model code-switches freely.
  • Turkish accuracy/latency numbers: UNVERIFIED (none in fetched pages; the benchmarks page covers no Turkish). Plan a live comparison against Deepgram Nova-3 tr before choosing a default.

Pricing

  • Streaming is billed per hour of SESSION DURATION (time the WebSocket is open, idle time included).
  • https://www.assemblyai.com/pricing (2026-10-08): Universal-3.6 Pro Realtime (universal-3-6-pro) $0.45/hr; Universal-Streaming English $0.15/hr; Universal-Streaming Multilingual $0.15/hr. Add-ons for the whole session: Speaker Diarization +$0.12/hr, Medical Mode +$0.15/hr, Voice Focus +$0.10/hr (U3.6 Pro only), Keyterms Prompting included on U3.6 Pro / Multilingual and +$0.04/hr on Universal-Streaming English. Volume discounts: UNVERIFIED in the 2026-10-08 re-check. Recheck before quoting.

Gotchas / implementation notes

  • Replace, don't append, within a turn_order. Treat end_of_turn:true as the utterance end on Pro; on Universal Streaming + format_turns=true, wait for turn_is_formatted:true on the final (avoid double-processing). For Turkish brisvox uses Pro, where this does not apply.
  • Always read until Termination after Terminate; closing earlier loses the last transcript.
  • Pro models: use ForceEndpoint from brisvox's own VAD/Smart Turn; the provider's own endpointing (min_turn_silence/max_turn_silence) is a fallback. For a pure-ForceEndpoint setup the docs only describe end_of_turn_confidence_threshold=1 (Universal Streaming); no equivalent for Pro is documented (raising max_turn_silence is the closest knob): UNVERIFIED.
  • Chunking: 50-1000 ms per binary frame. brisvox frames of 20 ms must be batched (>= 50 ms) before sending. The quickstart page gives no chunk recommendation (its example sends 4096-byte reads); the file-streaming guide defaults to 100 ms chunks (50 ms = closest to live mic) and the Deepgram migration guide uses 800 frames = 50 ms at 16 kHz.
  • Control messages are text frames; audio binary. Never log the Authorization header or token.
  • The reference's llm_gateway and speaker/redaction params are irrelevant for v1; unknown params are ignored silently, so typos fail silently (verify Begin.configuration).
  • The docs describe the model naming as moving (3.5 -> 3.6 in the pages fetched); re-check the model list before coding, and pin speech_model explicitly.

UNVERIFIED summary

  1. Default speech_model when omitted (reference + select-model page say U3.6 Pro; migration guide says Universal defaults to English).
  2. language_codes URL serialisation (JSON array string vs repeated key). 3. Effective U3.6 Pro defaults for min_turn_silence/max_turn_silence/vad_threshold (turn-detection page, migration guide and reference disagree).
  3. ForceEndpoint behaviour with no open turn / no buffered speech, and what marks its answer (no from_finalize analogue). 5. Whether a formatted second message follows a ForceEndpoint final on Universal Streaming.
  4. Whether mode works on U3.6 Pro (reference: 3.5 Pro only, but its Begin example and other pages show it with 3.6).
  5. Idle behaviour of a connection that never receives audio. 8. Whether Heartbeat is sent while no audio flows.
  6. Rate-limit close code (1008 vs 3009) and whether upgrade failures come as HTTP status. 10. Reconnect/resume semantics, turn_order/timestamp reset, retry/backoff values. 11. pcm_mulaw at 8000 Hz (rate guidance absent).
  7. Temporary-token HTTP method (POST vs GET). 13. AssemblyAI-Version value format. 14. Error message field names and JSON example. 15. UpdateConfiguration acknowledgement and error behaviour. 16. Turkish accuracy and latency numbers; Turkish count discrepancy between 3.5 Pro pages (18 vs 19). 17. Fine semantics of Turn.words[].word_is_final. 18. 3.6 Pro streaming latency figures (benchmarks page has none; only 3.5 Pro P50 568 / P90 829 ms). 19. (resolved 2026-10-08: whole reference page read; no Error schema in it.) 20. Turn semantics for Universal Streaming turn_is_formatted without format_turns (pages conflict). 21. The ~1.25x throttle / 5-minute buffer claims (no official source found). 22. Whether 3.5 Pro sends SpeechStarted. 23. UpdateConfiguration keyterms_prompt: [] clearing the list; agent_context replace semantics. 24. 3008 delivery timing and extra-time billing. 25. Begin.expires_at equals the session limit. 26. "no extra cost" for custom rate limits; volume discounts.

Adapter (brisvox_providers::assemblyai, feature provider-assemblyai)

Code: crates/brisvox-providers/src/assemblyai/ (config, connection, protocol, assembler, batch, replay, session); tests: crates/brisvox-providers/tests/assemblyai.rs (local mock of the v3 protocol, the shared contract suite, one live test). Public: AssemblyAiStt, AssemblyAiConfig, Encoding, Region.

  • Auth: Authorization: <key> (sensitive header, no Bearer); key is a Secret<String>. speech_model is always sent (default universal-3-6-pro), Begin.configuration.model is compared with it (warning on mismatch).
  • Connect waits for Begin under the same io_timeout as the handshake. A close frame instead of Begin maps by code: 1008 -> Rejected{1008}, 1008 with "too many" or 3009 -> Rejected{429} (UNVERIFIED 9: both are handled), 3006/3007 -> Protocol, anything else -> Rejected{code}. Both ways a failed auth can arrive (HTTP status before the upgrade, close after it; UNVERIFIED) therefore end up as a connect error.
  • Query: speech_model, encoding, sample_rate, language_codes (JSON array string, e.g. ["tr"], bare primary subtag of the session language; serialisation UNVERIFIED 2, extra_params is the escape hatch), format_turns (only for universal-streaming* models), min_turn_silence, max_turn_silence, keyterms_prompt (JSON array string), session_heartbeat=true (default), inactivity_timeout (opt-in), plus extra_params.
  • Audio: 50 ms minimum per chunk (3007 otherwise). The engine's 10-20 ms frames are held until 50 ms are there (copy into a small buffer; a caller chunk already >= 50 ms is passed on uncopied); anything over 500 ms is split (never leaving a piece < 50 ms); whole samples only, and the 50 ms minimum is rounded UP to whole samples (at 22.05 kHz 50 ms is 1102.5 samples: 2206 bytes of PCM16, never the 2204 that is 49.98 ms). A sub-50 ms remainder is held until the next audio; it is padded with silence to 50 ms only immediately before a ForceEndpoint (or after the opt-in batch_flush, default off, at least 50 ms): padding on a short timer put fake silence mid-word under network jitter and sent audio ahead of real time (a 3007 cause). Cost: the first frame of a burst waits for the next one; <= 49 ms of fabricated silence per Finalize. The replay ring stores these chunks and drops whole chunks only, so a replay is made of valid chunks too.
  • Turns: partial Turn -> Transcript{is_final:false} (replace semantics; blank/repeated partials not forwarded); final Turn (end_of_turn) -> one Transcript{is_final:true, speech_final:true}. from_finalize is set on the first final after a ForceEndpoint (nothing on the wire marks it; UNVERIFIED 4). With format_turns on a Universal Streaming model the unformatted final is shown as an interim and the formatted twin is the final. Repeated finals of an ended turn_order, stale partials, and the empty closing Turn after Terminate are dropped. SpeechStarted, Heartbeat, SpeakerRevision, LLMGatewayResponse and unknown types are ignored.
  • Finalize: ForceEndpoint after flushing the held audio. Bounded wait: finalize_timeout (800 ms) while a turn is in progress OR audio was given since the last final / Finalize (a short utterance such as "evet" can end by the engine's VAD before the model produced its first partial; the benchmark for completing a turn is P50 568 ms), finalize_idle_timeout (200 ms) when nothing is open: no audio since the last final / Finalize, OR the last server event was a final turn with no partial after it (the engine forwards silence too, so "audio since the final" is true again one frame later; a Pro model that ended "Evet." by itself and a VAD stop that comes later would otherwise wait the full 800 ms for a ForceEndpoint reply that is UNVERIFIED for a line with no open turn). Cost: a short utterance with no partial that follows a natural final gets the empty synthetic final after 200 ms, its real final arriving later is delivered as a late utterance (see below). The server may stay silent or send an empty final: both are handled. Then a synthetic final (last interim or empty, speech_final, from_finalize). The server's late real final is dropped when its text equals the synthetic one (case/punctuation-insensitive, Turkish-aware), else delivered as another utterance end; this only holds for 3 s after the synthetic final, later finals are ordinary utterance ends without from_finalize. A final that arrives while a Finalize waits answers it (it may be a natural endpoint that raced the ForceEndpoint).
  • Keepalive / liveness: no idle timeout exists unless inactivity_timeout is set; then {"type":"KeepAlive"} is sent after keepalive_interval without audio. The response watchdog (io_timeout of server silence after audio was given) relies on Heartbeat messages (session_heartbeat=true, 5 s) because the server says nothing during silence; with heartbeat off a WebSocket Ping probes after io_timeout and a second io_timeout without any frame declares the connection lost (a Pong is standard WebSocket behaviour, AssemblyAI answering it is UNVERIFIED). The watch is paused when the line has been quiet for keepalive_interval (Heartbeats while no audio flows: UNVERIFIED 8). A silent connection is replaced like any lost one (Core(Timeout) when no reconnect is left). Tests cover the production ordering (keepalive_interval 4 s < io_timeout 10 s, scaled down): a half-open server is replaced at io_timeout while audio flows, and a single burst followed by a quiet line is not (until audio resumes). The timing defaults quoted in this file are asserted by a unit test in config.rs; the integration tests override them.
  • Reconnect: drops (close 1000/1011/3005/3008/none, I/O error, unrequested Termination) open a new session (default 1 attempt, backoff on the injected clock) and replay the audio the server has not finished: the ring is trimmed to the end of the last word of every final Turn (word times assumed to be ms from connection start: UNVERIFIED 10) and to the ForceEndpoint position when it is answered or given up on; bounded by replay_window (8 s). Replay is paced at replay_speed (default 1.0 = real time, as the doc says: faster than real time is a documented 3007 cause and the tolerated factor is UNVERIFIED 21; 1.0 never catches up while live audio keeps arriving, but cannot kill the call: a test feeds live audio during a paced replay against a mock that enforces the rate). Because a backlog never drains at 1.0, the replay does not resend what cannot matter: it starts 2 s before the first partial of the open turn (or before a pending Finalize's ForceEndpoint position), else 1 s before the old connection's send position (design decision, conservative: the model reports a partial within a few hundred ms of speech); audio the old connection never received (written after its last send, or left behind by a lagging replay) is always replayed. Without this, 8 s of pre-speech silence in the ring made every later word and ForceEndpoint reach the server 8 s late for the rest of the connection. If the ring evicts audio that a lagging replay had not sent yet, the connection's time base is moved past the gap (the server's clock does not run over it), so word times still map to the right ring position and a delivered turn is not replayed. A wordless final (the doc shows turns without words, UNVERIFIED 10/17) trims the ring to the ForceEndpoint position it answered or to the audio sent so far, so a delivered turn is not replayed and delivered twice. A pending ForceEndpoint is re-sent after the replay. 1008 and 401-403 are never retried. The attempt budget is restored by a Turn or a Heartbeat.
  • Close codes mid-session: 3006 whose reason says "inactivity" and 3007 whose reason says "Transmission Rate" are transient (reconnect with a re-paced replay); every other 3006/3007 is Protocol (an adapter bug) and ends the call.
  • Terminate: besides hang-up, a best-effort Terminate (<= 250 ms) goes out before a silent connection is replaced and after a fatal error is delivered, because billing is by connection time and zombie sessions count against the concurrent-session limit.
  • Hang-up: events close at once, Terminate is sent and the socket drained until Termination/close for at most close_timeout; nothing produced after the hang-up reaches the caller.
  • Not done: planned reconnect before the 3 h session limit (Begin.expires_at; 3008 is handled as a normal drop), temporary tokens, UpdateConfiguration, speaker labels, LLMGatewayResponse.

Gotchas found during implementation

  • serde derives Deserialize for structs from JSON arrays too: a [] text frame parsed as a typeless object until the parser required a leading {.
  • The contract suite sends 20 ms frames and expects an interim after one frame, so its harness enables the opt-in padded flush (batch_flush = Some(60 ms)); the adapter's default holds the remainder instead. The mock closes with 3007 like the real server when a chunk is outside 50..1000 ms or when audio runs ahead of wall-clock time, so the tests also verify the batching and the replay pace.
  • Close code and Error frame order: the Error text frame precedes the close; the adapter logs the frame (without content) and lets the close code decide.

Fact-checked 2026-10-08: 68 claims checked, 11 corrected. Corrections: (1) Turn.words[] DO have a required word_is_final field (file said no such flag); (2) language_detection models per reference/select-model page include 3.5 Pro (file listed only 3.6 Pro + Multilingual; multilingual page disagrees, both now noted); (3) SpeechStarted is stated for Universal-3.6 Pro only (file said "3.x Pro"); (4) the "~1.25x throttle / >5 min buffered -> 3007" claim is not in any official page, now UNVERIFIED and removed from reconnect guidance; (5) "quickstart recommends chunks of about 50 ms" was wrong (quickstart gives none; 100 ms / 50 ms come from the file-streaming and Deepgram-migration guides); (6) Error frame field names error_code/error could not be confirmed in the reference (now UNVERIFIED); (7) default model when omitted is also stated by the select-model page (universal-3-6-pro); (8) 3008 "delivered after the current turn / within 60 s" and "extra time billed" not found, now UNVERIFIED; (9) universal-3-5-pro price $0.45/hr added, benchmarks data (3.5 Pro TTCT P50 568 ms / P90 829 ms; none for 3.6 Pro or Turkish) added, reference page fully read (item 19 resolved), vad_threshold 0.5 with speaker_labels and previous_context_n_turns default 5 added; (10) KeepAlive resets the inactivity timer (clarified), LLMGatewayResponse type string corrected from llmGatewayResponse message name; (11) unconfirmed wording downgraded to UNVERIFIED: the "process the turn twice" quote, "ADTS", partial cadence "about every second", "no extra cost" custom limits, volume discounts, "SDK on_error" remark, keyterms_prompt: [].

Gladia — STT (live / real-time, REST init + WebSocket)

Trait: brisvox_providers::stt::SttProvider (SttInput::{Audio, Finalize}, SttEvent::{Transcript{text,is_final,speech_final,from_finalize}, Error}).

  • Docs (all fetched 2026-10-08; everything below comes from these pages unless marked):
    • Init a live session (REST): https://docs.gladia.io/api-reference/v2/live/init.md
    • Live WebSocket (AsyncAPI-rendered reference): https://docs.gladia.io/api-reference/v2/live/websocket.md (the fetch tool read only the first 100000 of 120611 characters on the first call; the tail with audio_chunk_ack .. end_session was read with offset=100000)
    • AsyncAPI spec: https://docs.gladia.io/asyncapi.yaml
    • Stop recording payload: https://docs.gladia.io/api-reference/v2/live/action/stop-recording.md
    • Quickstart / protocol: https://docs.gladia.io/chapters/live-stt/quickstart.md and https://docs.gladia.io/chapters/live-stt/getting-started
    • Endpointing: https://docs.gladia.io/chapters/live-stt/features/endpointing.md
    • Partial transcripts: https://docs.gladia.io/chapters/live-stt/features/partial-transcripts.md
    • Recommended parameters by use case: https://docs.gladia.io/chapters/live-stt/recommended-parameters.md
    • Deepgram -> Gladia migration: https://docs.gladia.io/chapters/migrations/from-deepgram.md
    • Get result: https://docs.gladia.io/api-reference/v2/live/get.md
    • Delete live job: https://docs.gladia.io/api-reference/v2/live/delete.md
    • Concurrency / limits: https://docs.gladia.io/chapters/limits-and-specifications/concurrency.md
    • Supported languages: https://docs.gladia.io/chapters/language/supported-languages.md
    • Code switching: https://docs.gladia.io/chapters/language/code-switching.md
    • Custom vocabulary: https://docs.gladia.io/chapters/audio-intelligence/custom-vocabulary.md
    • Data retention: https://docs.gladia.io/chapters/limits-and-specifications/data-retention.md
    • WebSocket handling (support article, possibly dated): https://support.gladia.io/article/how-to-handle-the-websocket-connection
    • Doc index used to find pages: https://docs.gladia.io/llms.txt
    • Pricing: https://www.gladia.io/pricing

Auth

  • Init call (POST /v2/live): header x-gladia-key: <GLADIA_API_KEY> and Content-Type: application/json (the init page shows x-gladia-key as the auth header; that Content-Type is marked required: UNVERIFIED, send it anyway). The docs recommend calling init from a backend so the key stays private.
  • WebSocket: no API key. The init response url carries a temporary session token: wss://api.gladia.io/v2/live?token=<uuid> (init reference: "The url will contain the temporary token to authenticate the session."; the quickstart example shows the token equal to the session id; AsyncAPI: "session token passed as the token query parameter"; no securitySchemes defined). The URL is therefore a secret: never log it (brisvox: redact the query).
  • Token lifetime / how long after init the socket must be opened: UNVERIFIED (not stated on any fetched page).
  • Failed init: JSON error body with timestamp, path, request_id, statusCode, message; 400 adds validation_errors (array of strings; 400 example message "Content-Type is missing Multipart Boundary."); 401 = "Missing or insufficient permissions" (example message "gladia key not found"); 422 = invalid parameters (example message "Invalid parameter"). 429 = concurrency/rate limit (see Limits).

Endpoint(s) / regions

  • Init: POST https://api.gladia.io/v2/live (optional query region, values us-west | eu-west; "The region used to process the audio"). Example in the docs: POST https://api.gladia.io/v2/live?region=us-west.
  • The returned url is wss://api.gladia.io/v2/live?token=... in all doc examples. Whether region changes the hostname of the returned url: UNVERIFIED (docs only show the global host). Default region when region is omitted: UNVERIFIED.
  • Result retrieval after the session: GET https://api.gladia.io/v2/live/{id} (header x-gladia-key; response has status queued/processing/done/error, request_params, result, file, created_at, completed_at, custom_metadata; 200/401/404). Not needed for the brisvox adapter (live transcripts arrive over the socket).
  • Flow: init (REST) -> open WebSocket -> stream audio -> handle messages -> stop_recording or close 1000 -> optional GET. Brisvox consequence: connect() costs one HTTPS round trip before the WebSocket handshake. warm() can resolve DNS / build the TLS config; pre-creating sessions is unsafe until token lifetime is known (UNVERIFIED).

Init request (POST /v2/live) — body fields

All optional per the reference. Defaults as stated there.

FieldType / default / values
encodingstring, default wav/pcm; enum wav/pcm, wav/alaw, wav/ulaw. PCM supports 8/16/24/32 bit, A-law and mu-law are 8 bit. "WAV headers aren't needed for raw audio"
bit_depthnumber, default 16; 8, 16, 24, 32
sample_ratenumber, default 16000; 8000, 16000, 32000, 44100, 48000
channelsinteger, default 1, min 1, max 8 (multi-channel billed as duration x channels)
modelstring, default and only value solaria-1 ("live transcription supports solaria-1 only")
endpointingnumber seconds, default 0.05, min 0.01, max 10
maximum_duration_without_endpointingnumber seconds, default 5, min 5, max 60
language_config{languages: [codes], code_switching: bool}; languages default [] ("If one language is set, it will be used for the transcription. Otherwise, language will be auto-detected by the model": one code = that language; none or several = auto-detect); code_switching default false ("If true, language will be auto-detected on each utterance. Otherwise, language will be auto-detected on first utterance"); that it is ignored when exactly one language is set: UNVERIFIED
pre_processing{audio_enhancer: bool (default false), speech_threshold: number 0..1 (default 0.6; "A value close to 1 will apply stricter thresholds")}
realtime_processingcustom_vocabulary (+custom_vocabulary_config), custom_spelling (+custom_spelling_config), translation (+translation_config), named_entity_recognition, sentiment_analysis (all default false)
post_processingsummarization (+summarization_config.type: general default / bullet_points / concise); chapterization deprecated and ignored
messages_configwhich WebSocket messages to receive (below)
callback, callback_configcallback default false; callback_config has url and the same receive flags with different defaults (receive_partial_transcripts false, receive_final_transcripts true, receive_speech_events false, receive_acknowledgments false, receive_errors false, receive_lifecycle_events true, others true) — unused by brisvox
custom_metadatafree-form object attached to the session

messages_config flags (all booleans) and defaults: receive_partial_transcripts false, receive_final_transcripts true, receive_speech_events true, receive_pre_processing_events true, receive_realtime_processing_events true, receive_post_processing_events true, receive_acknowledgments true, receive_errors true, receive_lifecycle_events false. => brisvox must set receive_partial_transcripts: true to get interims. Whether switching off acknowledgments / post-processing events is safe and reduces traffic: the flags exist; effect on latency is UNVERIFIED.

encoding, sample_rate, bit_depth, channels "describe the source audio and each configured value must match the audio being sent" (getting-started; the quickstart words it "must match the audio you send"); the server uses them to parse chunks. 16000 is "one supported configuration, not the only supported value".

Request example from the init reference (curl, trimmed to the relevant fields):

curl -X POST "https://api.gladia.io/v2/live?region=us-west" \
  -H "x-gladia-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "encoding": "wav/pcm",
    "bit_depth": 16,
    "sample_rate": 16000,
    "channels": 1,
    "model": "solaria-1",
    "endpointing": 0.05,
    "maximum_duration_without_endpointing": 5,
    "language_config": { "languages": ["en"], "code_switching": false },
    "pre_processing": { "audio_enhancer": true, "speech_threshold": 0.6 },
    "messages_config": {
      "receive_partial_transcripts": true,
      "receive_final_transcripts": true,
      "receive_lifecycle_events": true
    },
    "callback": false
  }'

Recommended brisvox body (derived from the docs, not copied; Turkish, PCM16 mono):

{"encoding":"wav/pcm","bit_depth":16,"sample_rate":16000,"channels":1,"model":"solaria-1",
 "endpointing":0.1,"maximum_duration_without_endpointing":15,
 "language_config":{"languages":["tr"],"code_switching":false},
 "messages_config":{"receive_partial_transcripts":true,"receive_final_transcripts":true,
                    "receive_speech_events":true,"receive_acknowledgments":false}}

(receive_acknowledgments:false and the 0.1 / 15 values are choices; 0.05-0.1 and 15 come from the "Voice Agents" row of the recommended-parameters page.) For telephony pass-through: encoding: "wav/ulaw", bit_depth: 8, sample_rate: 8000 (8000 is in the allowed list; that mu-law at 8000 Hz is the intended telephony pairing is not spelled out: UNVERIFIED).

Init response (201 Created)

Reference: id (string UUID, required), created_at (date-time, required), url (string URI, required; "WebSocket URL, including a temporary session token").

{
  "id": "45463597-20b7-4af7-b3b3-f5fb778203ab",
  "created_at": "2023-12-28T09:04:17.210Z",
  "url": "wss://api.gladia.io/v2/live?token=4a39145c-2844-4557-8f34-34883f7be7d9"
}

The quickstart example shows only id and url: parse created_at as optional.

Client -> server messages

Audio can be sent two ways (quickstart / getting-started / AsyncAPI):

  • Binary frame: raw audio bytes in the session's encoding/bit depth/sample rate/channels, no wrapper (socket.send(buffer)). This is what brisvox should use (no base64 overhead).
  • JSON text frame with base64:
{
  "type": "audio_chunk",
  "data": {
    "chunk": "<base64-encoded audio>"
  }
}

Chunk size: the support article says "small, continuous chunks (e.g. 20-50 ms of audio)"; the getting-started and quickstart pages specify no recommended or maximum size.

Stop:

{
  "type": "stop_recording"
}

Per the stop-recording page: "After reception, no more audio chunk will be accepted and post-processing will start." (the page description also says the payload is meant to close the WebSocket connection; getting-started says stop_recording tells Gladia to process the remaining audio and start post-processing.) Alternative: socket.close(1000); post-processing still runs in the background with results to the configured callback.

There is no documented client message for: finalize/flush-without-ending, keepalive/ping, cancel/discard, or mid-session reconfiguration (searched the AsyncAPI client messages: only audio_chunk JSON, binary audio, stop_recording). Hence the Finalize mapping below.

Server -> client messages (all JSON text frames, discriminated by type)

Common envelope: session_id (UUID), created_at (ISO 8601 UTC), type, and a data object (absent on start_session, start_recording, end_session).

  • transcript — data.id (utterance id), data.is_final (bool: "Indicates if the transcript is final or partial"), data.utterance {start, end (seconds from session start), confidence (0..1), channel (0-based int), words[] {word, start, end, confidence}, text, language (BCP-47)}. The AsyncAPI spec's only transcript example (a final):
{"session_id":"550e8400-e29b-41d4-a716-446655440000","created_at":"2025-09-19T12:34:10Z","type":"transcript","data":{"id":"00-00000011","is_final":true,"utterance":{"start":0.0,"end":0.48,"confidence":0.91,"channel":0,"words":[{"word":"Hello","start":0.0,"end":0.35,"confidence":0.91},{"word":"world","start":0.36,"end":0.48,"confidence":0.91}],"text":"Hello world.","language":"en"}}}

The quickstart/getting-started multi-channel example omits is_final and words (treat both as optional; a missing is_final must not be silently read as final: UNVERIFIED which default applies) and uses id format "00_00000000" (underscore) versus "00-00000011" (hyphen) in the spec: treat id as an opaque string:

{
  "type": "transcript",
  "session_id": "de70f43f-3041-46e0-892c-8e7f53800a22",
  "created_at": "2025-04-09T08:44:16.471Z",
  "data": {
    "id": "00_00000000",
    "utterance": {
      "text": "Hello, I'm Sami. I'm the first speaker",
      "start": 0.188,
      "end": 2.852,
      "language": "en",
      "channel": 0
    }
  }
}

No partial (is_final:false) example exists in any fetched page; shape is the same with is_final:false (per the partial-transcripts page: "The message contains a partial transcript, which is subject to change."), exact field subset for partials (e.g. words): UNVERIFIED.

  • speech_start / speech_end (on by default via receive_speech_events; data.time seconds from session start, data.channel int):
{"session_id":"550e8400-e29b-41d4-a716-446655440000","created_at":"2025-09-19T12:34:06Z","type":"speech_start","data":{"time":1.24,"channel":0}}
{"session_id":"550e8400-e29b-41d4-a716-446655440000","created_at":"2025-09-19T12:34:09Z","type":"speech_end","data":{"time":3.10,"channel":0}}

The recommended-parameters page calls the end event speech_stop once; every schema page says speech_end: use speech_end, ignore unknown types.

  • Acknowledgments (on by default; receive_acknowledgments), both carry acknowledged (bool) and error (object or null) and reuse the client message name as type:
    • type:"audio_chunk": acknowledged ("True if the server successfully received and buffered the audio chunk"), data.byte_range [start,end] inclusive, data.time_range [start,end] seconds inclusive. Doc example values: byte_range [0, 4095], time_range [0, 0.256], acknowledged true, error null.
    • type:"stop_recording": acknowledged true if accepted, false if rejected; data provider context, may be empty. Rejection example: error = {"status_code":409,"exception":"ConflictError","message":"Recording already stopped"}.
  • Lifecycle (only with receive_lifecycle_events:true, default false): start_session ("emitted once when the session is created and ready"), start_recording ("when audio capture/streaming begins"), end_recording (data provider-defined, example reason: "user_request", received_total_bytes: 1048576; "provider has stopped receiving audio"), end_session ("session is closed and no further data will be sent"; no data). Whether end_session is actually sent on the live socket as the last message before the close (versus callback/webhook only) is not stated: UNVERIFIED.
  • Post-processing: post_transcript (data.full_transcript, languages[], sentences, subtitles (srt/vtt/ass), utterances), post_final_transcript (data.metadata {audio_duration, number_of_distinct_channels, billing_time, transcription_time} + transcription and add-on results), post_summarization (data.results string). Example (spec):
{"session_id":"550e8400-e29b-41d4-a716-446655440000","created_at":"2025-09-19T12:45:00Z","type":"post_final_transcript","data":{"metadata":{"audio_duration":123.45,"number_of_distinct_channels":1,"billing_time":120.0,"transcription_time":3.2},"transcription":{"full_transcript":"hello world"}}}

(The post_transcript sub-fields languages, sentences, subtitles, utterances were not re-confirmed in the 2026-10-08 fact-check: UNVERIFIED.) The quickstart does not document the post-processing message types; brisvox ignores them.

  • Add-on messages (translation, named_entity_recognition, sentiment_analysis) only when enabled; unused.
  • Error object used inside messages: {"status_code": <int|string>, "exception": "<name>", "message": "<text>"} (example {"status_code":400,"exception":"InvalidArgument","message":"Unsupported sample rate"}). A standalone top-level error message type for the live socket: not found in the fetched pages (receive_errors flag exists): UNVERIFIED.

Result semantics (mapping to brisvox SttEvent::Transcript)

  • Partials and the final of one utterance share data.id. "A newer partial replaces the previous provisional text for that utterance." "When is_final is true, the final text replaces the provisional text." "The resulting transcript contains one entry for that utterance—do not append each update as a separate line."
  • is_final:false -> Transcript{is_final:false,...} replacing the in-progress text.
  • is_final:true -> the utterance is fixed ("The message contains the final, most accurate transcript for an utterance. This transcript will not change."). Gladia utterances are closed by endpointing (silence >= endpointing seconds, or maximum_duration_without_endpointing), so one Gladia final == one utterance == one endpointed segment. Mapping chosen for brisvox: every final -> is_final:true, speech_final:true, from_finalize:false. That each final marks the end of the speaker's turn (not just a pause) is an inference from the endpointing page, not a doc statement. Because low endpointing (0.05-0.1 s) may split a sentence on a hesitation (endpointing page: "can split sentences if the speaker hesitates briefly"), the engine's own turn logic (VAD + Smart Turn) should decide when the turn is over; consecutive Gladia finals can be concatenated like Deepgram is_final segments.
  • "Partial transcripts use a faster, smaller model than final transcripts" -> partial text can differ from the final; finals are more accurate (partial-transcripts page). Don't diff partial vs final.
  • Partial accuracy drops with multiple languages / code switching ("Partial transcripts accuracy deteriorates when multiple languages and/or code switching are enabled."): use languages:["tr"] only.
  • finalized utterance != end of session: finals arrive with the session still open.
  • Empty-text partials/finals and how silence/noise is reported: UNVERIFIED.

Finalize / flush / cancel semantics

  • No Finalize equivalent. stop_recording ends the session (no more audio accepted, then post-processing, then the server closes with 1000 per the quickstart), so it cannot be used for a per-utterance flush on a long-lived session.
  • The only latency lever is endpointing: the utterance closes automatically after endpointing seconds of silence. Docs: "Neither setting guarantees that a final transcript arrives within that many seconds" (transport and processing delay still apply).
  • Proposed adapter mapping for SttInput::Finalize (adapter-side, not a Gladia feature):
    1. If an open utterance exists (a partial with no final yet), wait up to a finalize_timeout for Gladia's own final (low endpointing makes this quick), then deliver it with from_finalize:true, speech_final:true.
    2. On timeout, synthesise Transcript{text: last partial, is_final:true, speech_final:true, from_finalize:true} (same approach as the Deepgram adapter) and drop the later real final for that data.id as a duplicate.
    3. If no utterance is open (final already delivered), answer at once with an empty/synthetic from_finalize final so the engine's Finalize is never left unanswered. Alternative (not recommended): stop_recording + a new init per utterance (extra REST round trip each time, counts against the 30-session concurrency limit while post-processing runs: UNVERIFIED).
  • Whether stop_recording flushes a pending (unfinalised) utterance into a last is_final:true transcript before the post-processing messages: UNVERIFIED (getting-started: tells Gladia to process the remaining audio).
  • Cancel/discard buffered audio: none; drop the socket. After a hang-up (input dropped) send stop_recording or close 1000, then discard any results (brisvox contract: nothing follows a hang-up).

Keepalive / timeouts

  • No application-level ping/keepalive message is documented. Support article (possibly dated): "roughly 30 seconds of inactivity" (no audio sent) closes the socket with code 4408; close code 4504 "after 1 minutes if not text is transribed, or no audio is sent" (the article does not say whether both conditions apply together). Neither code appears in the official reference (AsyncAPI defines no close codes). The article also advises enabling TCP keep-alive on long sessions. => Keep sending audio (even digital silence) continuously; do not rely on a pause. UNVERIFIED how the server treats silent audio vs the 4504 "no text transcribed" rule (a mostly silent call could hit 4504 even with audio).
  • Max session duration 3 hours on all plans ("a single live session cannot exceed 3 hours and is terminated at that point"; start a new session before the limit).
  • Socket close codes in the reference: 1000 = intentional close (e.g. after the session ended / post-processing done); "any other code: reconnect to the same URL". Reconnect: getting-started says you can reconnect to the same URL and resume where you left off; the support article says "the token remains valid" and to re-open the same session using the original URL. No retry intervals, backoff or max retries given; what is replayed/lost on resume (audio in flight, utterance state, timestamp base): UNVERIFIED.

Timestamps / interim results

  • utterance.start/end and words[].start/end are seconds from the session start (spec: "seconds from session start"). Whether they continue monotonically across a resumed socket: UNVERIFIED.
  • speech_start/speech_end data.time are in the same time base ("Time in seconds from session start").
  • ack time_range gives the audio time covered by each accepted chunk (debugging aid only).
  • Latency guidance from the docs: lower endpointing suits voice agents; "Voice Agents" row of the recommended parameters page: endpointing "0.05 - 0.1", maximum_duration_without_endpointing 15, partials on, custom vocabulary on; "If utterances get cut off mid-sentence, raise endpointing slightly." No numeric latency figures appear in the docs. Vendor marketing (seen only as search snippets, not fetched pages: UNVERIFIED): partials "< 120 ms" / "< 103 ms", "around 270 ms average". Measure with the sim/live test instead.

Concurrency / rate limits

  • Live concurrent sessions: Free 1, Paid 30, Enterprise on demand (concurrency page). Pre-recorded: 3 / 25 / on demand. Paid queue (300) is for pre-recorded only.
  • HTTP 429 from init (and, per support article, opening a second socket on Free) when the concurrency limit is reached; 429 can also mean API rate limiting, "handle separately". Body format and retry header: the concurrency page says to honor "any retry header documented for the endpoint" but none is specified for live init. Docs advice: bounded retries with exponential backoff and jitter. Request-rate limit value: not given.
  • Session cap 3 h; channels max 8.
  • Docs recommend the official SDK for reconnection/continuity/buffering; brisvox must implement these itself.

Turkish support

  • Turkish is tr; supported by solaria-1 (table row: Turkish | tr | Solaria-1 Yes | Solaria-3 No | auto-discovery and code-switch Yes | translation Yes). The page lists 100 languages and does not mention live/real-time, but live supports only solaria-1, which lists Turkish. Solaria-3 supports only English, French, German, Italian, Spanish.
  • Use language_config: {"languages":["tr"],"code_switching":false} for monolingual Turkish (one language set = it is used for the transcription; with none or several languages the model auto-detects; the code-switching page says not to enable code switching with an empty list because the detector evaluates every utterance against 100+ languages, causing misdetections especially between similar-sounding languages, and recommends a small set of expected languages for best accuracy and latency).
  • Quality of live Turkish (partials vs finals, casing I/İ) is not documented: UNVERIFIED; check in the live test.
  • Custom vocabulary works live (realtime_processing.custom_vocabulary + custom_vocabulary_config: vocabulary strings or {value, pronunciations[], intensity, language}, default_intensity; suggested intensity 0.4-0.6 (guidance, not a schema limit); no term-count cap stated, long lists increase false positives).

Pricing

  • https://www.gladia.io/pricing (2026-10-08): Starter pay-as-you-go real-time $0.75/hr; Growth "as low as" $0.25/hr with upfront commitment; Enterprise custom (annual plan); sign-up gives EUR 50 one-time free credits (FAQ: roughly "60+ hours of real-time transcription"). "Prepaid wallet": UNVERIFIED (not seen on the re-fetched page). Multi-channel is billed duration x channels (getting-started page, not the pricing page). Recheck before quoting.

Data retention

  • The retention page does not separate pre-recorded from live; it gives one table: Free: audio, transcription, API metadata and logs 1 year; Paid (Starter/Growth/Enterprise): audio and transcription 3 weeks, API metadata 1 year, logs "limited period". Whether it applies identically to live sessions: UNVERIFIED. Enterprise can request custom retention (0 = deletion within 24 h) or zero data retention (ZDR; results only via callback).

Gotchas / implementation notes

  • Two-step connect: REST init then WebSocket; both need timeouts (engineering invariant, see CONTRIBUTING.md). Treat init 401/403-style as Rejected{status}; 429 as Rejected{429} with a bounded retry in the caller.
  • The url contains the session token: redact in logs/errors (Protocol errors must not contain credentials).
  • Interims are OFF by default (receive_partial_transcripts false).
  • Ack messages reuse type values audio_chunk and stop_recording; disambiguate by presence of acknowledged (and absence of data.chunk), or switch acks off. Unknown types must be ignored.
  • Gladia final == endpointed utterance, not a flush response; Finalize needs adapter-side synthesis.
  • Declared audio params must match what is sent; a mismatch makes the server mis-parse audio (error text Unsupported sample rate appears in the spec as an InvalidArgument example).
  • Live sessions are stored (see retention). The index (llms.txt) lists "List transcriptions" and "Delete transcription" pages for live. Verified on https://docs.gladia.io/api-reference/v2/live/delete.md: DELETE https://api.gladia.io/v2/live/{id} with x-gladia-key; 202 = deleted, 401, 403 (job not in a deletable state), 404. The exact list route path: UNVERIFIED (not fetched).

UNVERIFIED summary

  1. Session token lifetime / delay allowed between init and socket open. 2. Whether region changes the returned url host; default region. 3. Exact shape of partial transcripts (no example anywhere), default of a missing is_final, empty-text messages. 4. Whether stop_recording flushes the pending utterance as a final transcript; whether end_session appears on the socket. 5. Standalone error message type on the socket and the close codes 4408/4504 (support article only, official reference defines none), plus 4504 behaviour with silent audio.
  2. Timestamp continuity and what is replayed after a reconnect-and-resume; retry/backoff values. 7. wav/ulaw at 8000 Hz as the telephony pairing. 8. Latency numbers (vendor marketing only; no doc figures). 9. Live Turkish quality and whether the retention table applies to live. 10. Effect of audio_enhancer, acks off and speech_threshold on latency. 11. Max chunk size. 12. code_switching ignored with exactly one language; Content-Type marked required; "prepaid wallet" on the pricing page; list-live route path; post_transcript sub-fields. (DELETE /v2/live/{id} is now verified.)

Gotchas found during implementation

Adapter: brisvox_providers::gladia (GladiaStt, GladiaConfig; feature provider-gladia); tests in crates/brisvox-providers/tests/gladia.rs (loopback mock: HTTP init + WebSocket) and the unit tests of the module. No live run was possible while writing (no key): everything below that depends on Gladia's real behaviour is marked.

  • Two-step connect under one deadline. connect() = POST /v2/live (reqwest, shared rustls config, no proxy, no redirects) then the WebSocket on the returned url; both together are bounded by io_timeout on the injected clock (a stalled init or a stalled upgrade ends in Core(Timeout) at the deadline). The returned URL is a secret (Secret<String>, dropped after the handshake, never formatted into errors or logs); it must be wss:// (ws:// only on loopback hosts, for the mock). The init id equals the token in the quickstart example, so it is not logged either. Init errors log only the status and Gladia's request_id; the error body is not logged (it may echo fields).
  • Orphaned sessions are deleted (review fix). POST /v2/live creates a session (one concurrency slot: 1 free / 30 paid) before the WebSocket exists. If the upgrade then fails (refused, stalled past io_timeout, TLS error) or the connect future is dropped (caller cancelled, hang-up during a reconnect), the session would sit on its slot until Gladia expires it (undocumented), and on the free plan the retry would get 429. The adapter keeps the init response's id (only if it is 1-128 ASCII letters/digits/-/_, so it can never alter the request path; treated as a secret like the token, since the quickstart shows them equal) and a drop guard sends one detached DELETE /v2/live/{id} with x-gladia-key, bounded by close_timeout, answer ignored, failures logged without the URL. A refused init created nothing and sends nothing; a successful connect disarms the guard (the session is then retired through stop_recording, as before). Without a Tokio runtime (guard dropped outside one) nothing is sent. UNVERIFIED: that the DELETE frees the slot of a session that never streamed (the page lists 403 for a job not in a deletable state); without it the leak is no worse than before. Best effort also means the retry may still race the DELETE and see one 429, which the reconnect loop retries (408/429 are retryable, other 4xx are fatal).
  • sample_rate_hz is an assertion, not an override (review fix). The adapter does not resample, so the rate declared at init (and the byte rate behind the replay ring, pacing, silence and the timeline) is always the SttConfig rate of the audio the caller sends. GladiaConfig::sample_rate_hz = Some(hz) is accepted only when hz equals that rate (and Gladia supports it); any other value fails connect() with a config Protocol error before any network call instead of declaring audio at a rate it is not in. The field is kept (the config struct is public API) rather than removed. Unsupported session rates (24 kHz) are still refused; the caller must resample.
  • Status mapping. Any non-2xx init answer and any non-101 upgrade answer is Rejected { status } (401, 403, 422, 429, 5xx...). Reconnecting stops at once on a 4xx other than 408/429 (credentials and request errors do not heal).
  • Finalize has no wire message. Nothing is sent for SttInput::Finalize. The adapter answers it itself: (1) utterance open (a partial without a final, or speech_start with no final since; a final clears that, because the order of speech_end and the final is not documented, and so does giving up on a Finalize), or audio the server has not answered yet may hold speech (the replay ring is not empty and the session has not sent a single transcript/speech event yet, or the session is being replaced, or it was replaced and has not said anything yet): wait up to finalize_timeout (default 800 ms, counted from the request, also across a reconnect) for Gladia's own final of that utterance and deliver it as from_finalize + speech_final; (2) on timeout deliver the last partial (or empty text) as a synthetic from_finalize final and drop the real final of that utterance if it arrives later (delivering corrected text would duplicate words in the engine's transcript; same decision as for Deepgram except that Deepgram delivers a differing late text); (3) nothing open and nothing in flight: empty from_finalize final at once. The "in flight" rules exist because partials lag the audio: a Finalize sent right after the first audio of a session, or while the caller's audio is being replayed into a replacement session, must not be answered empty while the utterance is still on its way (the contract suite exercises the first; the reconnect cases have tests of their own). Cost: a Finalize on a call whose Gladia never produced any message (VAD fired on noise) waits finalize_timeout once; a Finalize on an idle call right after a reconnect (everything already final) does not wait. Known limit (deliberate): on a live session that has spoken before, a short utterance whose first message has not arrived when the Finalize comes is answered empty and its final arrives later as an ordinary endpointing final. Waiting for "audio since the last final" on every Finalize would put finalize_timeout on every turn (the engine sends one Finalize per VAD stop and keeps streaming audio after it, while the provider's own final normally arrives first), and the engine accepts a speech_final that answers no Finalize (stt stage docs).
  • Every Finalize is answered exactly once (review fix). The engine's STT stage keeps one ledger entry per Finalize it sends and pops one per from_finalize + speech_final result; an unanswered entry makes it declare the session dead after its own 3 s timeout. A Finalize that arrives while another waits (a second VAD stop before the final, one sent while the session is being replaced, one on a session that has not heard anything yet) is therefore counted, not dropped. The first request owns the open utterance and the finalize_timeout deadline (counted from that first request); whatever answers it (the real final, a blank final, the timeout) is followed by one empty from_finalize + speech_final final per extra request. The count survives a reconnect and is capped at 32 (the engine's default finalize_capacity; a request beyond the cap is ignored, so a caller that respects the engine's bound loses nothing and the burst of empty answers stays bounded). Alternative considered: queueing the requests and timing each separately; rejected as more state for the same observable result, because the engine only counts answers.
  • Latency of the real answer is endpointing (default here 0.1 s, the voice-agent value) plus Gladia's processing; the engine keeps streaming silence after the VAD stop, which is what lets Gladia's endpointing fire. Whether endpointing counts audio time or wall time: UNVERIFIED (matters only if the engine stops sending audio after VAD).
  • Partials/finals. is_final missing is read as a partial (the docs' quickstart example omits it; reading it as a final could freeze text that is still provisional). Every real final is speech_final: true. Blank partials and blank finals are dropped unless a Finalize waits for them. Utterance ids are opaque and scoped to the session; after a reconnect the assembler forgets them (a final of any id then answers a pending Finalize).
  • Acknowledgments are opt-in (receive_acknowledgments default false, as in the recommended body). Gladia sends nothing while nobody speaks, so with acks off an idle line is indistinguishable from a dead server and the adapter's response watchdog is OFF (that watchdog needs the acks). Dead links are caught independently of them by a WebSocket liveness ping (liveness_ping, default on): after keepalive_interval without any frame from the server the adapter sends a Ping and any frame within io_timeout proves the link (RFC 6455 requires a Pong); none counts as a lost session, replaced like any drop (Core(Timeout) when no reconnect is left). UNVERIFIED: Gladia's handling of pings is undocumented; if a live run shows healthy calls replaced every keepalive_interval + io_timeout, set liveness_ping: false. With acknowledgments: true every audio chunk is acked, and io_timeout without any message while audio flows is a lost session (replaced like any drop, Core(Timeout) when no reconnect is left); the silence keepalive is then at most io_timeout / 2 so an idle caller stays alive. UNVERIFIED: whether Gladia acks binary frames the way it acks JSON audio_chunk messages; that is why acks are off by default. The first live run must check it (turn the flag on and watch for Core(Timeout)). acknowledged: false on an audio ack is fatal (Protocol, only the exception name and status in the text).
  • Keepalive is audio: after keepalive_interval (default 5 s, must be < 30 s) without audio the adapter sends 20 ms of digital silence in the session encoding (PCM 0x00, mu-law 0xFF, A-law 0xD5). Generated silence shifts the session timeline, and audio the replay ring evicted before it could be sent leaves a gap in it; both are recorded as runs at the moment they happen (session position, stream position, length), and an utterance end time is mapped back to a stream position through those runs, so later silence or gaps never move the mapping of older audio. Replaying too much is not a safe error here (the words would come twice), so there is no "errs toward over-replay" slack.
  • Reconnect = new session, bounded replay. Any unexpected close (also code 1000 mid-call, 4408/4504, a server end_session/end_recording, an error message with status >= 500) opens a new session with a new init call (default 1 attempt, backoff 100 ms doubling to 5 s) and replays the audio since the end of the last final (ring bounded by replay_window, 8 s) at replay_speed (default 2.0; Gladia documents no limit on how fast audio may be sent: UNVERIFIED, lower it if the live run shows throttling). The same-URL resume the docs mention is not used: the token lifetime and what a resumed socket replays are undocumented. Each attempt costs an init call and a concurrency slot (1 free / 30 paid), so the attempt budget is restored only by a transcript or ack, or by a session that lived 30 s. After the attempts run out the error is the last connect error (e.g. Rejected { status: 429 }) if there was one, else Disconnected (or Core(Timeout) after silence). A session that is replaced or failed while its socket may still work (silence, a server error message, a protocol error; not after a failed write or a close from the peer) gets stop_recording and a close frame first, bounded by close_timeout, so that it frees its concurrency slot before the new init call (otherwise 429 on the free plan).
  • Hang-up (the input or the events receiver dropped, also during a reconnect backoff or a stalled re-init, where no further init call is made) sends stop_recording and waits at most close_timeout for the server to close; events close first and nothing after the hang-up is delivered. Whether stop_recording flushes a pending utterance is irrelevant here.
  • Errors on the socket. A type: "error" message is accepted in both plausible shapes (error or data carrying status_code/exception): status >= 500 reconnects, anything else is fatal Protocol (UNVERIFIED shape; the free text message is never copied into errors). Only a frame that is not a JSON object is fatal Protocol. Fields are read leniently because the partial shape is UNVERIFIED: text: null or a number reads as blank, a numeric id is kept as its decimal text, a non-boolean is_final is a partial, a type that is not a string or a transcript without a body is ignored. Unknown types, lifecycle, post-processing and add-on messages are ignored.
  • warm() sends a credential-less HEAD to the init URL (DNS + TLS + pooled connection) and builds the shared TLS config; it never creates a session (concurrency slot, unknown token lifetime). The test records the HEAD requests and asserts no x-gladia-key, Authorization or Cookie header, no header value or target containing the key.
  • The key's two protections have tests. The init client uses no system proxy and follows no redirects: reqwest strips only Authorization, Cookie and WWW-Authenticate on a cross-host redirect, not a custom header such as x-gladia-key, and a proxy from the environment would see the header. redirecting_init_endpoint_is_not_followed answers the init call with a 307 to a second listener and asserts Rejected { status: 307 } and zero requests at the second listener; tests/gladia_proxy.rs (its own test binary, because it sets HTTP_PROXY/ALL_PROXY for the process) asserts the init call reaches its target and not the proxy. Both were mutation-checked (removing the builder line fails them).
  • Init body is built from typed structs: model solaria-1, endpointing 0.1, maximum_duration_without_endpointing 15, language_config only when a language is known (tr-TR is sent as tr), partial/final/speech/error messages on, pre-/realtime-/post-processing events and lifecycle off (flags named in the reference; their effect on latency is UNVERIFIED), acks per the config, optional custom_vocabulary. Sample rates other than 8000/16000/32000/44100/48000 are refused before any network call.
  • Tests. The mock answers POST /v2/live (script per call: ok, status, garbage body, bad url, stall, hang, redirect), HEAD and DELETE (both recorded) and upgrades GET; reconnect tests cover the 401 (fatal after two inits) and the 408/429 (retried, session recovers after three inits) sides of the status split, and the Finalize-across-a-reconnect test drops the first session 400 ms after the request and stalls the re-init 300 ms, so a deadline restarted at the reconnect (about 1.6 s) cannot pass for the request-anchored one (about 0.9 s); the shared STT contract suite runs against it with acks on and no reconnect. The live test (BRISVOX_LIVE_TESTS=1, GLADIA_API_KEY) streams the first Turkish segment of fixtures/tr_two_utterances.wav in real time plus a second of silence, sends a Finalize and requires a non-empty Turkish final and an answered Finalize.

Fact-checked 2026-10-08: 60 claims checked, 8 corrected. Corrections: (1) data retention page covers the whole service in one table (Free 1 year incl. logs; paid 3 weeks) and does not split pre-recorded/live; the old "describes only pre-recorded retention" claim was wrong; (2) stop_recording quote replaced with the page's actual wording; (3) final-transcript quote replaced with the page's wording ("final, most accurate transcript for an utterance. This transcript will not change."); (4) DELETE /v2/live/{id} is documented (202/401/403/404), previously marked UNVERIFIED; (5) multi-channel billing attributed to getting-started, not the pricing page, and "prepaid wallet" downgraded to UNVERIFIED; (6) 401 meaning is "Missing or insufficient permissions"; (7) code_switching "ignored with one language" downgraded to UNVERIFIED, and Content-Type "marked required" downgraded to UNVERIFIED; (8) callback_config defaults listed fully (receive_acknowledgments and receive_errors false), plus token equals session id in the quickstart example and GET /v2/live/{id} response details added.

OpenAI — LLM (streaming chat with tool calling)

Researched 2026-10-08, fact-checked 2026-10-08 (second pass, see last line). Every fact below came from a page fetched that day; URLs are listed per section and in "Sources". Pages were read through a summarising fetch tool, so JSON blocks marked "(doc)" were copied from the docs and blocks marked "(illustrative)" were rebuilt from field lists and are NOT verbatim wire captures. Anything not confirmed is marked UNVERIFIED. A live capture must confirm wire shapes before they are trusted.

  • Docs: https://developers.openai.com/api/docs (old platform.openai.com/docs/... URLs 301 to developers.openai.com/api/docs/..., re-confirmed for guides/streaming-responses; platform.openai.com/docs/api-reference/* returned 403 to the fetcher in the first pass, not re-checked) (fetched 2026-10-08)
  • Auth: Authorization: Bearer <API key> (the overview page also allows a short-lived access token from workload identity federation). Optional OpenAI-Organization: <id>, OpenAI-Project: <id> (needed for multiple orgs or legacy user API keys). Keys are server-side only. Source: https://developers.openai.com/api/reference/overview
  • Endpoint(s) / regions: base https://api.openai.com/v1/ (the overview page does not state the base URL as text; its examples use https://api.openai.com/v1/...; response header openai-version: 2020-10-01).
    • Responses: POST /v1/responses (reference page: POST /responses; the websocket-mode guide names "one persistent connection to /v1/responses").
    • Chat Completions: POST /v1/chat/completions (confirmed: model page endpoint list shows v1/chat/completions; openai-python api.md lists post /chat/completions relative to the base URL).
    • Regional/data-residency hostnames: UNVERIFIED (not listed on the overview page). The claims "EU data residency exists for Standard/Flex/Batch" and "regional processing costs a 10% premium" were NOT re-verified in the fact-check pass: UNVERIFIED. Confirmed: Fast mode is not available with EU data residency for GPT-6 Astra, GPT-6.1 Sol, GPT-6 Sol, or GPT-6 Luna (fast-mode guide).

Decision (surface recommendation)

Use the Responses API (POST /v1/responses, stream: true, store: false, stateless full history replay) for the brisvox OpenAI adapter.

Reasons, each backed by fetched docs:

  1. OpenAI: "While Chat Completions remains supported, Responses is recommended for all new projects." (migrate-to-responses guide).
  2. Tool calling on the current models needs Responses. latest-model guide: "GPT-6 Astra and GPT-6.1 Sol support Chat Completions, but tool calling requires Responses." Model pages: Sol = Chat Completions "without tool calling"; Luna = Chat Completions function calling "only when reasoning_effort is none". The migrate guide says the same from GPT-5.4 on: "Starting with GPT-5.4, Chat Completions does not support tool calling with reasoning_effort values other than none." (Note: the Astra model page lists function_calling as a supported feature on v1/chat/completions without qualification, which conflicts with the latest-model guide; follow the guide and use Responses.)
  3. Reasoning items (needed between tool turns) are first-class items in Responses; Chat Completions has no field to carry them (UNVERIFIED as absence, but no such field appears in the chat message param types fetched).
  4. Docs claim better cache utilisation on Responses ("40% to 80% improvement" compared to Chat Completions in internal tests; OpenAI's own internal number, not independently verified).
  5. store: false + replaying our own history matches the brisvox invariant "after an interruption the LLM context contains ONLY what was actually played": we build input ourselves from the engine's played-text history. Do NOT use previous_response_id or conversation (server-side history would contain the unplayed text of an interrupted answer). Note instructions is not carried over when using previous_response_id anyway (SDK docstring for instructions; the migrate guide says "previous_response_id does not carry over the previous response's top-level instructions" and advises resending stable instructions on each request).
  6. Chat Completions stays a possible fallback for OpenAI-compatible vendors (Groq, vLLM, Ollama etc.), which is a separate adapter ("openai-compatible", Priority 2 in PROVIDERS.md) and is documented in the secondary section below.

Model choice for voice (names from https://developers.openai.com/api/docs/models, .../models/gpt-6-luna, .../models/gpt-6.1-sol, .../models/gpt-6-astra and .../guides/latest-model):

Model IDRole per docsPrice /1M in / cached in / outReasoning effort values
gpt-6-luna"most efficient model for focused, high-volume tasks"; latest-model guide: "Fastest and most cost-effective"$0.10 / $0.01 / $0.50 (cache write $0.125)none, low, medium (default), high, xhigh, max
gpt-6.1-sol"Near-Astra performance for complex work at a lower cost"$2 / $0.10 / $10 (cache write $2.50)low, medium (default), high, xhigh, max (no none/minimal)
gpt-6-astra"Our most capable model for the most demanding work"$10 / $1 / $50 (cache write $12.50)low, medium, high, xhigh, max (default not stated); none returns HTTP 400 (reasoning guide: "returns HTTP 400" on Responses and Chat Completions)
All three: 1,050,000 context (max input 922,000), 128K max output. Modalities text+image in, text
out were confirmed on the Astra page only (Luna and Sol pages not checked for modalities:
UNVERIFIED). Prompts over 272K input tokens cost 2x input/1.5x output (Luna page; assumed same for
others: UNVERIFIED). The models page lists only these three text models with full details and no
"mini"/"nano" tiers. The latest-model guide also mentions an older gpt-6-sol (supports none)
and the rate-limits page mentions "Terra" (not described anywhere fetched).
Recommended default for brisvox voice: gpt-6-luna with reasoning.effort = "none" (or
"low" if answer quality needs it), because it is the only model on the models page with none
(the latest-model guide says gpt-6-sol also supports none) and the cheapest. No latency numbers
(TTFT, tokens/s) are published on the pages fetched, so "lowest latency" is an inference:
UNVERIFIED. The latest-model guide calls Luna "Fastest" but does not tie that to none. Measure in
the live test. Model IDs must be configuration, not hard-coded.
Latency levers from docs: service_tier: "fast" (fast-mode guide: "up to 2.5x faster speeds" than
Standard, stated for gpt-5.6-sol; "Fast mode costs twice the corresponding Standard rate" for
GPT-5.6 Sol; Luna and Sol pages say Fast mode is priced at 2x; priority provides the same
behaviour for supported models, and the response reports priority for GPT-5.6 and earlier; the
SDK also lists ultrafast, gpt-5.6-sol only). Fast mode is not available with EU residency (see
above). Ramp traffic no faster than +50% per 15 min once at 1M input TPM or some requests are
downgraded to standard speed and the response shows service_tier:"default"
(https://developers.openai.com/api/docs/guides/fast-mode). Whether Luna supports Fast mode: Luna's
model page says "Fast mode: 2x applicable rates" and the fast-mode guide's EU sentence names Luna,
so it seems yes, but the guide has no explicit supported-model list: UNVERIFIED; streaming TTFT
effect also UNVERIFIED.
Voice/realtime models exist separately (gpt-realtime-2.1, gpt-realtime-2.1-mini,
gpt-live-1, gpt-realtime-2, gpt-realtime-translate, gpt-realtime-1.5, deprecated
gpt-realtime-mini; IDs confirmed on the models page); see "Realtime" below.

Responses API streaming (primary surface)

Sources: https://developers.openai.com/api/docs/guides/streaming-responses , https://developers.openai.com/api/docs/guides/function-calling , openai-python types at https://github.com/openai/openai-python/tree/main/src/openai/types/responses (response_*_event.py, response_stream_event.py), all fetched 2026-10-08.

Request (fields from response_create_params.py)

model, input (string or item list), instructions, store (default true: set false), stream: true, stream_options.include_obfuscation (bool, on by default; adds random padding in an obfuscation field on deltas to hide payload sizes; can be disabled on trusted links to save bandwidth), tools, tool_choice, parallel_tool_calls, reasoning, max_output_tokens (includes reasoning tokens), prompt_cache_key, prompt_cache_retention (deprecated in favour of prompt_cache_options.ttl; values in_memory, 24h; 24h is the only option for gpt-5.5 and later), prompt_cache_options (mode = implicit (default) | explicit, ttl = 30m only, prewarm (default false; when true sets generate to false so no output is produced), comparison_response_id; gpt-5.6 and later), safety_identifier (<= 64 chars), service_tier (auto (default), default, flex, priority, fast (request opt-in), ultrafast (gpt-5.6-sol only)), truncation (auto | disabled, default disabled; disabled overflow -> HTTP 400), text, temperature (0..2; remove temperature, top_p, top_logprobs when reasoning effort is not none per latest-model guide), include, context_management (only compaction supported, with compact_threshold), background, previous_response_id (cannot be combined with conversation). Input message items (EasyInputMessageParam): {"type":"message","role":"user|assistant|system|developer","content":<string|content list>} (type is optional in the SDK TypedDict); optional phase (commentary | final_answer, assistant only). The text guide lists the roles developer, user, assistant and says developer messages are prioritised ahead of user messages. Content-part type strings (SDK param files): input_text ({"type":"input_text","text":...}) and output_text ({"type":"output_text","text":...,"annotations":[...]}; annotations is required in the SDK param type, logprobs optional).

SSE framing

SDK decoder behaviour (openai-python _streaming.py): standard SSE with fields event, data, id, retry; other fields ignored; lines starting with : are comments (this is where keep-alive comments would appear; whether OpenAI sends any: UNVERIFIED); event dispatched on blank line; multiple data lines joined with \n. A data payload that is JSON with a truthy error key is treated as an error even mid-stream (SDK raises APIError, message from error.message, default "An error occurred during streaming"). The SDK ends iteration on any event whose data starts with [DONE] (both Chat and Responses use the same Stream class). For Chat Completions the stream ends with a data: [DONE] sentinel. For Responses the streaming guide does not mention [DONE]; termination is the terminal lifecycle event (response.completed / response.incomplete / response.failed) then connection close: [DONE] use on Responses is UNVERIFIED, so the adapter must tolerate both. (The streaming guide only shows response.completed in an example and does not formally define terminal events; the terminal-event set is inferred from the SDK event types.) The type JSON field discriminates events (SDK union ResponseStreamEvent has 59 members, counted 2026-10-08; sequence_number on every event gives total order).

Events brisvox needs (fields from SDK type files)

typeFieldsUse
response.created, response.in_progress, response.queuedresponse (Response), sequence_numberignore (record response.id for logs)
response.output_item.addeditem, output_index, sequence_numberstart of an item (message / function_call / reasoning). For function_call, item carries id, call_id, name, arguments:""
response.content_part.added / .donecontent part infoignore
response.output_text.deltadelta (text), item_id, output_index, content_index, logprobs, sequence_number-> LlmEvent::TextDelta
response.output_text.donetext (final text), item_id, output_index, content_index, logprobs, sequence_number (class ResponseTextDoneEvent)ignore, we accumulate
response.refusal.deltadelta, item_id, output_index, content_index, sequence_number (response.refusal.done fields not checked: UNVERIFIED)treat as text or error: policy decision
response.function_call_arguments.deltadelta (arguments fragment), item_id, output_index, sequence_number-> LlmEvent::ToolCallDelta
response.function_call_arguments.donearguments (full string), item_id, output_index, sequence_number (no name field)validate JSON; the doc says it carries the full arguments
response.output_item.donecompleted item, output_indexfor reasoning items, take encrypted_content from here, not from .added (SDK docstring: value in .added may be incomplete)
response.completedresponse (full Response incl. usage, output), sequence_numberterminal success
response.incompleteresponse, sequence_number; response.incomplete_details.reason in max_output_tokens, max_messages, content_filter, steered (stopped at a safe boundary after a WebSocket response.steer event)terminal; max_output_tokens -> FinishReason::Length
response.failedresponse (response.error has code (enum, e.g. server_error, rate_limit_exceeded, invalid_prompt, data_residency_mismatch, image errors), message, optional misalignment), sequence_numberterminal error
errorcode (opt), message, param (opt), sequence_number-> LlmEvent::Error
response.reasoning_summary_text.delta etc., response.reasoning_text.deltareasoning textignore (summaries only if opted in)

Response status values: completed, failed, in_progress, cancelled, queued, incomplete.

Examples from the function-calling guide (doc; the guide shows seven delta lines for one call with delta values {", location, ":", Paris, ,, France, "}; one of them is shown here):

{"type":"response.output_item.added","response_id":"resp_1234xyz","output_index":0,"item":{"type":"function_call","id":"fc_1234xyz","call_id":"call_1234xyz","name":"get_weather","arguments":""}}
{"type":"response.function_call_arguments.delta","response_id":"resp_1234xyz","item_id":"fc_1234xyz","output_index":0,"delta":"Paris"}
{"type":"response.function_call_arguments.done","response_id":"resp_1234xyz","item_id":"fc_1234xyz","output_index":0,"arguments":"{\"location\":\"Paris, France\"}"}

Note: the guide's examples include response_id while the SDK event classes list item_id, output_index, sequence_number only; treat unknown fields as ignorable (serde: no deny_unknown_fields). Whether events carry the event: SSE line equal to type: UNVERIFIED. Accumulation rule from the guide: its sample code appends each response.function_call_arguments.delta event's delta to the matching call's arguments string, keyed by output_index (the doc text says "you're aggregating chunks into an encoded arguments JSON object").

Mapping to brisvox LlmEvent (crates/brisvox-providers/src/llm.rs)

  • TextDelta <- response.output_text.delta.delta.
  • ToolCallDelta { index, id, name, arguments }: index = output_index (or a dense counter over function_call items only; output_index also counts message/reasoning items, so a dense counter is cleaner), id = item.call_id (NOT item.id; tool results are keyed by call_id), name = item.name, both emitted from response.output_item.added (with empty arguments) and followed by pure fragments. Parallel calls are sequential items in OpenAI's examples but interleaving must still be assumed possible (key by item_id).
  • Done { finish, usage } <- terminal event. FinishReason: Responses has no finish_reason; derive ToolCalls if the output contains any function_call item, Length if response.incomplete with max_output_tokens, else Stop. content_filter has no brisvox variant (gap; map to Stop + log or extend the enum).
  • Usage: input_tokens, input_tokens_details.cached_tokens, output_tokens (output_tokens_details.reasoning_tokens is included in output_tokens, UNVERIFIED but docs say reasoning tokens "are billed as output tokens"). input_tokens_details.cache_write_tokens also exists (required int in the SDK type; gpt-5.6+), plus total_tokens.
  • Stream ending without a terminal event -> report as cut off (brisvox LlmStream contract).

Tool definitions and results

Responses (doc, function-calling guide)

{
  "type": "function",
  "name": "get_weather",
  "description": "Retrieves current weather for the given location.",
  "parameters": {
    "type": "object",
    "properties": {
      "location": {"type": "string", "description": "City and country e.g. Bogotá, Colombia"},
      "units": {"type": "string", "enum": ["celsius", "fahrenheit"], "description": "Units the temperature will be returned in."}
    },
    "required": ["location", "units"],
    "additionalProperties": false
  },
  "strict": true
}
  • Strict mode: additionalProperties: false on every object, every property in required; optional = add "null" to the type (["string","null"]). Non-compliant schema with strict: true is rejected (that last point was not re-confirmed in the fact-check: UNVERIFIED). If strict is omitted in Responses the API "will attempt to normalize your schema into strict mode when possible" and "will fall back to non-strict, best-effort function calling" otherwise; Chat Completions "remain non-strict by default". Brisvox ToolSpec.parameters_schema is arbitrary JSON Schema, so the adapter must either set strict:false or normalise: decision for the adapter (default strict:false unless the schema already complies; arguments must be validated regardless, docs warn they may be invalid).
  • parallel_tool_calls: false -> "exactly zero or one tool is called". Default allows several. "Built-in tools cannot be included in a parallel function-call batch."
  • tool_choice: "auto" (default; zero, one or multiple functions), "required" (one or more), {"type":"function","name":"get_weather"} (exactly one), "none", or allowed_tools (restricts the callable subset WITHOUT changing the tools list; "recommended for prompt caching" is from the first pass and was not re-confirmed on the function-calling page: UNVERIFIED):
"tool_choice": {"type":"allowed_tools","mode":"auto","tools":[{"type":"function","name":"get_weather"},{"type":"function","name":"search_docs"}]}
  • Function name constraint (SDK chat FunctionDefinition docstring, not stated in the function-calling guide): a-z A-Z 0-9 _ -, max 64 chars.
  • async: true on a function or custom tool lets the model keep reasoning/call other tools while the application runs the tool; the result is returned "using the original call_id" (latest-model guide, listed under GPT-6 in general, not specifically Luna; not mentioned in the function-calling guide). The SDK ResponseFunctionToolCall has an async field. Remaining semantics: UNVERIFIED; do not use in v1.

Returning tool results (Responses)

Append the model's function_call item(s) (and any reasoning items returned with them) to input, then a result item keyed by call_id (field names from the guide's JS sample and the SDK FunctionCallOutput type; the guide has no standalone JSON example):

{"type": "function_call_output", "call_id": "call_12345xyz", "output": "{\"temperature_c\":18}"}

output is a string ("should typically be a string ... format is up to you") or a content list (SDK: Union[str, ResponseFunctionCallOutputItemListParam], "Text, image, or file output"). Replay rule (reasoning guide): "you should pass back all reasoning items, function call items, and function call output items, since the last user message." Simplest: pass all reasoning items of the previous response back; "Our systems will smartly ignore any reasoning items that aren't relevant to your functions." function_call item replay fields (SDK ResponseFunctionToolCall): type:"function_call", call_id, name, arguments (JSON string), optional id, status, namespace, caller, async. Whether id must be echoed on replay with store:false: UNVERIFIED (OpenAI examples append response.output unchanged, which includes it; the safe approach is to echo the items exactly as received from response.output_item.done).

Implementation: brisvox_providers::openai

Feature provider-openai; code in crates/brisvox-providers/src/openai/ (config.rs, request.rs, sse.rs, events.rs, mod.rs); tests in crates/brisvox-providers/tests/openai/.

API surface decision (recorded): Responses API, POST {base}/v1/responses, stream: true, store: false, full history replayed every turn, no previous_response_id / conversation. Reasons are the six listed under "Decision" above; the adapter does not implement Chat Completions (that is the separate "openai-compatible" adapter).

Types

OpenAiLlm::new(OpenAiConfig, SharedClock) -> Result<OpenAiLlm> implements LlmProvider. OpenAiConfig::new(Secret<String>) defaults: model gpt-6-luna (the doc recommendation; a config value, never hard-coded in logic), reasoning.effort = "none" (ReasoningEffort, None = field omitted), parallel_tool_calls = true (sent only when tools are present), StrictTools::WhenCompliant, replay_reasoning = true, include_obfuscation = false, connect 2 s, response headers 10 s, first output 30 s, idle (between events) 10 s, total 5 min, SSE event cap 4 MiB, pool idle timeout 10 min. Optional max_output_tokens, prompt_cache_key, service_tier, organization, project. base_url is configurable (any server that implements the Responses API, such as a local gateway, works; Chat-Completions-only servers do not). Plain http:// is accepted for loopback hosts only (localhost, 127.0.0.0/8, ::1; the host is taken from the parsed URL, so http://127.0.0.1@evil.example is remote); for another host OpenAiLlm::new returns Protocol unless allow_insecure_http is set, because the bearer key would otherwise travel in cleartext. Other schemes are refused. Debug of the config and adapter prints the key as [REDACTED]; the key lives only in a sensitive Authorization header value.

Interface extension (documented because llm.rs changed)

The provider-neutral Message could not carry what a stateless Responses replay needs, so it grew two fields, plus one on Done; nothing else changed:

  • Message::tool_calls: Vec<ToolCall { id, name, arguments }> (assistant turns). Without them a function_call_output answers no function_call and the API rejects the request. (The agent's own Message::Assistant already has tool_calls; the sim provider_messages mapping in brisvox-sim/src/reference.rs does not copy them yet because that reference stage runs without tools: follow-up for the real LLM stage.)
  • Message::provider_data: Option<Arc<str>> and LlmEvent::Done { provider_data }: opaque JSON text the caller stores on the assistant message of the turn and passes back. For OpenAI it is the JSON array of the response's reasoning and function_call output items exactly as received in response.output_item.done (the encrypted_content of a reasoning item is only complete there). The agent's Delta::ProviderData / Message::Assistant.provider_data are the intended carriers (they hold serde_json::Value; the providers crate keeps text so it needs no JSON dependency outside this feature).
  • Constructors Message::tool_result(call_id, content) and Message::assistant(content, tool_calls, provider_data).

Request mapping

  • Leading System messages -> instructions (joined by a blank line); a later System message (a late note) -> an input item with role developer.
  • User -> {"type":"message","role":"user","content":text}.
  • Assistant -> the items of provider_data in the order the server produced them (only if replay_reasoning): each reasoning item verbatim and directly in front of the item it led into, the played text ({"type":"message","role":"assistant","content":<played text>}, if not empty) at the position the response's message had, and the exact function_call item (keeps id/status) for every tool_calls entry whose call_id is in the data. So a response rs1, fc1, rs2, fc2 is replayed rs1, fc1, rs2, fc2, never rs1, rs2, fc1, fc2. Done's provider_data carries a placeholder {"type":"message"} where the message sat (only when there is also a reasoning or call item to replay; a text-only answer has no data); it is never sent. Data without the placeholder (older or foreign) puts the text before the reasoning run that leads into the first call, or after everything when there is no call. Reasoning whose follower is gone (the message was not played, or its call is not in tool_calls) is dropped with it; a tool_calls entry missing from the data is synthesised as {"type":"function_call", "call_id","name","arguments"} after the rest. With replay_reasoning = false, or without data, the turn is rebuilt from the engine's own record only: the text, then one synthesised function_call per call, no id, no status (this is the fallback if the API rejects a replay, so it must not depend on fc_ ids that are no longer preceded by their reasoning). An assistant turn with no text and no calls (everything cut by an interruption) is dropped together with its reasoning: a reasoning item without a following message or call is rejected by the API (known behaviour, not in the fetched docs: UNVERIFIED but defensive).
  • Tool -> {"type":"function_call_output","call_id","output"}. The history is checked before any I/O and a Protocol error names the call id when: a tool message has no tool_call_id; a tool result has no earlier unanswered call (the assistant turn was dropped because it was empty, or the result is answered twice); or a call has no result by the end of the history (a tool cancelled by a barge-in). OpenAI answers these with HTTP 400 ("No tool output found for function call ...", behaviour known but not in the fetched docs: UNVERIFIED) after a full round trip, which in a voice turn is dead air with an opaque Rejected{400}; so the LLM stage must repair the history first (answer the cancelled call with a synthetic result such as "cancelled", or drop the whole assistant turn together with its result).
  • tools: {"type":"function","name","description","parameters","strict"}. strict is true only if the schema already complies (object root, additionalProperties:false and every property in required on every object, none of oneOf/allOf/not/patternProperties/if/then/ else/dependentSchemas/dependentRequired/propertyNames/unevaluatedProperties/unevaluatedItems/ contains/minContains/maxContains), and every sub-schema (property, items, anyOf branch, $defs entry) declares its shape with type (a string or an array of strings), $ref or anyOf: a typeless leaf such as {"enum":["a","b"]}, {"description":"x"} or {} makes the tool strict:false (the strict validator is believed to reject "schema must have a 'type' key", UNVERIFIED, so the conservative choice applies), else false (a conservative list: whether the API rejects strict:true for each of them is UNVERIFIED, and a wrong true would fail every request of the agent with HTTP 400, while a wrong false only loses constrained decoding); arguments must be validated by the caller either way. Names must match [A-Za-z0-9_-]{1,64}; a bad name or non-JSON schema is a Protocol error before any I/O.
  • Always sent: model, stream:true, store:false, stream_options.include_obfuscation; include:["reasoning.encrypted_content"] while replay_reasoning. tool_choice is not sent (auto): LlmRequest has no field for it.

Response mapping

Server eventOutput
response.output_text.delta, response.refusal.deltaTextDelta (a refusal is spoken like an answer)
response.output_item.added with a function_call itemToolCallDelta { index, id: call_id, name, arguments } (index = dense position among the response's calls; the call is keyed by item_id, falling back to output_index)
response.function_call_arguments.deltaToolCallDelta { index, None, None, fragment }
response.function_call_arguments.done, response.output_item.done (function_call)only the part of arguments the deltas missed is forwarded; an item never announced is opened with its full arguments
response.output_item.done (reasoning / function_call / the first message)collected into provider_data (the message as a placeholder, see above); at most 256 items and 16 MiB, more is Protocol
(caps on tool-call arguments)the arguments streamed for one call are capped at 1 MiB and for all calls of a response together at 4 MiB (the adapter keeps a copy of each call's arguments to reconcile ...arguments.done); one byte more is Protocol and ends the stream. The consumer's own budget (the assembler, 256 KiB) is tighter; this cap bounds the adapter on its own, whatever base_url it talks to
an SSE event with empty data (data: alone)ignored (a heartbeat)
response.completedDone: ToolCalls if any function call was seen, else Stop; usage with cached_input_tokens. The response object is read leniently: an unreadable usage, output or incomplete_details is skipped (logged at debug), never turned into an error after the answer was delivered
response.incompleteDone: Length for max_output_tokens, else as above (content_filter maps to Stop, logged at debug: no brisvox variant)
response.failed, error (also the untyped {"error":{...}})Error: the code may be a string or a number (a number 400-599 is that status); error may be an object or a string (then there is no code and the result is Protocol); code rate_limit_exceeded/slow_down/insufficient_quota -> Rejected 429, server_error -> 500, server_is_overloaded -> 503, invalid_prompt/context_length_exceeded/... -> 400, anything else Protocol naming the sanitised code. The free-text message is never copied (it can echo prompt text or a key fragment)
body ends / [DONE] before a terminal event, transport errorError(Disconnected)
invalid JSON, unknown call, event over the capError(Protocol)
everything else (59-member union, reasoning summary events, content parts, ...)ignored; an event is parsed in two steps (type first, then only the fields of that kind), so odd field types on an ignored or future event cannot fail the stream

HTTP status other than 2xx -> Err(Rejected { status }) from stream() (400, 401, 403, 429, 5xx, even 3xx: redirects are never followed, so the key cannot leave). Retry-After and the error type/code are read (error body capped at 8 KiB and 1 s: a body that is huge, stalls or trickles never delays the Rejected; tests with a stalled and with an oversized body) and logged at warn; the adapter never retries, because a retry of a streaming request belongs to the turn logic.

Timeouts, cancellation, warm-up

  • All deadlines run on the injected clock: first_byte_timeout covers connect, TLS, the request and the response headers (Core(Timeout) from stream()); first_output_timeout runs from the headers to the first text or tool-call fragment (or the terminal event) and is not extended by SSE comments or lifecycle events (response.created, ...): a proxy that keep-alives while the model is stuck is cut off there, not after five minutes of dead air (Core(Timeout "openai first output") as an event); after that idle_timeout is the longest gap between SSE events ("openai idle"; comments do not count, any dispatched event does, and the time the consumer spends not reading is not charged to the server); max_stream_duration caps the whole response ("openai stream"). The HTTP client also gets connect_timeout (its own timer, belt and braces). first_output_timeout is longer than the idle gap on purpose, since a reasoning model may think before it speaks; raise idle_timeout as well for efforts above none, where gaps between events can be long (UNVERIFIED how long).
  • Dropping the LlmStream drops the receiver; the reader task notices at once (it selects on Sender::closed()), drops the HTTP response (HTTP/1.1: connection closed; HTTP/2: stream reset) and ends, also when the server is silent (the reader selects on Sender::closed() next to the socket read); dropping the stream() future before the headers arrive aborts the request the same way. Billing of tokens generated before the drop is UNVERIFIED (see above).
  • After a terminal event the reader drops its sender (so the stream ends) and drains the remaining body for at most 16 KiB and 500 ms in total, so an HTTP/1.1 connection returns to the pool and a server that trickles bytes cannot keep the task alive.
  • Request building: stream() parses every assistant message's provider_data and serialises the whole history on each request, a cost linear in the history (replayed encrypted reasoning can be tens of KB per turn). Up to 32 KiB of history (messages, call arguments and provider_data; about one pass of tens of microseconds) it runs inline; above that it runs on tokio's blocking pool (spawn_blocking), never on a worker thread, so audio and VAD tasks are not stalled by a long conversation. A panic in it is Core(ProcessorPanicked). Not done (follow-up): caching the parsed replay per message.
  • Channel: LLM_EVENT_CAPACITY (64). Overflow policy: backpressure; the reader stops reading the socket until the consumer catches up (TCP flow control), events are never dropped, and the idle deadline is re-armed only after the consumer has taken an event's outputs, so a slow consumer is not charged to the server.
  • warm(): unauthenticated HEAD {base}/v1/responses on the shared client; any HTTP status counts, only a transport failure is an error. The connection stays in the pool (HTTP/2: pinged every 20 s while idle), the next stream() skips DNS/TCP/TLS. The pool keeps an idle connection for pool_idle_timeout (default 10 minutes; None = until the server closes it), so a warm-up survives a long ringing period up to that bound; after a longer quiet period call warm() again (it is idempotent and cheap). No key is sent and nothing is billed. Idempotent. The pool is per OpenAiLlm: share one instance. Not done: prompt_cache_options.prewarm (a billed, model-side warm-up, GPT-5.6+; follow-up).
  • TLS: the shared net::tls_config() clone with ALPN h2, http/1.1; no system proxy. The client settings are now tested over a TLS + h2 loopback server in brisvox-providers (net/http/tls_tests.rs): ALPN h2, one TLS handshake for warm() plus the first streams, HTTP/2 PING keep-alive while idle, no system proxy. Still untested until a live run: the adapter's own SSE parsing over h2 (the mocks speak cleartext HTTP/1.1 or prior-knowledge h2), certificate/webpki-roots wiring against the real provider, the post-terminal drain and the stream reset on drop over TLS.

Prompt-cache friendly ordering (what the adapter does and what callers must do)

Adapter: renders the tools array once and reuses the bytes while the ToolSpecs are equal (also saves re-parsing schemas per turn); serialises instructions / input in a fixed order; replays reasoning and calls exactly as received; never reorders or rewrites earlier items; sends a configured prompt_cache_key unchanged. Test: the second request's body starts with the first request's body up to the end of input, and the tools substring is identical. Callers (the LLM stage): (1) stable persona in the leading system message(s), (2) stable tool list and order, (3) history append-only (the played-text truncation of an interrupted answer changes only the tail), (4) volatile content (time, caller name, retrieved snippets) at the end of the newest user message, never in the system prompt, (5) do not change model or reasoning.effort mid-call, (6) a stable prompt_cache_key per agent configuration, sharded for more than ~15 requests/min on models before GPT-5.6. usage.cached_input_tokens in Done shows whether it worked; the live test prints it for its last request.

Guesses the implementation had to make (all UNVERIFIED until a live run)

  • Assistant text is replayed as an easy message with string content (the docs show output messages as content lists with annotations); a reasoning item is replayed without the msg_... item it originally preceded.
  • include_obfuscation:false and include:["reasoning.encrypted_content"] are accepted for the chosen model (both are in the SDK's create params).
  • parallel_tool_calls is accepted whenever tools are sent; omitted when there are none.
  • Whether response headers arrive before the first event; first_byte_timeout is therefore generous (10 s) while idle_timeout is the voice-relevant bound.
  • Dense call indexes (instead of output_index) and call_id as the id: as recommended above.
  • An error event carries code at the top level; a nested error.code and the SDK's untyped {"error":...} are accepted too.
  • [DONE] may or may not follow the terminal event; both are tolerated.
  • HEAD /v1/responses without credentials answers with some HTTP status (even 401/404/405).
  • The replayed history is accepted when a function_call item keeps its server id/status and is separated from its reasoning item only by the played text (the order the server produced); the replay_reasoning = false fallback (no id, no reasoning) is accepted too.
  • Whether OpenAI sends SSE comments or empty-data events at all (the adapter tolerates both and no longer lets comments hold the idle deadline).

Tests

Unit (in the module): SSE parser (every split position, CRLF/CR/LF, comments, cap, UTF-8), request builder (exact JSON, replay rules, strict detection, prefix stability), event mapper (every event type of the table, failures, defensive paths). Integration (tests/openai/, loopback mock server speaking HTTP/1.1 chunked SSE): the shared provider contract suite (llm_contract: streaming, cancel, reconnect, error mapping, timeout, warm, task leaks; about 8 s because the suite waits out its own 2 s quiet windows), the exact request on the wire, a full server stream cut into 37-byte chunks, statuses/redirects, in-stream failures, first-output/idle/total timeouts (comments and lifecycle events do not extend them), pooled-connection reuse, cancellation (also while the server is silent and before the headers), backpressure (600 events to a consumer that starts late, none lost, no idle timeout; and 16 MiB scripted to a consumer that does not read at all: the server's writes block after a fraction of it, then the consumer resumes and gets everything in order), the error-body bounds (stalled body returns after about 1 s, oversized body returns at once), a 400 KiB history sent intact through the blocking-pool path, the bounded post-terminal drain, heartbeats and odd field types, numeric error codes, a malformed terminal response, the http:// rule, dangling tool calls/results, byte-stable prefixes, the provider_data round trip and secrecy. Unit tests cover the replay order (interleaved reasoning/calls, message position, fallback without the marker, replay_reasoning=false), the pairing check, the strict-schema keywords and typeless sub-schemas, the argument caps, and the inline/blocking-pool split of the request builder. The live test (tests/openai/live.rs) needs BRISVOX_LIVE_TESTS=1 and OPENAI_API_KEY (optionally OPENAI_MODEL) and is skipped otherwise.

Chat Completions streaming (secondary / fallback surface)

Sources: SDK type files chat/chat_completion_stream_options_param.py, chat/chat_completion_tool_message_param.py, chat/chat_completion_assistant_message_param.py, chat/chat_completion_function_tool_param.py, chat/chat_completion_message_function_tool_call_param.py, shared_params/function_definition.py; chunk reference https://developers.openai.com/api/reference/resources/chat/subresources/completions/streaming-events (all fetched 2026-10-08).

  • Request: stream: true, stream_options: {"include_usage": true, "include_obfuscation": <bool>}. include_usage (SDK docstring): "If set, an additional chunk will be streamed before the data: [DONE] message. The usage field on this chunk shows the token usage statistics for the entire request, and the choices field will always be an empty array. All other chunks will also include a usage field, but with a null value." If the stream is interrupted "you may not receive the final usage chunk".
  • Chunk fields: id (same in all chunks), object = "chat.completion.chunk", created (Unix seconds), model, choices[], system_fingerprint, service_tier, obfuscation, usage, moderation (optional {input, output}).
  • choices[]: index, delta, finish_reason (null until the last content chunk), logprobs.
  • delta: role, content (string|null), refusal, tool_calls[], deprecated function_call, audio.
  • delta.tool_calls[] fragment: index (required, identifies the call), id (first fragment), type: "function", function: {name, arguments} (arguments is a JSON string fragment; may be invalid/hallucinated: validate).
  • finish_reason values: stop, length, tool_calls, content_filter, deprecated function_call. In the audio flow the last update omits it (not re-checked: UNVERIFIED).
  • usage: prompt_tokens, completion_tokens, total_tokens, completion_tokens_details (reasoning_tokens, accepted_prediction_tokens, rejected_prediction_tokens, audio_tokens, text_tokens), prompt_tokens_details (cached_tokens, cache_write_tokens, audio_tokens, image_tokens, text_tokens).
  • Illustrative chunks (rebuilt from the field list, NOT verbatim; model id in them is a placeholder):
{"id":"chatcmpl-456","object":"chat.completion.chunk","created":1700000000,"model":"<model>","choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null,"logprobs":null}]}
{"id":"chatcmpl-456","object":"chat.completion.chunk","created":1700000000,"model":"<model>","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"get_weather","arguments":"{\"city\":"}}]},"finish_reason":null,"logprobs":null}]}
{"id":"chatcmpl-456","object":"chat.completion.chunk","created":1700000000,"model":"<model>","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls","logprobs":null}]}
{"id":"chatcmpl-456","object":"chat.completion.chunk","created":1700000000,"model":"<model>","choices":[],"usage":{"prompt_tokens":25,"completion_tokens":12,"total_tokens":37}}
  • Tool definition (Chat): {"type":"function","function":{"name","description","parameters","strict"}} (strict optional bool; parameters omitted = empty parameter list).
  • Tool result (Chat): message {"role":"tool","tool_call_id":"<id>","content":"<string>"} (content may also be an iterable of text parts); the preceding assistant message carries tool_calls: [{"id","type":"function","function":{"name","arguments"}}] (and content may be null/omitted when tool_calls is set: "Required unless tool_calls or function_call is specified").
  • Chat delta.tool_calls maps 1:1 onto LlmEvent::ToolCallDelta (index, id, function.name, function.arguments), the existing brisvox design assumption; usage arrives in a separate last chunk with empty choices after finish_reason, before data: [DONE].
  • Limitation: on gpt-6 models tool calling with reasoning is not available (see Decision).

Errors, status codes, rate limits, timeouts

Sources: https://developers.openai.com/api/docs/guides/error-codes , https://developers.openai.com/api/docs/guides/rate-limits , https://developers.openai.com/api/reference/overview .

  • Pre-stream HTTP errors (-> Error::Rejected { status } from stream()): 400 (error-codes page: invalid service_tier, type invalid_request_error, error.param:"service_tier"; context overflow with truncation:"disabled" is from the SDK docstring), 401 (invalid authentication / incorrect key, not in an org, IP not allowlisted), 403 (country/region/territory not supported), 429, 500, 503.
  • 429 variants: rate limit reached (temporary), slow_down (rate_limit_error; rate rose too quickly, can happen within RPM/TPM), and non-retryable credit_balance_exhausted, organization_spend_limit_exceeded, project_spend_limit_exceeded, organization_usage_limit_exceeded (error.type may be insufficient_quota; check error.code): "Retrying billing, spend, or quota errors won't restore API access". 503 server_is_overloaded (type service_unavailable_error): retry honouring Retry-After. 500: retry after a short wait.
  • Error JSON fields referenced: error.type (e.g. rate_limit_error, service_unavailable_error), error.code, error.param, error.message (full example body not on the page; the standard {"error":{...}} envelope is confirmed only by the SDK stream decoder, which reads error and error.message).
  • Retry: honour Retry-After (seconds, only sometimes present) as a minimum plus a small random delay, else exponential backoff with jitter and capped retries (attempts and total time). Failed requests still count against the per-minute limit. Official SDKs auto-retry (SDK default max_retries 2; the exact set of retried statuses is not stated in the docs fetched: UNVERIFIED).
  • Rate-limit headers (names + sample values from the guide): x-ratelimit-limit-requests (60), x-ratelimit-limit-tokens (150000), x-ratelimit-remaining-requests (59), x-ratelimit-remaining-tokens (149984), x-ratelimit-reset-requests (1s), x-ratelimit-reset-tokens (6m0s), project variants x-ratelimit-limit-project-tokens (60000), x-ratelimit-remaining-project-tokens (57000), x-ratelimit-reset-project-tokens (3s). Other headers: x-request-id, openai-processing-ms, openai-organization, openai-version (2020-10-01). Client-set X-Client-Request-Id (ASCII only, <= 512 chars, unique per request; not added automatically) recommended for tracing (400 if invalid). Total request headers must stay < 64 KiB (custom header values <= 60 KiB total).
  • Limits (RPM / TPM, per org+project, per model group; Standard): Build (>= $5 credit) Astra/Sol/Terra 5,000 / 1M, Luna 5,000 / 2M; Launch (>= $100) 10,000 / 4M (Luna 10,000 / 10M); Grow (>= $500) 15,000 / 40M (Luna 30,000 / 180M). Whether cached input tokens count toward TPM, and whether concurrency is limited separately: UNVERIFIED (the rate-limits page does not say; it lists RPM/TPM only). Luna's RPM bounds new calls/min.
  • Timeouts: no server-side stream timeout/keepalive spec found: UNVERIFIED. SDK defaults: total timeout 600 s, connect timeout 5 s, 1000 max connections, 100 keep-alive (openai-python _constants.py). Brisvox must set its own connect (<= ~2 s), first-event and inter-event timeouts.
  • Cancel semantics: no cancel call documented for a streaming Responses request other than closing the connection (WebSocket guide examples only ws.close()); dropping the HTTP body (brisvox: drop LlmStream) is the plan. Billing of tokens generated before the drop: UNVERIFIED. response.status:"cancelled" exists (relevant to background mode).

Prompt caching (cache-friendly ordering)

Source: https://developers.openai.com/api/docs/guides/prompt-caching (fetched 2026-10-08).

  • Automatic, on by default for supported models; no code change required (not re-confirmed verbatim: UNVERIFIED). Minimum cacheable prompt: 1,024 tokens for GPT-5.6 and later (older models: "varies by request settings", UNVERIFIED which; older models report cached_tokens rounded down to a multiple of 128).
  • "Cache reuse requires the entire rendered prefix to match." Any change before a breakpoint invalidates everything after it. Changing the model, tool definitions or ordering, structured output format, reasoning effort, verbosity, or compaction can change the prefix. Cached content = rendered instructions, developer messages, tool definitions and conversation history (the exact list of content kinds is from the first pass).
  • Usage fields: Responses usage.input_tokens_details.cached_tokens (reads) and .cache_write_tokens (writes; gpt-5.6+). Chat usage.prompt_tokens_details.cached_tokens (SDK chunk field list). Map to Usage.cached_input_tokens.
  • prompt_cache_key: "Keys influence routing; they do not pin requests to a machine or guarantee a cache hit"; on models before GPT-5.6 keep about 15 requests per minute per key ("traffic above 15 requests per minute can lead to overflow routing"), so partition across keys. On GPT-5.6 and later: "The key is optional and is not needed to optimize caching on these models"; separate keys keep "separate cache accounting for customers or users". Caches are not shared across organisations (nor across regional processing boundaries).
  • Retention: GPT-5.6+: prompt_cache_options.ttl, only value 30m (default): prefix stays eligible 30 min after last write/reuse. Older: prompt_cache_retention in_memory ("around 5 to 10 minutes of inactivity, up to one hour") or 24h (around 30 minutes, up to 24 hours; the only option for gpt-5.5 and later per the SDK docstring; without ZDR the default is 24h, with ZDR in_memory). No manual clearing (first pass, not re-confirmed).
  • Breakpoints (GPT-5.6+): implicit mode (default) "places a breakpoint at the end of the latest eligible message" (SDK: plus up to three explicit ones); explicit mode disables the automatic one and allows up to four. Explicit prompt_cache_breakpoint ({"mode":"explicit"}) on content blocks, "up to four cache writes" per request.
  • Pricing (GPT-5.6+): cache writes cost 1.25x the standard uncached input rate, reads 0.1x (0.05x for GPT-6.1 Sol); Luna cached input $0.01 vs $0.10 uncached. "Cache-write pricing is not an additive fee".
  • Ordering rules for brisvox message construction (from the guide's advice + our needs):
    1. Fixed order: tools (stable order and schemas, never reordered per turn) -> instructions/ developer message (stable persona) -> shared reference data -> conversation history (append-only) -> newest user turn last. Put volatile content (timestamps, caller name, retrieved snippets) at the END of the latest user/developer item, not in the system prompt. (latency-optimization guide: "put dynamic content later in the prompt to maximize prompt caching".)
    2. Append new messages, never rewrite earlier turns. After an interruption the played-text truncation of the last assistant message changes only the tail, so earlier cache stays valid; cache hit for the truncated turn itself is lost, accepted.
    3. Use tool_choice: "none" or allowed_tools rather than removing tools for a turn.
    4. Do not vary reasoning.effort/model mid-call if cache matters.
    5. Send a stable prompt_cache_key per agent configuration (not per call) and keep each key under ~15 req/min (pre-5.6 models); for many concurrent calls of one agent, shard keys (e.g. key + shard id).
    6. prompt_cache_options.prewarm: true prepares the cache without generating output (sets generate to false); the warm-up tokens are billed at the cache-write rate. Candidate for warm().

Reasoning items (brisvox provider_data)

Sources: https://developers.openai.com/api/docs/guides/reasoning , response_reasoning_item.py (SDK), response_output_item_added_event.py.

  • Reasoning tokens "still occupy space in the model's context window and are billed as output tokens" and are not visible; items are opaque ("the API does not return their reasoning text").
  • Item shape (doc, summary only shown): {"id":"rs_6876cf02e0bc8192b74af0fb64b715ff06fa2fcced15a5ac","type":"reasoning","summary":[{"type":"summary_text","text":"..."}]} (the guide's sample output has this id and type:"reasoning"; the summary_text inner shape is from the first pass). SDK fields: id, type:"reasoning", summary[], optional content[] (reasoning_text), optional encrypted_content, optional status (in_progress|completed|incomplete).
  • Stateless mode (store:false): "reasoning items in the response's output array include an encrypted_content property by default"; the legacy reasoning.encrypted_content value in include is "still accepted ... for compatibility, but doesn't require it". Always send include: ["reasoning.encrypted_content"] anyway for compatibility with older models (its necessity on older models: UNVERIFIED).
  • Echo requirement: the docs "highly recommend you pass back any reasoning items returned with the last function call" (along with the function output), and all reasoning/function-call/ function-call-output items since the last user message. With store:false the guide says "preserve every output item, append the next user message, and replay the complete history". For brisvox: the adapter needs an opaque provider_data blob per assistant turn holding the raw JSON of the reasoning and function_call output items (taken from response.output_item.done) to replay in the next request. Message today has only role/content/tool_call_id, so the adapter needs either a provider_data: Option<Arc<str>> field on Message or an adapter-side side table keyed by tool call id (flag as interface change).
  • With reasoning effort none (Luna) no reasoning items are expected: UNVERIFIED.
  • reasoning.context (auto|current_turn|all_turns): auto uses the selected model's default; all_turns "renders available, compatible reasoning items from earlier turns into the next sample" and "has an effect only when the request has access to earlier response items". Whether GPT-5.6+ renders earlier-turn reasoning by default: UNVERIFIED. "Persisted reasoning can be reused only within the same model family"; "reasoning does not carry between the GPT-5.6 and GPT-5.5 families". Switching models mid-call may require dropping reasoning items (inferred: UNVERIFIED).
  • After an interrupted answer: reasoning items of a turn whose text was cut should still be replayed only if paired with the items they belong to; whether orphaned reasoning is accepted: UNVERIFIED (test live).
  • reasoning.summary: opt-in (auto for the most detailed summary); may need organisation verification (UNVERIFIED). Not needed.

Realtime / WebSocket (note only, not part of the Responses adapter)

  • Realtime API: "the best starting point for voice agents that need barge-in" (low first-audio latency wording from first pass); transports WebRTC in the browser or WebSocket on the server; POST /v1/realtime/client_secrets for ephemeral keys; example model gpt-realtime-2.1; the guide has no WebSocket URL and says nothing on text-only output: UNVERIFIED. It is a speech-to-speech product; brisvox's goal (own STT/LLM/TTS pipeline, provider choice) does not use it. Luna does NOT support Realtime (Luna model page). Source: https://developers.openai.com/api/docs/guides/realtime
  • Responses WebSocket mode (/v1/responses, client event response.create; the payload "mirrors the normal Responses create body, except that transport-specific fields like stream and background are not used"): connections last up to 60 min ("Reconnect at the limit"); up to 16 active in-flight responses per connection (across named and default lanes); up to 32 distinct named stream_id values (FIFO per lane is from the first pass, not re-checked); continuation via previous_response_id with only the new input items, compatible with store=false/ZDR; generate:false warms state; uncached id with store=false -> previous_response_not_found; claim "up to roughly 40% faster end-to-end execution" for rollouts with 20+ tool calls. No documented cancel; the guide links a separate "Mid-turn steering" guide (and the SDK mentions a response.steer event) for adding user input while a response runs. WebSocket URL and auth handshake not in the guide: UNVERIFIED. Candidate later optimisation (pre-warmed persistent connection avoids TLS+connect per turn), but it conflicts with stateless history replay; park as a follow-up. Source: https://developers.openai.com/api/docs/guides/websocket-mode
  • OpenAI's voice-agents guide lists a chained pipeline as an option (best for "Control over each speech and text stage"; "Use this path when each stage needs to be visible or replaceable") without recommending models for it or giving latency numbers. Source: https://developers.openai.com/api/docs/guides/voice-agents

Turkish support

UNVERIFIED. The text guide, latest-model guide, latency guide and models pages fetched say nothing about languages; no Turkish statement found. Verify empirically with the Turkish conversation fixtures (live test) and measure token inflation (Turkish agglutination usually costs more tokens: unverified assumption).

Gotchas / things the implementer must handle

  • Documentation shows a new model generation (GPT-6 family, GPT-5.6) not in older training data: never hard-code model names; read from config.
  • function_call items have both id (fc_...) and call_id (call_...); results use call_id.
  • Reasoning encrypted_content is incomplete in output_item.added; use output_item.done.
  • Argument fragments may be invalid JSON; validate before dispatch (also the brisvox MalformedToolArguments fault).
  • Terminal events carry the whole Response (can be large with long history echo; do not log).
  • Usage arrives only in the terminal event; if the stream is cut it is lost.
  • truncation defaults to disabled: oversize context returns 400; brisvox must trim itself.
  • 429 slow_down: ramp load gradually (1M TPM -> +50%/15 min); relevant for load tests.
  • Fast mode downgrade returns service_tier:"default" in the response.
  • stream_options.include_obfuscation adds padding bytes per delta (on by default): set false for lower bandwidth (the SDK docstring says it is safe on trusted links).
  • Sol/Luna with Chat Completions and tools: see Decision, do not use.
  • gpt-6-astra rejects reasoning.effort:"none" with HTTP 400.

UNVERIFIED list

  • Responses SSE: event: line values, presence of [DONE], any keep-alive comments, response_id field on delta events (guide shows it, SDK types do not).
  • Fields of response.refusal.done and content-part events.
  • Whether function_call.id must be echoed on stateless replay; orphaned reasoning items; reasoning after model switch; reasoning items at effort:"none".
  • Whether include:["reasoning.encrypted_content"] is still needed on older models.
  • Latency numbers (TTFT, tok/s) for Luna/Sol; Fast mode effect on Luna streaming TTFT; whether Luna officially supports Fast mode.
  • Regional hostnames; EU data-residency tiers and the 10% regional premium; WebSocket URL/auth; text-only Realtime output.
  • Server-side idle/stream timeouts; cost of dropped streams; cancel semantics.
  • Minimum cacheable length for pre-5.6 models; whether the >272K surcharge, text+image modalities apply to Sol (checked on Luna / Astra pages only).
  • Cached input counting toward TPM; separate concurrency limits.
  • Turkish quality/tokenisation.
  • Full error body example; remaining semantics of async: true tools and configuration_update input item (latest-model guide: changes reasoning effort mid-conversation without rewriting the prompt prefix).
  • Exact JSON of Chat Completions chunks (above are illustrative).
  • "Terra" model (named in rate limits only); whether reasoning.context default renders earlier reasoning on GPT-5.6+.

Sources (all fetched 2026-10-08)

  • https://developers.openai.com/api/docs/guides/migrate-to-responses
  • https://developers.openai.com/api/docs/guides/function-calling
  • https://developers.openai.com/api/docs/guides/streaming-responses
  • https://developers.openai.com/api/docs/guides/prompt-caching
  • https://developers.openai.com/api/docs/guides/reasoning
  • https://developers.openai.com/api/docs/guides/latest-model
  • https://developers.openai.com/api/docs/guides/fast-mode
  • https://developers.openai.com/api/docs/guides/latency-optimization
  • https://developers.openai.com/api/docs/guides/error-codes
  • https://developers.openai.com/api/docs/guides/rate-limits
  • https://developers.openai.com/api/docs/guides/websocket-mode
  • https://developers.openai.com/api/docs/guides/realtime
  • https://developers.openai.com/api/docs/guides/voice-agents
  • https://developers.openai.com/api/docs/guides/text
  • https://developers.openai.com/api/docs/models , .../models/gpt-6-luna , .../models/gpt-6.1-sol , .../models/gpt-6-astra
  • https://developers.openai.com/api/reference/overview
  • https://developers.openai.com/api/reference/resources/responses/methods/create (POST /responses)
  • https://developers.openai.com/api/reference/resources/chat/subresources/completions/streaming-events
  • https://developers.openai.com/api/reference/resources/responses/streaming-events (only response.created visible; page truncated)
  • openai-python (raw.githubusercontent.com/openai/openai-python/main/src/openai/): api.md, _streaming.py, _constants.py, types/responses/{response_create_params, response, response_stream_event, response_text_delta_event, response_text_done_event, response_refusal_delta_event, response_function_call_arguments_delta_event, response_function_call_arguments_done_event, response_output_item_added_event, response_completed_event, response_incomplete_event, response_failed_event, response_error, response_error_event, response_usage, response_function_tool_call, response_function_tool_call_output_item, response_input_item_param, response_input_text_param, response_output_text_param, response_reasoning_item, easy_input_message_param}.py, types/chat/{chat_completion_stream_options_param, chat_completion_tool_message_param, chat_completion_assistant_message_param, chat_completion_function_tool_param, chat_completion_message_function_tool_call_param}.py, types/shared_params/function_definition.py.

Fact-checked 2026-10-08: 112 claims checked, 19 corrected. Corrections: (1) gpt-6-astra cached input was "?", now $1 (cache write $12.50), description is "Our most capable model for the most demanding work" (dropped bogus "latestModelInfo"); (2) Luna is not the only model with none (gpt-6-sol too per latest-model guide); (3) Fast mode "up to 2.5x faster" and "2x" price are stated for gpt-5.6-sol / GPT-5.6 Sol, not generically; added fast/ultrafast service_tier values; (4) Chat Completions path /v1/chat/completions is now confirmed (was UNVERIFIED); (5) realtime model IDs are confirmed on the models page (were "inferred"), plus 4 more IDs; (6) reasoning replay quote fixed to "you should pass back all reasoning items, function call items, and function call output items, since the last user message" and "highly recommend you pass back any reasoning items returned with the last function call"; (7) "The API ignores irrelevant items" quote replaced by "Our systems will smartly ignore any reasoning items that aren't relevant to your functions"; (8) instructions quote ("Not carried over...") is an SDK docstring paraphrase, the migrate guide wording differs; (9) response.output_text.done fields now verified (text, content_index, ...); (10) response.refusal.delta fields verified; (11) content-part type strings input_text/output_text verified (were UNVERIFIED); (12) prewarm semantics now known (no output generated, billed at cache-write rate); (13) "cached input tokens count toward TPM" and "concurrency not limited separately" moved to UNVERIFIED (rate-limits page does not say); (14) async: true is documented in the latest-model guide only, not the function-calling guide; SDK function_call has an async field; (15) function name constraints come from the SDK docstring, not the function-calling guide; (16) WebSocket payload wording ("transport-specific fields like stream and background", not "minus"), steering mention added; (17) prompt_cache_retention 24h-only for gpt-5.5+ and ZDR defaults added; the 15 req/min key advice applies to pre-5.6 models; (18) "No mini/nano tiers" softened to "models page lists only three text models with full details", the Astra page's unqualified Chat function_calling listing conflicts with the guide (noted); (19) EU residency tiers/10% premium and "non-compliant strict schema is rejected" and "allowed_tools recommended for caching" were not re-confirmed and are now marked UNVERIFIED. Confirmed unchanged: 59-member event union, SDK timeouts/retries, rate-limit tables and headers, error-code table, reasoning item fields, usage fields, prompt-caching numbers.

Live check (2026-10-13: blocked)

The key provided reaches the API but the account has no credit: the Responses stream fails with credit_balance_exhausted, which the adapter surfaces as a protocol error naming that code. The wire checks wait for a funded key.

OpenAI-compatible Chat Completions — LLM (Groq, Together AI, Fireworks AI, vLLM, Ollama, OpenRouter)

Researched 2026-10-08. Every fact below came from a page fetched that day; URLs are given per section and in "Sources". Pages were read through a summarising fetch tool, so JSON blocks marked "(doc)" were copied from the docs, and blocks marked "(illustrative)" were rebuilt from field lists and are NOT verbatim wire captures. Source-code reads (Ollama Go, vLLM Python, openai-python, groq-python) are labelled "(source)". Anything not confirmed is marked UNVERIFIED. No live capture was done: a live run per vendor must confirm wire shapes before they are trusted.

Recommended (read this, skip the survey).

You wantStart withVerified
A hosted LLM, any language, toolsOpenRouter: preset = "openrouter", api_key_env = "OPENROUTER_API_KEY", model openai/gpt-6-luna with reasoning_effort = "none" (recipe: docs/PROVIDERS.md, "OpenRouter")Live: streaming, parallel and chained tool calls, voice-to-voice runs in English and Turkish (docs/TOOLS.md, docs/LATENCY.md). Turkish quality is the one thing to judge by listening.
A fully local LLMOllama: preset = "ollama", model = "gemma4:12b-it-qat" (ollama pull gemma4:12b-it-qat), reasoning_effort = "none", no keyLive: streaming, and the six tool scenarios (parallel, chain, repeated booking, invalid-argument retry, slow tool, barge-in) 18/18 on an RTX 5070 with first sentence in ~270 ms. Five local models were tried, only this one called tools reliably (table below, docs/TOOLS.md, "Local models through Ollama").
Groq, Together, Fireworks, vLLMThe matching presetMock servers and contract tests only; no live run against them. Treat the vendor sections below as documentation, and try one call before relying on it.

The same server speaks the OpenAI-compatible protocol for the local speech side too (a text-to-speech server); that is a different provider, openai_speech (docs/providers/openai-speech.md), and the recommended local speech stack is in the first lines of docs/providers/local-speech.md.

  • Docs: see "Sources" at the end (fetched 2026-10-08).
  • Auth: Authorization: Bearer <key> for all hosted vendors. Local servers: Ollama local needs no header (client libraries want a dummy key); vLLM checks a key only if started with --api-key. Details per vendor below.
  • Endpoint(s) / regions: POST {base_url}/chat/completions, base_url includes the version prefix and differs per vendor (table below). Regions: not researched (UNVERIFIED for every vendor).
  • Protocol: SSE of chat.completion.chunk JSON objects, terminated by data: [DONE].
  • Keepalive / timeouts: no vendor documents SSE keepalive or server-side stream timeouts (UNVERIFIED for all five); brisvox must apply its own connect / first-byte / idle timeouts.
  • Finalize / flush / cancel semantics: cancel = close the HTTP connection. Only Fireworks says so in docs ("Close the connection to stop generation and avoid billing for ungenerated tokens").
  • Timestamps / interim results / tool-call deltas: see "Tool-call streaming matrix".
  • Concurrency / rate limits: see "Rate limits and error bodies".
  • Turkish support: not researched per model (model-dependent; UNVERIFIED for every vendor/model below). Choose the model by a Turkish live test, not by this document.
  • Gotchas found during implementation: see "Implementation notes" below; "Known deviations" lists the ones the docs reveal.

Summary table

Base URLAuthSource
OpenAI (baseline)https://api.openai.com/v1 (see openai.md)Authorization: Beareropenai.md
Groqhttps://api.groq.com/openai/v1Authorization: Bearer $GROQ_API_KEY (the page says "passing your Groq API key"; the header itself is the OpenAI-client default: UNVERIFIED as text on the page)console.groq.com/docs/openai
Together AIhttps://api.together.ai/v1-H "Authorization: Bearer $TOGETHER_API_KEY" (doc)docs.together.ai/docs/openai-api-compatibility
Fireworks AIhttps://api.fireworks.ai/inference/v1Bearer token in the authorization header (cURL example); also OPENAI_API_BASE / OPENAI_API_KEY envdocs.fireworks.ai/tools-sdks/openai-compatibility
vLLMhttp://localhost:8000/v1 in the page's client example (host/port defaults are NOT stated on the page: UNVERIFIED)--api-key <k> or env VLLM_API_KEY; only enforced for the /v1, /v2, /inference prefixes; the page does not name the header (UNVERIFIED; OpenAI clients send Bearer)docs.vllm.ai openai_compatible_server
Ollama localhttp://localhost:11434/v1/none; client needs a placeholder such as 'ollama', ignored by the serverdocs.ollama.com/api/openai-compatibility
Ollama cloudhttps://ollama.com/v1Authorization: Bearer $OLLAMA_API_KEY; "x-api-key alone is not supported" (stated for the Anthropic-compatible endpoint)docs.ollama.com/api/authentication

OpenRouter (preset = "openrouter"; fetched 2026-10-14 from https://openrouter.ai/docs/api-reference/overview , read through a summarising fetch tool): POST https://openrouter.ai/api/v1/chat/completions, Authorization: Bearer <key>; optional attribution headers HTTP-Referer, X-OpenRouter-Title (X-Title also accepted), X-OpenRouter-Categories (the preset sends none; an application that wants them sets extra_headers through the library). Streaming is SSE and "will occasionally contain a comment payload, which you should ignore" (the adapter ignores comment lines). Usage: "exactly once in the final chunk before the [DONE] message"; that chunk has a non-empty choices with a content-free delta that repeats the finish_reason, so the response ends at the usage chunk or [DONE] (placement TrailingChunk). The token limit is max_tokens. The overview page lists no reasoning_effort request field, but the live runs of 2026-10-13 sent it (docs/LATENCY.md: openai/gpt-6-luna thinks by default and adds about 1 s to the first token; reasoning_effort = "none" turned it off), so set it for voice. Tools: tools, tool_choice, role: "tool" messages with tool_call_id, finish_reason tool_calls; only some models support them (openrouter.ai/models?supported_parameters=tools). The quirks of the preset are OpenAI's own (max_tokens, stream_options.include_usage sent, usage in a trailing chunk), the settings of those live runs. The model ids are vendor/model; the suggested one is the model of the live comparison, pick yours by measurement.

vLLM warning (doc): "Do not rely on --api-key alone to secure vLLM." Ollama binds 127.0.0.1:11434 by default; change with OLLAMA_HOST (docs.ollama.com/faq).

Brisvox adapter implications: base_url (string, includes /v1 or /openai/v1 or /inference/v1), optional bearer key (absent for local Ollama/vLLM), extra static headers map, model string verbatim (vendor-namespaced: Together openai/gpt-oss-120b, Fireworks accounts/fireworks/models/...; "OpenAI model strings like gpt-4o ... return a 404" on Together). Plain http:// only for loopback (same rule as openai.md).

OpenRouter

Not part of the research above (its documentation was not fetched for this file). What the repository itself uses and has run: base URL https://openrouter.ai/api/v1, bearer auth with the user's own key, a vendor-namespaced model id (openai/gpt-6-luna), the top-level request field reasoning_effort (none, because that model thinks by default and adds about a second to the first token). The openrouter preset fills in the base URL and that default model: provider = "openai_compatible", [llm.openai_compatible] with preset = "openrouter" (or base_url and model spelled out), and api_key_env. A ready agent file is bench/pipecat_baseline/agent_live_tr.toml; the recipe with notes is in docs/PROVIDERS.md ("OpenRouter"), and the live results (tool scenarios, latency) are in docs/TOOLS.md and docs/LATENCY.md.

Baseline: canonical OpenAI Chat Completions streaming

Sources: https://developers.openai.com/api/reference/resources/chat/subresources/completions/streaming-events , openai-python src/openai/types/chat/chat_completion_chunk.py, completion_create_params.py, lib/streaming/chat/_completions.py (all fetched 2026-10-08). See also the Chat section of docs/providers/openai.md (same sources). Note: OpenAI recommends Responses for new work and on GPT-6 models tool calling needs Responses (openai.md; that claim was not re-checked in the 2026-10-08 fact-check of this file: UNVERIFIED here); this document concerns Chat Completions as implemented by third parties.

Request: stream: true; stream_options: {"include_usage": true} (optionally include_obfuscation); tools: [{"type":"function","function":{"name","description","parameters"}}]; tool_choice (none default without tools, auto default with tools, or a named function); parallel_tool_calls ("Whether to enable parallel function calling during tool use"); max_completion_tokens (max_tokens is deprecated in favour of it); n ("Keep n as 1").

Chunk (page field list):

  • id (same in every chunk), object = "chat.completion.chunk", created (Unix seconds, same in every chunk), model, choices[], optional system_fingerprint, service_tier, obfuscation, moderation, usage.
  • choices[]: index, delta, finish_reason (null until the end; stop, length, tool_calls, content_filter, deprecated function_call), logprobs.
  • delta: role, content, refusal, tool_calls[], deprecated function_call, audio.
  • delta.tool_calls[]: index (required int, identifies the call), id (optional, "The ID of the tool call"), type ("Currently, only function is supported"), function {name, arguments}; arguments is a JSON string that "may be invalid" and must be validated.
  • usage: only present with include_usage; "null in every chunk except the last"; the last chunk has choices: [] ("The array can be empty on the last chunk when stream_options.include_usage is set"; the choices: [] quote was not re-confirmed on 2026-10-08: UNVERIFIED); fields prompt_tokens, completion_tokens, total_tokens, prompt_tokens_details.cached_tokens, completion_tokens_details.reasoning_tokens and more. If the stream is interrupted "you may not receive the final usage chunk".
  • Stream ends with data: [DONE].

Verbatim first chunk (doc; the page example is cut by the fetch tool at the quote limit, the model id is whatever the page shows):

{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-6-astra",
"system_fingerprint": "fp_44709d6fcb",
"choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}],
"obfuscation":"r4N7vQ2m"}

The reference page has NO JSON example of a tool_calls delta or of the usage chunk (reported by the fetch). Canonical tool-call stream shape (illustrative, from the field list; the standard OpenAI behaviour is: first fragment carries index, id, type, function.name and arguments:""; later fragments carry only index and an arguments fragment):

{"id":"chatcmpl-1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}
{"id":"chatcmpl-1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":"}}]},"finish_reason":null}]}
{"id":"chatcmpl-1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}
{"id":"chatcmpl-1","object":"chat.completion.chunk","created":1,"model":"m","choices":[],"usage":{"prompt_tokens":25,"completion_tokens":12,"total_tokens":37}}
data: [DONE]

Accumulation (source, openai-python lib/streaming/chat/_completions.py): _accumulate_chunk merges choice.delta.to_dict() into the message snapshot via accumulate_delta; for each delta.tool_calls element the SDK locates the snapshot call by .index (choice_snapshot.message.tool_calls[tool_call_chunk.index]). Partial-JSON parsing (from_json(..., partial_mode=True)) is done ONLY for strict function tools with non-empty arguments; for other tools parsed_arguments is set only when the arguments.done event fires (re-checked 2026-10-08). The implementation file _deltas.py returned 404 in the fetch: merge rules for id/name/strings UNVERIFIED here; the usual rule is string concatenation for arguments.

Tool result message (illustrative, standard): assistant message with tool_calls:[{"id","type":"function","function":{"name","arguments"}}] followed by {"role":"tool","tool_call_id":"<id>","content":"<string>"} (see openai.md Chat section; Groq's local-tool-calling page confirms in prose that each tool result message includes a tool_call_id matching the call id ("Unique identifier for this tool call (used when sending results back)"); the earlier quoted sentence "must match the id from the assistant's tool_calls" was NOT found verbatim and is dropped).

Tool-call streaming matrix (the core question)

Streaming formatidindexEvidence
GroqThe local-tool-calling page's Python streaming example appends each chunk's delta.tool_calls to a list (collected_tool_calls.extend(chunk.choices[0].delta.tool_calls)); no prose sentence and no JSON example of a chunk. SDK type ChoiceDeltaToolCall has index: int, id: Optional[str], type, function. Whether arguments come as fragments or whole in one chunk: UNVERIFIEDoptional per SDK typerequired int per SDK typeconsole.groq.com/docs/tool-use/local-tool-calling (code example); groq-python chat_completion_chunk.py (source)
Together AIIncremental: "the model returns tool calls incrementally, accessible from the delta.tool_calls object in each chunk" (single-call page; the longer wording "The first delta includes the call id, type, and function name with empty arguments. Later deltas add the argument text." is from the function-calling overview and was not re-confirmed: UNVERIFIED verbatim). That is the OpenAI format. Caveat: the doc's example of a "later delta" shows the whole arguments object in one fragment ("arguments": "{\"location\":\"New York City, USA\"}"), so a model/vendor may emit the full string in one fragment. The non-streaming parallel page shows "index": 0,1,2 in the same-function example but "index": null on every call in the multiple-different-functions example (see deviations)first deltaper doc example index: 0 in both deltas; null seen in non-streaming multi-function exampledocs.together.ai/docs/inference/function-calling/single-call.md, .../parallel.md
FireworksIncremental: "Arguments are sent incrementally as the model generates them." The page's example code accumulates in a dict keyed by tool_call.index, appends function.arguments fragments and parses after the stream finishes (the quoted "store id and function.name when they appear" / finish_reason == "tool_calls" wording was not re-confirmed: UNVERIFIED verbatim)when it appears (first)yes, accumulate by indexdocs.fireworks.ai/guides/function-calling
vLLMDepends on the --tool-call-parser; the stream generator delegates to parser.parse_delta(...) (not re-read), so the shape is parser-specific. For the hf parser: "When streaming, each tool call is emitted whole once its region parses." Other parsers (hermes etc.): UNVERIFIED whether fragments or wholeUNVERIFIED for streaming (non-stream default id format chatcmpl-tool-<uuid>, kimi_k2 parser uses functions.<name>:<idx>)UNVERIFIEDdocs.vllm.ai features/tool_calling; vllm chat_completion/serving.py, chat_utils.py (source)
OllamaNot documented either way (streaming page: "Watch for streamed tool_calls in each chunk"). Source: the OpenAI layer copies fully parsed api.ToolCalls into a chunk delta (ToToolCalls JSON-marshals the whole Arguments map into one string), i.e. a call is most likely complete in one chunk; confirmation of "never fragmented" UNVERIFIED (parser internals not read)generated server-side as "call_" + 8 random [a-z0-9] in ChatHandler (toolCallId(); the native chat path handleNativeChat was cut off in the fetch: UNVERIFIED there); omitempty on ToolCall.IDFunction.Index (int, json:"index", no omitempty)docs.ollama.com/capabilities/tool-calling, /streaming; ollama openai/openai.go, server/routes.go, api/types.go (source)

Design consequence (adapter): the accumulator must accept BOTH shapes with the same code path: fragments (first with id+name+"" arguments, then pure arguments) and whole calls (id+name+full arguments in one delta). Brisvox's LlmEvent::ToolCallDelta { index, id?, name?, arguments } already allows both. Do not assume index is non-null (Together non-streaming parallel example) or that id is present (Groq/Together/Fireworks types make it optional; Ollama omitempty).

Per-vendor details

Groq

Sources: https://console.groq.com/docs/openai , /docs/tool-use , /docs/tool-use/local-tool-calling , /docs/api-reference , /docs/rate-limits , /docs/errors , /docs/models , groq-python types/chat/chat_completion_chunk.py and completion_create_params.py (source), all fetched 2026-10-08.

  • Base URL https://api.groq.com/openai/v1. Compat page: change base_url, pass the Groq key.
  • Unsupported fields, "400 error if supplied": logprobs, logit_bias, top_logprobs, messages[].name; n must equal 1. temperature: 0 is converted to 1e-8. (The compat page does not mention stream_options or parallel_tool_calls; the API reference does list both. The groq-python completion_create_params.py defines parallel_tool_calls but NOT stream_options; it only has stream as Literal[True/False].)
  • API reference: stream (SSE, ends with "data: [DONE]"); stream_options ("Only set this when you set stream: true"; the property list is collapsed in the fetched text, so whether include_usage is accepted is UNVERIFIED); parallel_tool_calls (boolean, default true); tool_choice (none/auto/required/named function, default none without tools, auto with); max_completion_tokens (replaces deprecated max_tokens); service_tier (auto, on_demand (default), flex, performance; flex "will succeed or fail quickly"). reasoning_effort: values none|default|minimal|low|medium|high|xhigh|max, model dependent (qwen3 models: none disables reasoning, default/null = model default; qwen/qwen3.8-27b additionally low|medium|high, its default is none; openai/gpt-oss-20b and -120b: low|medium|high, default medium); "Values outside a model's supported set are rejected with a 400".
  • Streaming chunk: SDK type ChatCompletionChunk has top-level usage and an x_groq object with usage, id, debug, error, seed, usage_breakdown. The API reference calls x_groq "Groq-specific metadata for non-streaming chat completion responses"; where Groq puts usage in a stream (top-level on the last chunk, inside x_groq.usage, or on a trailing empty-choices chunk): UNVERIFIED. Accept all three. x_groq.error exists in the type: a mid-stream error may be delivered there (UNVERIFIED).
  • finish_reason (SDK Literal): stop, length, tool_calls, function_call (deprecated). No content_filter in the Groq type.
  • delta extras (SDK): reasoning (string; per the SDK docstring it is "only populated when the request's reasoning_format is parsed", so by default thinking may arrive inline in content or be hidden depending on reasoning_format: the other reasoning_format values are UNVERIFIED), refusal, annotations, executed_tools (compound systems), function_call (deprecated). Reasoning text must NOT be spoken (see gotchas).
  • Parallel tool calls by model (tool-use page): parallel yes: qwen/qwen3.8-27b, minimaxai/minimax-m2.7, llama-3.3-70b-versatile, llama-3.1-8b-instant; parallel NO: openai/gpt-oss-20b, openai/gpt-oss-120b, openai/gpt-oss-safeguard-20b. All listed support local/remote tool use (the models page lists qwen/qwen3.8-27b and minimaxai/minimax-m2.7 under PREVIEW models, "not for production"). Per the local-tool-calling page's Error Handling section: tool_choice: "required" with no call -> 400; "none" can still produce a 400 on some models; a named function the model fails to call (or another function) -> 400.
  • Invalid tool call -> HTTP 400 failed_generation (local-tool-calling page; message "Invalid tool call generated"; retry advice: adjust temperature). The JSON below is as recorded earlier; on 2026-10-08 the fetch tool only confirmed the field names message, type, failed_generation {reason, tool_call_id, attempted_arguments} and the example reason "Tool call arguments are not valid JSON", so the exact values (call_abc123, etc.) are UNVERIFIED:
{
  "error": {
    "message": "Invalid tool call generated",
    "type": "invalid_request_error",
    "failed_generation": {
      "reason": "Tool call arguments are not valid JSON",
      "tool_call_id": "call_abc123",
      "attempted_arguments": "{'location': 'New York'}"
    }
  }
}

The errors page does not mention tool_use_failed. Whether this 400 can also occur mid-stream (after 200): UNVERIFIED.

  • Models (https://console.groq.com/docs/models, production): llama-3.1-8b-instant 560 tokens/s, 131,072 context, 131,072 max completion; openai/gpt-oss-20b 1,000 tokens/s, 131,072 context, 65,536 max completion; openai/gpt-oss-120b 500 tokens/s, 131,072 context, 65,536 max completion; llama-3.3-70b-versatile 280 tokens/s, 131,072 context, 32,768 max completion. Speed figures are the page's, not measured TTFT. Low-latency candidates for voice: llama-3.1-8b-instant (parallel tools yes) and openai/gpt-oss-20b (reasoning model, no parallel tools). Turkish quality: UNVERIFIED.
  • Status codes (errors page): 400, 401, 403, 404, 413, 422, 424, 429, 498 (custom: flex tier at capacity), 499 (custom: caller cancelled), 500, 502, 503 (plus 200/206 informational); "You will not be charged for requests that return server error codes" (500/502/503; 498/499 not stated). Body (doc): {"error": {"message": "String - description of the specific error", "type": "invalid_request_error"}}.

Together AI

Sources: https://docs.together.ai/docs/openai-api-compatibility , /docs/inference/function-calling/{overview,single-call,parallel,best-practices}.md , /reference/chat-completions.md , /docs/error-codes.md , /docs/serverless/rate-limits.md , /docs/serverless/models.md , /docs/inference/chat/parameters.md (fetched 2026-10-08).

  • Base URL https://api.together.ai/v1. Chat completions incl. streaming, function calling and response_format are supported.
  • Differences from OpenAI (compat page): service_tier, store, metadata, prediction accepted but ignored; seed best-effort; logit_bias unsupported on most models; n not on every model; "Extra token counts such as cached and reasoning tokens can appear in different locations depending on the model, so clients may need to check both shapes"; chain-of-thought in a reasoning or reasoning_content field on the assistant message, "the field name depends on the model"; id and system_fingerprint use Together formats; errors "follow OpenAI's shape, but type and code values are Together's. Match on HTTP status codes".
  • Reference (ChatCompletionChunk): required id, object (chat.completion.chunk), created, choices, model; optional system_fingerprint, usage, warnings. choices[]: index, delta, finish_reason (nullable), logprobs, top_logprobs, seed. delta: role (required: UNVERIFIED as "required" on 2026-10-08), optional content, reasoning, reasoning_content, tool_calls, function_call (deprecated), token_id. usage (prompt_tokens, completion_tokens, total_tokens) is nullable, "typically null except where populated". finish_reason values: stop, eos, length, tool_calls, function_call. eos is a Together-only value; treat as Stop.
  • stream_options and parallel_tool_calls: NOT defined on the reference page (confirmed 2026-10-08: ChatCompletionRequest has only stream as a boolean) nor the parameters page: support UNVERIFIED. Do not rely on include_usage; accept usage if present. The function-calling parallel page documents parallel calls as a model behaviour (three calls with index 0,1,2 and distinct ids such as call_aisak3q1px3m2lzb41ay6rwf), without a toggle.
  • tool_choice (best-practices): "auto" default, "none", "required", or {"type": "function", "function": {"name": "get_current_stock_price"}}. strict: true constrains arguments (streaming interaction not described).
  • max_tokens; context_length_exceeded_behavior defaults to error (truncate overrides max_tokens with the model maximum; per the reference, error "returns 400 with an appropriate error message", which CONFLICTS with the error-codes page's 403, see below).
  • Models (serverless catalog page; column "function calling" as extracted): Qwen/Qwen3.5-9B (262,144 context, yes; $0.17 in / $0.25 out per 1M), zai-org/GLM-5.3-Flash (1,048,575, yes; $0.15 / $0.50, cached input $0.03), openai/gpt-oss-120b (131,072, yes; $0.15 / $0.60), meta-llama/Llama-3.3-70B-Instruct-Turbo (131,072, yes; $1.04 / $1.04), Qwen/Qwen3.8-Flash (1,000,000 context, no function calling listed), together/Tev1-4B-experimental (32,768, no). The page does not mention Llama 3.1 8B (earlier text "isn't in the serverless chat catalog" was an inference, not a page statement). The page has no latency figures; pick by measurement. Turkish: UNVERIFIED.
  • Errors (error-codes page, no JSON body shown): 400, 401, 402 (monthly spend limit), 403 is documented as "Bad Request": "Input token count + max_tokens parameter must be less than the context length of the model being queried" (a context overflow is documented as a 403 here, not 400, while the chat-completions reference says 400 for context_length_exceeded_behavior=error: pages disagree, handle both), 404 (bad URL or model name), 429 (serverless limited under high demand, or GPU quota for dedicated), 500, 503, 504, 524 (Cloudflare timeout), 529 (unspecified server error). "Serverless performance is best-effort."

Fireworks AI

Sources: https://docs.fireworks.ai/tools-sdks/openai-compatibility , /api-reference/post-chatcompletions , /guides/function-calling , /guides/querying-text-models , /guides/quotas_usage/rate-limits , /guides/inference-error-codes , /getting-started/quickstart (fetched 2026-10-08).

  • Base URL https://api.fireworks.ai/inference/v1. Model IDs are accounts/fireworks/models/<id> (examples on the quickstart: glm-5p3-flash, kimi-k3, deepseek-v3p1, glm-5p3); dedicated deployments accounts/<ACCOUNT_ID>/deployments/<DEPLOYMENT_ID> (UNVERIFIED on 2026-10-08, not re-seen in fetched text).
  • Compat page, "minor differences": max_tokens behaves differently when the context is exceeded (context_length_exceeded_behavior: truncate is the DEFAULT and lowers max_tokens; error matches OpenAI). No supported/unsupported parameter list exists on the page.
  • Streaming usage: Fireworks returns usage stats for streaming too. The compat page says "the usage field is returned in the very last chunk on the response"; the querying-text-models page says usage is in the final chunk, "the chunk with finish_reason set", and calls this a "Fireworks extension" (that label appears only on the querying page, not on the compat page). Earlier chunks have "usage":null (UNVERIFIED on 2026-10-08). The API reference says instead: stream_options (streaming only) "Unlike the OpenAI spec, Fireworks includes usage by default for streaming responses; set this to false to opt out" (parameter include_usage), and usage arrives as a final chunk, "not the chunk carrying finish_reason", before data: [DONE] (the "empty choices array" wording and "Per-choice usage may appear on the finish chunk, depending on the deployment" were not re-confirmed: UNVERIFIED verbatim). The pages disagree; the adapter must take usage from whichever chunk carries it (finish chunk, trailing empty-choices chunk, or a per-choice usage). Other stream_options fields: include_internal_content ("Omitted entirely from response chunks when false"), buffer_tokens ("Honored only on the vLLM backend"), buffer_mode (default 'any'), buffer_ms (not re-confirmed: UNVERIFIED); coalescing, adds latency, leave unset.
  • Request: parallel_tool_calls (boolean, "Enable parallel function calling" per the reference; the guide says only "Some models support parallel tool calling ... Check the model's capabilities"), tool_choice (default auto; none; required (alias any); allowed_tools; named function: these enumerations were not re-confirmed on 2026-10-08, UNVERIFIED). The API reference writes the named form as { "type": "function", "name": "my_function" } while the guide writes {"type": "function", "function": {"name": "get_weather"}} (doc): conflicting; use the OpenAI nested form (guide) and test the other. max_tokens (max_completion_tokens is an "Alias for max_tokens. Cannot be specified together with max_tokens."), prompt_cache_key, prompt_cache_isolation_key (not re-confirmed), perf_metrics_in_response (streaming: perf_metrics object on the final chunk; the reference says setting it to true "additionally includes the same metrics in the response body under the perf_metrics field"), response_format. Tool support per model is exposed as a supportsTools field (guide troubleshooting; where it appears is not described).
  • Chunk delta (reference): role, content, reasoning_content, tool_calls, internal_content. finish_reason: stop, length, function_call, tool_calls; null until the end.
  • Cancel: "Close the connection to stop generation and avoid billing for ungenerated tokens."
  • Errors (codes page): 400, 401, 402, 403, 404, 405, 408, 412, 413, 429, 500, 502, 503, 504, 520. 429 on serverless = account request/TPM limit exceeded; on dedicated/on-demand = deployment saturated ("there are no account-level rate limits"). JSON error body example: not on the page (UNVERIFIED shape; assume {"error":{"message":...}}).
  • Model examples: accounts/fireworks/models/glm-5p3-flash (a "Flash" variant; latency UNVERIFIED), deepseek-v3p1, kimi-k3. The model overview page does not list small fast models; see https://fireworks.ai/models (not fetched: UNVERIFIED). Turkish: UNVERIFIED.

vLLM (OpenAI-compatible server)

Sources: https://docs.vllm.ai/en/latest/serving/online_serving/openai_compatible_server/ , https://docs.vllm.ai/en/latest/features/tool_calling/ , vllm source entrypoints/openai/chat_completion/serving.py, entrypoints/chat_utils.py (raw GitHub, main) (fetched 2026-10-08). The old URL .../serving/openai_compatible_server.html redirects to the one above (UNVERIFIED on 2026-10-08).

  • Start: vllm serve NousResearch/Meta-Llama-3-8B-Instruct --dtype auto --api-key token-abc123 (doc; the fetch confirmed the beginning vllm serve NousResearch/Meta-Llama-3-8B-Instruct, the remaining flags UNVERIFIED); client example base_url="http://localhost:8000/v1". Default served model name = the model argument (UNVERIFIED: not read).
  • Chat API: parallel_tool_calls supported: "Setting the parallel_tool_calls parameter to false ensures vLLM only returns zero or one tool call per request"; multiple calls with true are model dependent. Only unsupported item noted: image_url.detail; user is ignored. Extra parameters (selected): top_k, min_p, repetition_penalty, min_tokens, chat_template_kwargs, structured_outputs, priority, request_id, cache_salt (the list other than stream_interval was not re-confirmed: UNVERIFIED), stream_interval ("Number of tokens to batch into each streamed chunk"; leave at default for latency). The page does not mention stream_options / continuous_usage_stats.
  • Source (serving.py): with include_usage a trailing chunk with choices=[] and usage is emitted after the token loop (serialized with exclude_none=True; the earlier note that it also uses exclude_unset=True was not confirmed); include_continuous_usage puts usage on EVERY chunk (also the initial role chunk and echo chunks, running counts; the exact completion_tokens=0 on the first chunk is UNVERIFIED); every path ends with data: [DONE] (yielded unconditionally at the end of the generator, even after an error chunk). Errors mid-stream are yielded as data: {...} error chunks (via create_streaming_error_response or _convert_generation_error_to_streaming_response for GenerationError), followed by [DONE] (body shape not read: UNVERIFIED). A GenerationError frame is sent without an explicit extra [DONE] other than the generator's final one.
  • finish_reason (source): when tool calls were streamed and the reason is stop, it is rewritten to "tool_calls" - EXCEPT when a named tool_choice function is used, where it stays "stop" even though a tool call was produced. (Non-streaming: also tool_calls when tool_choice == "required" and the engine said stop.) Adapter rule: derive ToolCalls when any tool call was accumulated, whatever the finish_reason says.
  • Tool calling requires --enable-auto-tool-choice ("mandatory") plus --tool-call-parser <name> (e.g. hermes for Hermes/Qwen2.5, mistral, llama3_json, llama4_pythonic, pythonic, granite, deepseek_v3, kimi_k2, glm45, qwen3_xml, ... plus --tool-parser-plugin; the page lists many more, e.g. internlm, jamba, xlam, openai, hf). Without the flags a request with tool_choice: "auto" fails (exact behaviour/error text UNVERIFIED: the page only says the flag is mandatory). tool_choice: named function (default on), auto, required (vllm >= 0.8.3), none. Named function and required use structured outputs: "guaranteed a validly-parsable function call - not a high-quality one". Parallel support is per model ("Parallel tool calls are not supported for Llama 3, but it is supported in Llama 4 models"; Mistral 7B struggling: not re-confirmed). The hf parser rejects "strict tools, required or named tool choice, or parallel_tool_calls=false". Mistral parsing "requires tool call IDs that are exactly 9 digits" - per the page this applies to the Transformers tokenization backend only, where vLLM's longer ids cause an error; "provided tool_call_id fields are truncated to the last 9 digits". (Not in chat_utils.py.)
  • Tool call id format (source make_tool_call_id): chatcmpl-tool-<uuid>; kimi_k2 parser: functions.<name>:<idx> (used for model types kimi_k2, kimi_k25, kimi_k3; so ids can be long; do not impose a short max).
  • Performance / concurrency: set by the deployment (GPU, batch size); not an API limit. 429/503 behaviour: not researched (UNVERIFIED). Model examples: any HF model; latency = deployment.

Ollama (OpenAI compatibility)

Sources: https://docs.ollama.com/api/openai-compatibility , /api/authentication , /api/errors , /api/usage , /capabilities/tool-calling , /capabilities/streaming , /faq ; ollama source openai/openai.go, middleware/openai.go, server/routes.go, api/types.go (raw GitHub, main) (fetched 2026-10-08). The old github.com/ollama/ollama/blob/main/docs/openai.md returned 404 (not re-checked 2026-10-08).

  • Base URL local http://localhost:11434/v1/, cloud https://ollama.com/v1. Responses API (/v1/responses, non-stateful only) exists since v0.13.3.

  • /v1/chat/completions supported request fields: model, messages (text, base64 images, parts arrays; image URLs not supported), frequency_penalty, presence_penalty, response_format, seed, stop, stream, stream_options (only include_usage), temperature, top_p, max_tokens, tools, reasoning_effort, reasoning (effort: the effort sub-field was not re-confirmed). Not supported: tool_choice, logit_bias, user, n, logprobs. max_completion_tokens and parallel_tool_calls are not in the list: whether they are ignored or rejected is UNVERIFIED (assume unknown JSON fields are ignored; make both opt-in per provider config).

  • Streaming (source middleware/openai.go): each chunk is written as data: <json> followed by a blank line; order is content chunks, a dedicated finish chunk (empty delta, finish_reason), then (only with include_usage) a second finish-style chunk with usage and a non-standard timings object (durations in milliseconds) and choices: [], then data: [DONE]. Successful streams use Content-Type: text/event-stream. Chunk id is fmt.Sprintf("chatcmpl-%d", rand.Intn(999)) (i.e. 0..998; collisions possible; do not treat id as unique). system_fingerprint is "fp_ollama". created is taken from the first response and reused. The role is sent only on the first chunk. A response with both thinking and content/tool calls may be split into two chunks (ToStreamChunks); thinking goes in delta.reasoning (field name reasoning not re-confirmed: UNVERIFIED).

  • finish_reason (source): DoneReason or "stop"; overridden to "tool_calls" only when the reason is stop and a tool call was sent; other reasons (e.g. length) pass through.

  • Tool calls (source): each carries id (generated as call_ + 8 lowercase alphanumerics in the chat handler via math/rand; omitempty in the struct, so absent if a path does not set it), index, type: "function", function.name, function.arguments (a JSON string made by marshalling the parsed arguments map; empty if marshalling fails). The native API example shows index inside function ({"type":"function","function":{"index":0,"name":"get_temperature","arguments":{"city":"New York"}}}, native API, arguments as an OBJECT, not a string); tool results in the native API use {"role":"tool","tool_name":...,"content":...}; on the OpenAI path use tool_call_id (UNVERIFIED how the server maps tool_call_id back; the docs never mention tool call ids). The native non-streaming path returns several parallel calls in one assistant message.

  • Usage: prompt_tokens, completion_tokens, total_tokens = prompt + eval, optional prompt_tokens_details.cached_tokens (only if the cached count is present). Native metrics: prompt_eval_cached_count, durations in nanoseconds (load_duration, total_duration, ...); for streaming they are in the final chunk where done is true.

  • Errors: native body is {"error": "the model failed to generate a response"} (a STRING) with codes 200/400/404/429/500/502; "If an error occurs mid-stream, the error will be returned as an object in the application/x-ndjson format with an error property" and the status stays 200. On the OpenAI path (source openai/openai.go): {"error": {"message","type","param","code"}} with type invalid_request_error (400), not_found_error (404), else api_error; param and code serialize as null (not re-confirmed). The chat writer has no SSE error event: a non-200 status is sent as plain application/json; how a mid-stream failure appears on the OpenAI path: UNVERIFIED (routes.go streamResponse writes a final NDJSON error line for the native path once output has started).

  • Concurrency (faq): OLLAMA_NUM_PARALLEL default 1 per model (the page says "default 1"), OLLAMA_MAX_LOADED_MODELS default 3 * GPUs (3 on CPU), OLLAMA_MAX_QUEUE default 512; a full queue answers "503 ... server is overloaded". Models stay loaded 5 minutes (OLLAMA_KEEP_ALIVE, per-request keep_alive overrides; negative = forever, 0 = unload: the per-request/negative/0 details were not re-confirmed). The documented OpenAI fields have no keep_alive, so set OLLAMA_KEEP_ALIVE on the server or warm via the native API; the first request after unload pays load_duration. A cold model is the main latency risk for a voice call: send a warm-up request before the call.

  • Context size: "The OpenAI API does not have a way of setting the context size for a model"; use a Modelfile with PARAMETER num_ctx and ollama create.

  • Model examples on the compat page: gpt-oss:20b, qwen3:8b, qwen3-vl:8b (chat examples), llama3.2 (ollama pull llama3.2), gemma4:31b (cloud direct access; gemma4:cloud through a signed-in local server). Turkish quality: UNVERIFIED.

  • Live results (2026-10-14, Ollama 0.34.1, RTX 5070 12 GB, reasoning_effort = "none", default context 4096). Tool calling is per model and was measured: six scenarios x three runs through the real engine, and a warm voice-style latency (5 runs, p50). Details and failure notes: docs/TOOLS.md, "Local models through Ollama".

    ModelGPU memoryTool scenarios (of 18)TTFT p50First sentence p50
    gemma4:12b-it-qat7.7 GB1869 ms267 ms
    gemma4:e4b-it-qat3.1 GB12 (fails the 3-round chain and the invalid-argument retry)42 ms157 ms
    qwen3.5:4b3.1 GB12 (same two)65 ms208 ms
    qwen3.5:9b5.5 GB12 (flaky; once answered with made-up weather, no tool call)79 ms291 ms
    qwen2.5-coder:7b4.7 GB0 (writes the call as JSON text, which would be spoken)34 ms220 ms
  • Thinking models. Set reasoning_effort = "none" (the config key of the same name): the ollama preset does not send it by itself. /v1/chat/completions honours it (and the nested reasoning: {effort: "none"}) but ignores the native think: false. Without it qwen3.5 spent a whole 200-token budget on hidden reasoning and never spoke; with medium effort the 9b model passed all tool runs but took 14 to 35 s to the first word.

  • GPU memory. gemma4:12b-it-qat is 7.7 GB at the default context. With only ~5 GB free, 30 of 48 layers on the GPU gave first sentence p50 1.66 s (TTFT 376 ms; native API with num_gpu = 30).

Known deviations (checklist for the adapter and its tests)

  1. Tool-call shape: fragments (OpenAI, Together, Fireworks) vs whole calls (Ollama most likely; vLLM hf parser). Same code path must accept both. Never require arguments:"" on the first delta.
  2. Missing or null fields: id optional on delta types for Groq, Fireworks, Together, Ollama (omitempty); index is null in Together's non-streaming parallel-multiple example. Fallbacks: if index is missing, use the position in tool_calls[] of that delta, and if a new id appears for an index already holding a different id, start a new call. If no id ever arrives, synthesise one (call_<n>) so the tool message can reference it. Different servers emit ids of very different formats (call_xxx, chatcmpl-tool-<uuid>, functions.name:0): treat ids as opaque strings, no length limit assumed (OpenAI function names are limited to 64 chars, see openai.md).
  3. finish_reason values: stop, length, tool_calls, content_filter (OpenAI), function_call (deprecated, OpenAI/Groq/Fireworks/Together), eos (Together). Mapping to brisvox FinishReason: length -> Length; tool_calls/function_call -> ToolCalls; stop/eos/content_filter -> Stop (content_filter has no variant, same gap as openai.md); additionally force ToolCalls when any call was accumulated (vLLM named tool_choice keeps stop).
  4. Usage placement: OpenAI: trailing chunk, choices: [], before [DONE]. vLLM: same, plus optional per-chunk usage. Ollama: same (extra timings). Fireworks: documented as BOTH "on the finish chunk" and "separate trailing chunk" (pages disagree; on by default, opt-out). Groq: top-level usage and x_groq.usage exist in the SDK type, placement UNVERIFIED. Together: usage nullable, stream_options support UNVERIFIED, cached/reasoning counts "in different locations depending on the model". Rule: remember the last non-null usage seen anywhere (top-level, x_groq.usage, per-choice usage UNVERIFIED), emit it in Done; do not wait for it past the terminal event more than a short grace (a missing usage must not delay Done; the stream can end at [DONE] or at EOF). Cached tokens: usage.prompt_tokens_details.cached_tokens (OpenAI, Ollama); other vendors UNVERIFIED -> cached_input_tokens = 0.
  5. Reasoning deltas: Groq delta.reasoning (only with reasoning_format=parsed), Ollama delta.reasoning, Fireworks delta.reasoning_content, Together reasoning / reasoning_content (model dependent). These must never reach TTS: ignore them for TextDelta (optionally count latency to first non-reasoning token). With reasoning models the first visible token is later than the first chunk: first-output timeout must exceed that (as in openai.md). Prefer non-reasoning models or reasoning_effort: none where supported (Groq: Qwen3; OpenAI: see openai.md).
  6. Unsupported / rejected request fields: Groq 400 on logprobs, logit_bias, top_logprobs, messages[].name, n != 1; Ollama lacks tool_choice, n, logit_bias; Together ignores service_tier, store, metadata, prediction. Send only what is needed: model, messages, stream, tools (when non-empty), max_tokens or max_completion_tokens (configurable name), temperature; everything else behind explicit per-provider config (stream_options.include_usage, parallel_tool_calls, tool_choice).
  7. Max-token field name: OpenAI/Groq prefer max_completion_tokens (max_tokens deprecated); Ollama, Together, Fireworks (accepts both, not together), vLLM (docs not read for it) document max_tokens. Make it a config enum, default max_tokens.
  8. Error bodies: OpenAI-style {"error":{"message","type","param","code"}} (OpenAI, Groq message+type, Together "OpenAI's shape ... Together's values", Ollama OpenAI path); Ollama native {"error":"string"}; vLLM mid-stream data: {...error...} then [DONE]; Ollama native mid-stream NDJSON {"error":...} with status 200. Parser: accept error as an object or a string; also accept x_groq.error. Never copy free text into logs/errors verbatim (can echo prompt), same policy as openai.md. HTTP status is the portable signal (Together's own advice).
  9. Context-overflow status: Together documents 403 (not 400) for input + max_tokens over the context on its error-codes page (but its chat-completions reference says 400 for context_length_exceeded_behavior=error); Fireworks silently truncates max_tokens by default (truncate); OpenAI/Groq 400 (Groq 400 not re-verified for this case). Map any of 400/403/413/422 on the pre-stream response to Rejected { status } unchanged; the LLM stage decides.
  10. Custom status codes: Groq 498 (flex tier at capacity) and 499; Together 524, 529; Fireworks 520; Ollama 502 (unreachable cloud model) and 503 (queue full). Treat 429, 498, 502, 503, 504, 520, 524, 529 as retryable-at-turn-level (the adapter itself never retries, like openai.md).
  11. SSE terminator: all five document or implement data: [DONE] (Groq stated in the API reference; Together, Fireworks stated in references (not re-confirmed for Together); vLLM and Ollama in source). A stream that ends without [DONE] and without a finish_reason was cut off -> Error(Disconnected). A stream that has a finish_reason but no [DONE] before EOF: accept as complete only after a short bounded wait for the usage chunk (UNVERIFIED how each vendor behaves).
  12. Non-standard chunk fields: Together warnings, token_id, seed, top_logprobs; Ollama timings, system_fingerprint: "fp_ollama"; Fireworks perf_metrics, internal_content; Groq x_groq. Parse leniently, no deny_unknown_fields; parse type -like discrimination in two steps as openai.md does for events.
  13. Chunk id not unique (Ollama random number < 999): never key state by chunk id.
  14. Empty choices can appear for usage chunks (all) and possibly for keep-alives (UNVERIFIED): treat choices: [] as "no content", not an error. choices[0] only (n = 1).
  15. Role-only first chunk delta: {"role":"assistant","content":""} (OpenAI) or content: null: empty string must not produce a TextDelta.

Rate limits and error bodies

Headers (documented)Notes
OpenAIx-ratelimit-limit-requests, -limit-tokens, -remaining-requests, -remaining-tokens, -reset-requests, -reset-tokens, x-request-id, Retry-After sometimes (openai.md)
Groqretry-after (seconds, set only on 429; sample value 2), x-ratelimit-limit-requests (14400, always refers to daily RPD), x-ratelimit-limit-tokens (18000, always per-minute TPM), x-ratelimit-remaining-requests (14370), x-ratelimit-remaining-tokens (17997), x-ratelimit-reset-requests (2m59.56s), x-ratelimit-reset-tokens (7.66s) (page says the sample values are "illustrative"; the six x-ratelimit-* headers are on every response)Example limits for openai/gpt-oss-120b: 30 RPM, 1K RPD, 8K TPM, 200K TPD; the page says the listed values are base Developer plan limits (the page does not say which plan that specific row is: UNVERIFIED). Limits per model and plan; check the account Limits page. Voice agents on low plans will hit TPM quickly
Togethernone documented (rate-limits page names no headers, tiers or values)429 and 503 under high demand; "avoid sending large bursts"; "Use exponential backoff when retrying"; "Serverless performance is best-effort"
Fireworksnone documented on the rate-limits page (response-header names UNVERIFIED; the error-codes page mentions "rate-limit response headers" for serverless 429s without naming them)Account-wide: no payment method/credits 10 RPM; with payment method and credits 6,000 RPM (fixed ceiling, "applies to all API usage on the account", "regardless of spending tier"); tiers by spend: Tier 1 valid payment method, Tier 2 $50, Tier 3 $500, Tier 4 $5,000 (spend or add credits), Unlimited (contact sales) set serverless TPM upper bounds (not RPM). Dedicated/on-demand: no account limits, 429 = saturation (the claim that such requests still count towards the account request limit is UNVERIFIED)
vLLMnone (deployment-defined)UNVERIFIED
Ollamanone; queue-full -> 503OLLAMA_MAX_QUEUE 512, OLLAMA_NUM_PARALLEL 1 default

Adapter rule: log retry-after and x-ratelimit-* when present (bounded body read, same limits as openai.md: 8 KiB, 1 s), never retry inside the adapter.

Mapping onto brisvox (crates/brisvox-providers/src/llm.rs)

  • LlmRequest { messages, tools } -> {"model", "messages":[...], "stream":true, "tools":[{"type": "function","function":{"name","description","parameters"}}]}. Role::Tool -> {"role":"tool","tool_call_id","content"}; Role::Assistant with tool_calls -> tool_calls:[{"id","type":"function","function":{"name","arguments"}}] with content null/"" when empty. Message::provider_data is ignored (not this adapter's). Tools whose parameters_schema is not valid JSON -> Protocol before any I/O. Omit tools when empty (some servers reject tools: []: UNVERIFIED, but omitting is safe).
  • Events: delta.content (non-empty) -> TextDelta; delta.tool_calls[] -> ToolCallDelta { index, id, name, arguments } (fragment or whole); terminal chunk / [DONE] -> Done. Dense index renumbering is safe (key by upstream index, fallback by id).
  • Pre-stream HTTP errors -> Err(Rejected { status }); in-stream error object -> LlmEvent::Error.
  • Timeouts: connect, first-byte, first-output (longer for reasoning models), idle between events, total; none of the five publishes a keepalive spec (SSE comment lines : should be tolerated, they do not count as progress, same as openai.md).
  • Cancel: drop LlmStream -> drop the HTTP body (closes the connection; only Fireworks documents billing stops).
  • Warm-up: HEAD/connection pre-warm as in openai.md is optional; for Ollama a real tiny generation (native API) may be needed to load the model (see above). UNVERIFIED that HEAD {base}/chat/completions answers any status on all five.

Latency-oriented model examples (docs list names/speeds only; no TTFT numbers anywhere)

  • Groq: llama-3.1-8b-instant (560 tokens/s), openai/gpt-oss-20b (1,000 tokens/s), openai/gpt-oss-120b (500 tokens/s).
  • Together: Qwen/Qwen3.5-9B, zai-org/GLM-5.3-Flash, openai/gpt-oss-120b (function calling listed yes for these).
  • Fireworks: accounts/fireworks/models/glm-5p3-flash.
  • Ollama: measured, see "Live results" in the Ollama section above (gemma4:12b-it-qat recommended); gpt-oss:20b, qwen3:8b, llama3.2 are names from the vendor's examples (not measured).
  • vLLM: whichever model the deployment serves. Pricing pages were not fetched (UNVERIFIED for all vendors except the Together per-1M prices above, which were re-checked 2026-10-08 on the serverless models page).

Implementation notes

Code: brisvox_providers::openai_compatible (feature provider-openai-compatible), tests in crates/brisvox-providers/tests/openai_compatible/ (one loopback mock, one wire dialect per preset, the shared contract suite once per preset, protocol tests, an opt-in live test).

  • Reading is one tolerant path, not a flag per vendor. The docs disagree with themselves (Fireworks usage placement, Together index: null, Groq usage location), so the chunk mapper accepts every documented shape at once: tool calls as fragments or whole, missing / null index, empty or missing id (synthetic call_<n>), usage at the top level, in x_groq.usage or in the choice, finish words stop / length / tool_calls / function_call / eos / content_filter / unknown, arguments as a string (or, defensively, a JSON object), error bodies as objects or strings, x_groq.error. Quirks only decides what is sent and how long the end of a response waits (see below).
  • Call identity. A fragment with an index belongs to the newest call with that index, unless it carries a different non-empty id (then it opens a new call: a vendor numbering every call 0), and so does a fragment whose name differs from the name the call already has (whole calls of an Ollama-style vendor may all say index: 0 and carry no id; a repeated identical name is taken as a continuation). Without an index: a known id continues that call; no id but a name opens a call; neither continues the newest call. Names are emitted once.
  • Finish. A response that accumulated tool calls ends in ToolCalls when the vendor said stop / eos / unknown (vLLM keeps stop for a named tool_choice), but stays Length when it was cut by length (the arguments may be truncated; the caller must not run them).
  • When Done is emitted. At the chunk with finish_reason if that chunk carries usage or Quirks::usage is FinishChunk / Absent; otherwise (TrailingChunk, the default and the presets for Groq, Fireworks, vLLM, Ollama) at the usage chunk, [DONE], EOF or after usage_grace (250 ms), whichever is first. Only usage on the finish chunk itself counts as final: running totals on earlier chunks (vLLM continuous_usage_stats) are kept, but the response still waits for the trailing chunk and reports the last usage seen. With FinishChunk / Absent a separate usage chunk after the finish chunk is deliberately not waited for (the preset says there is none; a vendor that sends one belongs on TrailingChunk). A stream that ends or breaks (EOF, reset, cut chunked body) after its finish_reason is complete; one that ends before it is Disconnected; [DONE] without any finish_reason completes as Stop when no tool call is open (text only). Decision (review fix): [DONE] without a finish_reason while tool-call fragments were seen is a cut, mapped to Disconnected exactly like an end of body there: a proxy or gateway can abort a generation mid call and still write [DONE], and the consumer must not get Done { ToolCalls } with truncated arguments (a SideEffect tool could run on them). The conservative side was chosen because a well-formed tool-call turn always carries a finish_reason on all five vendors' documented streams; a custom server that omits it for tool calls will see Disconnected.
  • Decision: tool-call shape, finish_reason variants and error body shapes are read by one tolerant path, not selected by per-vendor flags (the original plan asked for quirk flags for them). Every shape the docs list is accepted at once, so a preset cannot be wrong about them and a custom server needs no flag; Quirks keeps only what changes the request or the wait (max_tokens_field, request_stream_usage, usage, parallel_tool_calls, tool_choice) and the one read-side switch synthesize_missing_ids (off: a call without an id is a protocol error). Not offered: forcing fragmented-only parsing, or making stop with tool calls an error. Add a flag when a live run shows a vendor whose stream the tolerant path misreads.
  • URL assembly. The chat URL is built with a real URL parser, not by string concatenation: chat_path is appended to the path of base_url, the query of base_url (?key=...) stays a query after the path, a query on chat_path is appended to it with &, and fragments are dropped (they are never sent). https://gw.example/v1?key=abc + /chat/completions posts to /v1/chat/completions?key=abc. A base URL that cannot carry a path (mailto:) is refused at construction.
  • Secrets in the endpoint. The base URL and chat path may carry credentials. They are never logged (transport errors are logged by class, not with reqwest's URL-bearing Display), and Debug of the configuration prints scheme, host and port plus header and body-field names. extra_body must not set temperature / parallel_tool_calls while the adapter sets them (no duplicate keys in the body).
  • Never spoken / never used. delta.reasoning, reasoning_content, internal_content, refusal, annotations, x_groq extras, timings, warnings, the chunk id. A refusal is dropped, so a model that refuses answers with silence (UNVERIFIED whether any of the five sends refusals on Chat Completions; revisit with the live run).
  • Sent only when configured: stream_options.include_usage (Groq, vLLM, Ollama presets), parallel_tool_calls, tool_choice (no preset sets them: Ollama has no tool_choice, the parallel-call support of Groq differs per model), max_completion_tokens instead of max_tokens (Groq preset). tools is omitted when empty. Replayed assistant tool calls with empty arguments are sent as "{}" (a server that parses the string back would reject ""), an assistant turn that only called tools has content: null. Message::provider_data is ignored.
  • Errors. Pre-stream non-2xx is Rejected { status } unchanged (including Together's 403 for context overflow, Groq 498/499, Fireworks 520, Together 524/529). Retry-After and the sanitised error type / code are logged, the message never. In-stream error (object or string): a numeric code / status 400-599 is that status; rate_limit_* -> 429, server_error / api_error -> 500, overloaded / service_unavailable -> 503, invalid_request_error -> 400, authentication_error -> 401; anything else is Protocol. Ollama's native mid-stream NDJSON error line ({"error": "..."} without data:) is not SSE: the SSE parser ignores it and the response ends as Disconnected (UNVERIFIED whether the /v1 path ever emits it).
  • Warm-up is an unauthenticated HEAD to the chat URL (UNVERIFIED that every vendor answers it; any HTTP status counts, a refused connection is Disconnected). It does not load an Ollama model: send a real tiny request before the call (see Ollama above).
  • Usage cached tokens are read from usage.prompt_tokens_details.cached_tokens only (OpenAI, Ollama); other locations Together mentions are UNVERIFIED, so those vendors report 0.
  • Bounds: 128 tool calls, 1 MiB arguments per call, 4 MiB per response, SSE event cap 1 MiB (all Protocol when exceeded), event channel 64 with back-pressure.
  • Plain http: allowed for loopback; a vLLM / Ollama on another host needs allow_insecure_http.
  • Live: BRISVOX_LIVE_TESTS=1 plus GROQ_API_KEY / TOGETHER_API_KEY / FIREWORKS_API_KEY and/or OPENAI_COMPATIBLE_BASE_URL + OPENAI_COMPATIBLE_MODEL (+ OPENAI_COMPATIBLE_API_KEY). It prints the number of tool-call fragments and the time to first token per vendor; use the output to fill in the UNVERIFIED items below .

Live-test acceptance

Blocked: needs API keys (Groq, Together, Fireworks) and a local vLLM / Ollama.

  • BRISVOX_LIVE_TESTS=1 with the keys in .env: tests/openai_compatible/live.rs passes per vendor (streaming text and a one-tool loop).
  • Confirm or correct every item in the UNVERIFIED list below, and fix the mock dialects in tests/openai_compatible/wire.rs where a live capture differs: tool-call fragmentation per vendor, usage placement and stream_options acceptance, finish_reason words, mid-stream error body shapes, whether the five vendors send [DONE] after a tool-call finish_reason, HEAD warm-up answers.

UNVERIFIED (consolidated)

  • Wire captures of delta.tool_calls for every vendor (no live run; docs have no chunk JSON except Together's single "later delta" and the OpenAI first chunk).
  • Groq: placement of streaming usage (top-level vs x_groq.usage), stream_options.include_usage acceptance (not in groq-python params), whether tool-call arguments are fragmented, mid-stream error delivery (x_groq.error), Authorization header spelled out as text, exact failed_generation JSON values, reasoning_format values other than parsed.
  • Together: stream_options / include_usage and parallel_tool_calls parameters, usage in the last chunk, rate-limit headers, JSON error body, streaming index always integer, 400 vs 403 for context overflow, verbatim wording of the overview-page streaming sentence.
  • Fireworks: which usage placement is real (pages conflict), error JSON body, response rate-limit headers, small/fast model list, parallel_tool_calls per-model support, named tool_choice form (conflict {"type":"function","name"} vs nested), tool_choice enumeration, buffer_ms, prompt_cache_isolation_key, dedicated-deployment model id format.
  • vLLM: default host/port, auth header name, streaming fragmentation per parser (only hf documented as whole), exact failure behaviour without --enable-auto-tool-choice, mid-stream error body shape, stream_options acceptance in docs (source shows include_usage), max_tokens vs max_completion_tokens handling, 429/503 behaviour, full vllm serve flags.
  • Ollama: whether streamed tool calls are always whole, whether max_completion_tokens / parallel_tool_calls are ignored or rejected, mid-stream error shape on /v1, mapping of tool_call_id back to a call, Authorization handling for a reverse-proxied local server, handleNativeChat tool-call id assignment.
  • Turkish support of every listed model and vendor; regions/data residency of every vendor; keepalive/idle timeouts of every vendor; pricing; TTFT numbers.
  • openai-python _deltas.py merge rules (404 in the fetch); the openai.md claim about GPT-6 and Responses.

Sources (all fetched 2026-10-08)

  • OpenAI chunk reference: https://developers.openai.com/api/reference/resources/chat/subresources/completions/streaming-events
  • OpenAI SDK: https://raw.githubusercontent.com/openai/openai-python/main/src/openai/types/chat/chat_completion_chunk.py , .../types/chat/completion_create_params.py , .../lib/streaming/chat/_completions.py , https://developers.openai.com/api/docs/guides/function-calling
  • Groq: https://console.groq.com/docs/openai , https://console.groq.com/docs/tool-use , https://console.groq.com/docs/tool-use/local-tool-calling , https://console.groq.com/docs/api-reference , https://console.groq.com/docs/rate-limits , https://console.groq.com/docs/errors , https://console.groq.com/docs/models , https://console.groq.com/docs/text-chat , https://raw.githubusercontent.com/groq/groq-python/main/src/groq/types/chat/chat_completion_chunk.py , .../completion_create_params.py
  • Together: https://docs.together.ai/docs/openai-api-compatibility , https://docs.together.ai/docs/function-calling , https://docs.together.ai/docs/inference/function-calling/single-call.md , .../parallel.md , .../overview.md , .../best-practices.md , https://docs.together.ai/reference/chat-completions.md , https://docs.together.ai/docs/error-codes.md , https://docs.together.ai/docs/serverless/rate-limits.md , https://docs.together.ai/docs/rate-limits , https://docs.together.ai/docs/serverless/models.md , https://docs.together.ai/docs/inference/chat/parameters.md , https://docs.together.ai/llms.txt
  • Fireworks: https://docs.fireworks.ai/tools-sdks/openai-compatibility , https://docs.fireworks.ai/api-reference/post-chatcompletions , https://docs.fireworks.ai/guides/function-calling , https://docs.fireworks.ai/guides/querying-text-models , https://docs.fireworks.ai/guides/quotas_usage/rate-limits , https://docs.fireworks.ai/guides/inference-error-codes , https://docs.fireworks.ai/getting-started/quickstart , https://docs.fireworks.ai/models/overview
  • OpenRouter: https://openrouter.ai/docs/api-reference/overview (fetched 2026-10-14)
  • vLLM: https://docs.vllm.ai/en/latest/serving/online_serving/openai_compatible_server/ , https://docs.vllm.ai/en/latest/features/tool_calling/ , https://raw.githubusercontent.com/vllm-project/vllm/main/vllm/entrypoints/openai/chat_completion/serving.py , https://raw.githubusercontent.com/vllm-project/vllm/main/vllm/entrypoints/chat_utils.py
  • Ollama: https://docs.ollama.com/api/openai-compatibility , https://docs.ollama.com/api/authentication , https://docs.ollama.com/api/errors , https://docs.ollama.com/api/usage , https://docs.ollama.com/capabilities/tool-calling , https://docs.ollama.com/capabilities/streaming , https://docs.ollama.com/faq , https://raw.githubusercontent.com/ollama/ollama/main/openai/openai.go , .../middleware/openai.go , .../server/routes.go , .../api/types.go

Fact-checked 2026-10-08: 78 claims checked, 13 corrected. Corrections: (1) Groq local-tool-calling page has no "Accumulate delta.content and delta.tool_calls" sentence, only a code example (collected_tool_calls.extend); (2) Groq "tool_call_id must match" quote not found verbatim, replaced by the page's actual wording; (3) Groq rate limits: values are "base Developer plan" limits, header sample values "illustrative", retry-after sample 2; (4) Groq delta.reasoning only populated when reasoning_format is parsed; (5) groq-python params do not define stream_options (only the API reference does); (6) Groq errors page does show a {message, type} body example; Groq tool_choice 400 behaviours and qwen/minimax models (preview, not production) clarified; (7) Fireworks: "finish_reason set" and "Fireworks extension" wording is from querying-text-models only, compat page says "very last chunk"; (8) Together context overflow: reference says 400 for context_length_exceeded_behavior=error, error-codes page says 403 (conflict recorded); Together 429/524/529 meanings and Llama 3.1 8B statement (page silent) fixed; (9) Mistral 9-digit ids apply to the Transformers tokenization backend only and are not in chat_utils.py; (10) vLLM serialisation is exclude_none (the exclude_unset claim unconfirmed), include_continuous_usage also covers initial/echo chunks; vLLM parser list is longer; (11) openai-python partial-JSON parsing applies only to strict tools; (12) Ollama chunk id is chatcmpl-%d with rand.Intn(999); timings only on the usage chunk; stream_options only include_usage; extra model examples qwen3-vl:8b; (13) Together Qwen3.8-Flash context is 1,000,000 and prices added; Fireworks 6,000 RPM is a fixed ceiling and the "requests still count towards the account limit" claim is unconfirmed. Items downgraded to UNVERIFIED: see the consolidated list above.

Anthropic — LLM (Claude Messages API: streaming, tool use, prompt caching)

Researched 2026-10-08. Every fact below came from a page fetched that day; URLs are listed per section and in "Sources". Pages were read through a summarising fetch tool (the streaming page was read from the saved raw markdown), so JSON/SSE blocks marked "(doc)" were copied from the docs and blocks marked "(illustrative)" were rebuilt from field lists and are NOT wire captures. Anything not confirmed is marked UNVERIFIED. A live capture must confirm wire shapes before they are trusted. The documented model generation (Claude 5.5 family, released Sep/Oct 2026) is newer than any training data: never hard-code model ids in logic, read them from config.

  • Docs: https://platform.claude.com/docs/en (old hosts docs.claude.com/en/api/... answer 301 to platform.claude.com/docs/en/...; the API-reference schema still links docs.anthropic.com) (fetched 2026-10-08)
  • Auth: x-api-key: <API key> plus anthropic-version: 2023-06-01 plus content-type: application/json. The overview page lists Authorization: Bearer <token> as the primary header ("Yes, unless x-api-key is set"; the token is an API key or a short-lived token from Workload Identity Federation) and calls x-api-key the "Legacy fallback ... still supported". The Messages examples on every page still use x-api-key; use that. Optional anthropic-workspace-id (required only with a multi-workspace key) and anthropic-beta (comma separated, or the header repeated). Source: https://platform.claude.com/docs/en/api/overview and https://platform.claude.com/docs/en/api/beta-headers
  • Endpoint(s) / regions: base https://api.anthropic.com; Messages: POST /v1/messages (also POST /v1/messages/count_tokens, GET /v1/models). Max request size 32 MB (413 request_too_large, returned by Cloudflare before the API servers). Region choice: request field inference_geo (string or null, defaults to the workspace default_inference_geo; "us" and "global" seen in docs; US-only inference is priced 1.1x on Claude 4.6+ per the service-tiers page). Supported-regions/data-residency pages were NOT fetched: UNVERIFIED which geos exist and any regional hostnames. Rate limits are shared across inference_geo values.
  • Versioning: anthropic-version: 2023-06-01 is the only non-initial version listed ("Anthropic recommends using the latest API version"; 2023-01-01 is the initial release). Within a version Anthropic may "Add additional optional inputs", "Add additional values to the output", "Change conditions for specific error types", "Add new variants to enum-like output values (for example, streaming event types)". So: serde without deny_unknown_fields, unknown event/delta/block types ignored. Source: https://platform.claude.com/docs/en/api/versioning

Decision (surface and model recommendation)

Use POST /v1/messages with stream: true, stateless full-history replay (the API is stateless: "you always send the full conversational history"; synthetic assistant turns are allowed), raw HTTP + SSE. No official Rust SDK: the seven official client SDKs are Python, TypeScript, C#, Go, Java, PHP, Ruby (https://platform.claude.com/docs/en/cli-sdks-libraries/overview). Community Rust crates were not evaluated: UNVERIFIED. The OpenAI-SDK-compatibility layer exists but is a "library/integration", not the Messages API, and is "not considered a long-term or production-ready solution for most use cases"; do not use it. Documented limitations of that layer (https://platform.claude.com/docs/en/cli-sdks-libraries/libraries/openai-sdk): prompt caching not supported, the OpenAI SDK does not return Claude's thinking, strict is ignored, system/developer messages are hoisted and concatenated at the start.

Models (ids from https://platform.claude.com/docs/en/models/overview, .../models/haiku-5-5/overview, .../api/messages/create model enum, all fetched 2026-10-08):

Model IDRole per docsPrice /MTok in / outCache write 5m / 1h / readLatency label
claude-haiku-5-5"For high-volume, latency-sensitive tasks such as classification, extraction, and routing"; API enum text "Fastest model for high-volume, real-time tasks"; reduce-latency page: "For speed-critical applications, Claude Haiku 5.5 offers the fastest response times while maintaining high intelligence"$0.10 / $0.50 for prompts <= 100,000 tokens; $0.50 / $2.50 above$0.125 / $0.20 / $0.01 (<= 100K); $0.625 / $1 / $0.05 (> 100K)"Fastest"
claude-sonnet-5-5"The best combination of speed and intelligence" (models overview; the API enum text says "Efficient model for coding and agents")$2 / $10$2.50 / $4 / $0.10"Fast"
claude-opus-5-5"For long-running agentic coding and knowledge work"$4 / $20$5 / $8 / $0.20"Moderate"
claude-fable-5-1"demanding reasoning and long-horizon agentic work"$10 / $50n/a here (read 0.025x)"Slower"
All four: 1M context, 128K max output, text+image in, text out, reliable knowledge cutoff Jun 2026.
claude-haiku-5-5 released 2026-10-07 (yesterday relative to this research), retirement "Not
sooner than October 7, 2027"; the id "is a fixed model ID with no date suffix and no separate alias"
(Haiku 5.5 migration guide). Legacy ids still listed: claude-sonnet-5, claude-haiku-4-5
(claude-haiku-4-5-20251001), claude-opus-5, claude-opus-4-8, ...; claude-sonnet-4-5*
deprecated (EOL 2026-11-30).
Recommended default for brisvox voice: claude-haiku-5-5 (docs call it the fastest; no TTFT or
tokens/s numbers are published on any page fetched, so absolute latency is UNVERIFIED: measure in
a live run), with claude-sonnet-5-5 as the quality tier. Reasons Haiku 5.5 fits a streaming
voice loop: forced tool_choice is accepted (Sonnet 5.5 and Opus 5.5 reject any/tool with 400),
thinking: {"type":"disabled"} is accepted at effort <= high, 128K output, 512-token cache minimum.
Haiku 5.5 restrictions (all HTTP 400, from the Haiku migration guide and thinking page):
  • temperature, top_p, top_k: omit. Haiku migration guide: if present, temperature must be 1, top_p must be 0.99 (a top_p of 1 is rejected), top_k any value is rejected, and temperature + top_p together is rejected. (The Messages-create reference words top_p as "A value >= 0.99 will be accepted", which disagrees on 1: send neither.) For Sonnet 5.5 and Opus 5.5 the docs say generically that Claude 4.7+ models return 400 for a non-default temperature/top_p/ top_k (working-with-messages page); the exact accepted values for those two were only read for Haiku: UNVERIFIED. So brisvox has no temperature knob for Claude 5.x.
  • Assistant prefill (a final assistant message) is rejected even with thinking off: the messages array must end with a user turn.
  • thinking: {"type":"enabled","budget_tokens":N} is rejected; between_tools is rejected on Haiku (Sonnet 5.5 only).
  • Priority Tier is not supported on Haiku 5.5 / Sonnet 5.5 / Opus 5.5 (service-tiers page; the tier is also closed to new purchases: "Priority Tier capacity commitments are no longer available for purchase"). service_tier request values: auto (default), standard_only.
  • Haiku 5.5 uses the Claude 4.7+ tokenizer: "approximately 30% more tokens than on Claude Haiku 4.5".
  • Safety classifiers can decline a request: stop_reason: "refusal" with stop_details; Haiku 5.5 has no server-side fallback ("Sending the same request ... again usually returns another refusal").

Request (fields from the Messages create reference; names exact)

Source: https://platform.claude.com/docs/en/api/messages/create (page truncated after top_p/response, request parts read in two chunks).

  • model (string), max_tokens (number >= 0; 0 "populates the prompt cache without generating a response"), messages (array, max 100,000), system (string or array of text blocks), stream (bool), stop_sequences, tools, tool_choice, thinking, output_config (effort: low|medium|high|xhigh|max; format: {"type":"json_schema","schema":{...}}), metadata ({"user_id": string|null}, <= 512 chars, opaque id, not PII), service_tier, cache_control (top-level automatic caching), inference_geo, container, diagnostics ({"previous_message_id": string|null}, makes the response carry diagnostics.cache_miss_reason), speed (not found on the fetched part of the page; "fast mode" research preview is Opus only (Opus 5.5, Opus 5, Opus 4.8) per the rate-limits page: UNVERIFIED for Haiku).
  • messages[].role: user, assistant, or system (system only mid-conversation, see below; the schema lists system but the field description still says there is "no system role", an inconsistency in the reference). content: string (one text block) or array of blocks: text (text minLength 1, optional cache_control), image, document, thinking (signature, thinking), redacted_thinking (data), tool_use (id, name, input), tool_result (tool_use_id, content, is_error), ... An empty text block is invalid (minLength 1) and "empty text blocks" are not cacheable.
  • Whether consecutive same-role messages are merged or rejected, and whether the first message must be user: the pages fetched do not say: UNVERIFIED. Build strictly alternating turns: consecutive brisvox Tool messages must go into ONE user message (required anyway, see tool results), and a user text that follows tool results goes into the same user message AFTER the tool_result blocks.
  • Minimal streaming request (doc, streaming page; model id there is the doc's example):
curl https://api.anthropic.com/v1/messages \
  -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -d '{
    "model": "claude-opus-5-5",
    "messages": [{"role": "user", "content": "Hello"}],
    "max_tokens": 256,
    "stream": true
  }'
  • Latency example (doc, reduce-latency page): "model": "claude-haiku-5-5", "max_tokens": 1024, "output_config": {"effort": "low"} ("low, the cheapest and fastest level"). Thinking tokens count toward max_tokens "even when the thinking content isn't returned"; Haiku 5.5 guide: "A max_tokens value sized for Claude Haiku 4.5 requests that ran without thinking can cut the reply off, so leave room for thinking." With thinking disabled this does not apply.
  • Voice request sketch for Haiku 5.5 (illustrative, assembled from the rules above, not a doc block): {"model":"claude-haiku-5-5","max_tokens":<n>,"stream":true,"system":[{"type":"text", "text":"<persona>"}],"cache_control":{"type":"ephemeral"},"thinking":{"type":"disabled"}, "output_config":{"effort":"medium"},"tools":[...],"messages":[...]}.

Thinking / effort settings for voice (Haiku 5.5, Sonnet 5.5)

Sources: https://platform.claude.com/docs/en/build-with-claude/thinking , .../build-with-claude/effort , .../models/haiku-5-5/migration-guide , .../build-with-claude/prompt-engineering/prompting-claude-haiku-5-5 .

  • Per-model thinking table (doc): Haiku 5.5: no thinking field = adaptive thinking; "adaptive" ok; "enabled" + budget_tokens 400; "between_tools" 400; "disabled" = "Thinking off at high effort or below" (400 at xhigh/max). Sonnet 5.5: no field = adaptive; "disabled" 400 (message: To turn thinking off on this model, send "thinking": {"type": "between_tools"} instead of {"type": "disabled"}); "between_tools" = "Up-front thinking off at high effort or below" (400 at xhigh/max). Opus 5.5: "disabled" always 400 (thinking always on). Thinking is ON by default on all three, so a voice adapter must send thinking explicitly or accept a thinking pause before the first text.
  • Effort levels low, medium, high, xhigh, max; defaults: Haiku 5.5 medium, Sonnet 5.5 high, Opus 5.5 medium. Setting the default explicitly equals omitting it. Effort affects all tokens including tool calls ("Lower effort also means fewer and terser tool calls"). Haiku guide: low "Use it for chat, short tool tasks, and simple, high-volume requests. In long agent prompts, the model is more likely to skip a search, stop early, or skip a check at this level." Sonnet 5.5 guide: "For chat and other latency-sensitive work, start with medium or low."
  • display: "omitted" is the default on Haiku 5.5 and Sonnet 5.5 (empty thinking text, signature still returned) per the thinking page and the Haiku migration guide; NOTE the Messages-create reference says display "Defaults to summarized", which contradicts them: trust the thinking page, confirm in a live run. "Set display: "omitted" when your application doesn't surface thinking content ... faster time-to-first-text-token when streaming: the server skips streaming thinking tokens entirely and delivers only the signature". You are billed for the full thinking tokens either way.
  • Changing the thinking mode or the top-level effort between requests invalidates the message cache ("Treat any thinking or top-level effort change as starting the cache over"). Per-message effort (system message with output_config.effort, beta header mid-conversation-output-config-2026-07-01) keeps the cache but is rejected with 400 on Haiku 5.5 when thinking is disabled and effort differs from the level in effect.
  • Haiku 5.5 gotcha for TTS: "Claude Haiku 5.5 sometimes writes reasoning-like text in the reply that users see. This happens more often with thinking off or at low effort. If you see this behavior, switch to adaptive thinking and medium effort." A voice agent would speak that text. The thinking-off/low-effort voice setting and this warning pull in opposite directions: decide empirically in a live run and keep both thinking and effort configuration values.
  • Haiku 5.5 with thinking off and output_config.format JSON may skip a needed tool call (prompting guide); not relevant unless structured output is combined with tools.
  • Haiku 5.5 forced tool use works but "the response starts with the tool call and has no thinking block"; also Anthropic says tool_choice any/tool prefill means "the models will not emit a natural language response or explanation before tool_use content blocks".

SSE framing and event flow

Source: https://platform.claude.com/docs/en/build-with-claude/streaming (page title "Streaming messages"; docs/en/api/messages-streaming redirects here).

  • Standard SSE. "Each event uses an SSE event name (for example, event: message_stop), and includes the matching event type in its data." (The type field in the JSON data is authoritative for parsing; the event: line repeats it.) Events are separated by blank lines. There is no data: [DONE] sentinel in this version (versioning page: "Removed unnecessary data: [DONE] event"); the adapter should still ignore a [DONE] payload defensively.
  • Flow (doc): 1. message_start (a Message with empty content). 2. A series of content blocks, each with content_block_start, one or more content_block_delta, content_block_stop; each block has an index "that corresponds to its index in the final Message content array". 3. "One or more message_delta events, indicating top-level changes to the final Message object." 4. A final message_stop. ping events "dispersed throughout the response". Whether blocks are ever interleaved (deltas of block N+1 before content_block_stop of N): the flow text implies sequential blocks, not stated explicitly: UNVERIFIED; key accumulation by index.
  • A server-side-fallback fallback content block (start+stop pair, no deltas) appears only for models with server-side fallback (not Haiku 5.5); ignore unknown block types.
typeFieldsUse
message_startmessage {id, type:"message", role:"assistant", content:[], model, stop_reason:null, stop_sequence:null, usage {input_tokens, output_tokens, optionally cache_creation_input_tokens, cache_read_input_tokens}}record id for logs; usage baseline: input tokens and cache fields are here
content_block_startindex, content_block: {"type":"text","text":""} / {"type":"tool_use","id","name","input":{}} / {"type":"thinking","thinking":"","signature":""} / redacted_thinking / server_tool_use / ...open a block; for tool_use take id + name; input:{} is a placeholder
content_block_deltaindex, deltasee delta table
content_block_stopindexblock complete: parse a tool_use input here
message_deltadelta {stop_reason, stop_sequence, (stop_details, container)}, usage (cumulative: output_tokens; may also carry input_tokens, cache_creation_input_tokens, cache_read_input_tokens, server_tool_use, output_tokens_details)stop_reason arrives here, not in message_start; final usage
message_stopnoneterminal success
pingnoneignore (keepalive-like)
errorerror {type, message}terminal failure after HTTP 200

Delta types in content_block_delta.delta (doc):

delta.typeFieldMeaning
text_deltatexttext for a text block -> LlmEvent::TextDelta
input_json_deltapartial_jsonpartial JSON string for a tool_use input; "the final tool_use.input is always an object"
thinking_deltathinkingthinking text (empty string with display:"omitted")
signature_deltasignatureopaque signature, "sent just before the content_block_stop event"
citations_deltacitationcitations (only with citation-enabled documents; ignore)
Usage is cumulative: "The token counts shown in the usage field of the message_delta event are
cumulative." (message_delta.usage.input_tokens and the cache fields are "number or null"; a
null/absent field means "not updated": keep the message_start value.)

Examples (doc, copied from the streaming page):

event: message_start
data: {"type": "message_start", "message": {"id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", "type": "message", "role": "assistant", "content": [], "model": "claude-opus-5-5", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 25, "output_tokens": 1}}}

event: content_block_start
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}

event: ping
data: {"type": "ping"}

event: content_block_delta
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hello"}}

event: content_block_delta
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "!"}}

event: content_block_stop
data: {"type": "content_block_stop", "index": 0}

event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence":null}, "usage": {"output_tokens": 15}}

event: message_stop
data: {"type": "message_stop"}

Tool-use stream (doc, same page; a text preamble block 0, then the tool block 1):

event: content_block_start
data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01T1x1fJ34qAmk2tNTrN7Up6","name":"get_weather","input":{}}}

event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""}}

event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"location\":"}}

event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" \"San"}}

event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" CA\"}"}}

event: content_block_stop
data: {"type":"content_block_stop","index":1}

event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":89}}

event: message_stop
data: {"type":"message_stop"}

(The first input_json_delta has an empty partial_json. The doc's message_start for this example carries "usage":{"input_tokens":472,"output_tokens":2} and "content":[].) Thinking stream (doc, display:"summarized"):

event: content_block_start
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "thinking", "thinking": "", "signature": ""}}

event: content_block_delta
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "I need to find the GCD of 1071 and 462 using the Euclidean algorithm.\n\n1071 = 2 × 462 + 147"}}

event: content_block_delta
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "signature_delta", "signature": "EqQBCgIYAhIM1gbcDa9GJwZA2b3hGgxBdjrkzLoky3dl1pkiMOYds..."}}

event: content_block_stop
data: {"type": "content_block_stop", "index": 0}

event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": null}}

With display:"omitted": "The thinking block opens, receives a thinking_delta with an empty thinking string and then a single signature_delta, and closes." Usage with caching (doc, web-search example message_start): "usage":{"input_tokens":2679, "cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":3}; its message_delta carried "usage":{"input_tokens":10682,"cache_creation_input_tokens":0, "cache_read_input_tokens":0,"output_tokens":510,"server_tool_use":{"web_search_requests":1}}. Error event (doc):

event: error
data: {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}

"normally correspond[s] to an HTTP 529 in a non-streaming context". After the HTTP 200 the usual status-code handling does not apply: the adapter must treat an error event as terminal.

Tool input granularity (doc): "Current models only support emitting one complete key and value property from input at a time. As such, when using tools, there may be delays between streaming events while the model is working." So tool-argument fragments arrive in lumps with gaps; do not use a short idle timeout during a tool call (see timeouts). Per-tool opt-in "eager_input_streaming": true (tool definition field; no beta header needed, all models) streams fragments "without server-side buffering or JSON validation" ("typically longer, with fewer mid-word breaks"); invalid or truncated JSON possible; wrap unparseable input as a tool result {"type":"tool_result","tool_use_id":"...","is_error":true,"content":"{\"INVALID_JSON\": \"<the unparseable input you received>\"}"}. (The legacy beta header fine-grained-tool-streaming-2025-05-14 still turns it on for tools that leave the field unset.) Source: https://platform.claude.com/docs/en/agents-and-tools/tool-use/fine-grained-tool-streaming .

Stop reasons (handling-stop-reasons page, messages create reference)

end_turn, max_tokens, stop_sequence, tool_use, pause_turn (server-tool loop limit; not used by client tools), refusal, model_context_window_exceeded. In streaming, stop_reason is null in message_start, set in message_delta, absent elsewhere. stop_details is null except for refusal: {"type":"refusal","category":"cyber"|"bio"|"frontier_llm"| "reasoning_extraction"|"general_harms"|null,"explanation": string|null}. Empty response gotcha (doc): Claude "sometimes returns an empty response (exactly 2–3 tokens, no content) with stop_reason: "end_turn". This typically occurs when Claude interprets that the assistant turn is complete, particularly after tool results", caused by "adding text blocks immediately after tool results" or "sending Claude's completed response back without adding anything"; fix: send tool results with no extra text in the same user message (if still empty, add a continuation prompt in a new user message rather than retrying the empty response). The adapter must handle an end_turn with zero text and zero tool calls (voice: dead air).

Mapping to brisvox LlmEvent (crates/brisvox-providers/src/llm.rs) — design, not implemented

  • TextDelta <- content_block_delta with delta.type == "text_delta" (delta.text).
  • ToolCallDelta { index, id, name, arguments }: index = dense counter over tool_use blocks (the block index also counts text/thinking blocks, so keep a map block index -> dense index); id = content_block.id (toolu_..., pattern ^[a-zA-Z0-9_-]+$), name = content_block.name, both emitted at content_block_start with empty arguments; every input_json_delta.partial_json becomes a pure fragment (the empty first fragment may be skipped). The tool result is keyed by this id as tool_use_id.
  • Done { finish, usage, provider_data }: emit after message_stop (not at message_delta), using the stop_reason from message_delta. end_turn/stop_sequence -> Stop; tool_use -> ToolCalls; max_tokens -> Length; model_context_window_exceeded -> Length (truncated; no brisvox variant, gap); refusal -> no variant (gap: map to Stop + log, or extend the enum); pause_turn -> not expected. A stream that ends without message_stop/error is cut off (Disconnected).
  • Usage: Anthropic input_tokens excludes cached tokens. Docs: "input_tokens: tokens after the last cache breakpoint, not all input tokens. Total input = cache_read_input_tokens + cache_creation_input_tokens + input_tokens." So Usage.input_tokens = sum of the three, Usage.cached_input_tokens = cache_read_input_tokens (usage = message_start.usage overridden by non-null message_delta.usage fields; output_tokens is cumulative). Tokens that arrive only if the stream completes; a cut stream loses message_delta usage.
  • provider_data (opaque JSON text, echoed back on the assistant Message): the JSON array of the response's assistant content blocks that must be replayed verbatim: every thinking block (with thinking text and signature assembled from deltas), every redacted_thinking block (data), and the tool_use blocks, in the order produced (plus a placeholder where the text block sat, as the OpenAI adapter does). None when the response had none of these (text only). Streaming shape of a redacted_thinking block (start event with data in content_block, any delta?): the schema lists RedactedThinkingBlock as a possible content_block of content_block_start, but no streaming example was found: UNVERIFIED.

Tool definitions

Source: https://platform.claude.com/docs/en/agents-and-tools/tool-use/define-tools , .../tool-use/strict-tool-use , .../tool-use/parallel-tool-use . Tool (doc):

{
  "name": "get_weather",
  "description": "Get the current weather in a given location",
  "input_schema": {
    "type": "object",
    "properties": {
      "location": {
        "type": "string",
        "description": "The city and state, e.g. San Francisco, CA"
      },
      "unit": {
        "type": "string",
        "enum": ["celsius", "fahrenheit"],
        "description": "The unit of temperature, either 'celsius' or 'fahrenheit'"
      }
    },
    "required": ["location"]
  }
}
  • Fields: name (regex ^[a-zA-Z0-9_-]{1,128}$; max 128, vs 64 for OpenAI), description ("by far the most important factor in tool performance"; aim for 3-4 sentences), input_schema (JSON Schema, must be "type": "object"), optional input_examples (array of example input objects, must validate against the schema else 400; ~20-50 tokens each), strict, eager_input_streaming, cache_control, defer_loading, allowed_callers. Mapping: ToolSpec.name -> name, .description -> description, .parameters_schema (JSON text) -> input_schema (parse; root must be an object schema, else Protocol error before any I/O).
  • strict: true = grammar-constrained sampling ("Tool input strictly follows the input_schema"). Schema rules (structured-outputs page): additionalProperties must be false on objects; required supported; anyOf and allOf supported "with limitations" (allOf with $ref not); enum, const, local $ref/$def/definitions, default and string formats (date-time, time, date, duration, email, hostname, uri, ipv4, ipv6, uuid) supported; NOT supported: recursive schemas, minimum/maximum/multipleOf, minLength/maxLength, minItems other than 0/1, external $ref, regex lookahead/lookbehind/backreferences/\b/\B; oneOf is mentioned in neither list (UNVERIFIED); unsupported feature -> 400. Limits per request (combined across strict schemas): 20 strict tools, 24 optional parameters, 16 union-typed parameters (anyOf or type arrays); too complex -> 400 "Schema is too complex for compilation." (180 s compile timeout). First use of a schema adds latency ("additional latency while the grammar compiles"), compiled grammars are cached 24 h from last use; the cache is invalidated when the schema structure or the tool set changes (name/description changes do not). No number for that latency is published: UNVERIFIED. For a voice agent, either pre-warm (warm()) or keep strict off by default and validate arguments yourself (as with OpenAI). Strict mode is not needed for correctness if the caller validates .
  • tool_choice (doc): {"type":"auto"} (default when tools are given; Claude may answer in text or call tools), {"type":"any"} (must call some tool), {"type":"tool","name":"..."} (forced), {"type":"none"}. Each of auto/any/tool takes disable_parallel_tool_use (bool, default false, inside the tool_choice object, not top level): with auto, at most one tool per response; with any/tool, exactly one. any/tool return 400 on Opus 5.5, Sonnet 5.5, Fable 5.1, Mythos 5.1 ("tool_choice: type "tool" and "any" are not supported for this model."); auto and none are fine; Haiku 5.5 supports forced use. Manual extended thinking (enabled) also forbids forced tool use. Changing tool_choice or disable_parallel_tool_use invalidates the message cache (tools and system stay cached).
  • Parallel tool use (doc): by default Claude "may call multiple tools in a single response": several tool_use blocks in one assistant turn with stop_reason: "tool_use"; "The API doesn't prescribe an execution order". Brisvox invariants (SideEffect tools never twice, never speculative) mean the caller decides; the adapter just reports all calls.
  • Model text before tool calls (doc): "Claude often comments on what it's doing ... before calling tools" (a text block, then tool_use): in a voice agent this preamble is spoken before the tool runs. Lower effort => "Proceed directly to action without preamble".

Returning tool results

Source: https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls . Assistant tool turn (doc) and the user reply (doc):

{
  "id": "msg_01Aq9w938a90dw8q",
  "model": "claude-opus-5-5",
  "stop_reason": "tool_use",
  "role": "assistant",
  "content": [
    {"type": "text", "text": "I'll check the current weather in San Francisco for you."},
    {"type": "tool_use", "id": "toolu_01A09q90qw90lq917835lq9", "name": "get_weather", "input": { "location": "San Francisco, CA", "unit": "celsius" }}
  ]
}
{
  "role": "user",
  "content": [
    {"type": "tool_result", "tool_use_id": "toolu_01A09q90qw90lq917835lq9", "content": "15 degrees"}
  ]
}
  • tool_result fields: tool_use_id (the tool_use.id), content (optional: a string, a list of text/image/document/search_result blocks), is_error (optional bool). Error example (doc): {"type":"tool_result","tool_use_id":"toolu_01A09q90qw90lq917835lq9","content":"ConnectionError: the weather service API is not available (HTTP 500)","is_error":true}.
  • Rules (doc): "Tool result blocks must immediately follow their corresponding tool use blocks in the message history" (no message between the assistant tool_use message and the user tool_result message); "the tool_result blocks must come FIRST in the content array. Any text must come AFTER all tool results" (text before a tool_result is a 400). Error text to expect when violated: "tool_use ids were found without tool_result blocks immediately after". Parallel calls: "return one tool_result for each tool_use block, all together in the next user message"; for a call you chose not to run still return {"type":"tool_result","tool_use_id":"toolu_02","is_error":true, "content":"Not executed: the preceding write_file call failed."}. => Brisvox dangling calls (cancelled by barge-in) must be answered with a synthetic is_error result before the next request (or the whole assistant turn dropped with its results), exactly as with OpenAI; the adapter should validate pairing before any I/O and raise Protocol naming the call id.
  • Mapping brisvox history -> Anthropic messages: leading System messages -> top-level system (string or [{"type":"text","text":...}]); User -> {"role":"user","content":<text>}; Assistant -> {"role":"assistant","content":[<provider_data blocks in order>, {"type":"text", "text":<played text>} if non-empty, tool_use blocks for tool_calls]} with input parsed from the argument JSON text; a run of Tool messages -> ONE {"role":"user","content":[tool_result...]}; user text after tool results goes after them in the same message. A System message later in the history -> {"role":"system","content":...}: allowed on Haiku 5.5 and Sonnet 5.5, no beta header, "cannot be the first entry", "must immediately follow a user turn (or an assistant turn ending in a server tool result)" and be last or followed by an assistant turn (may follow a user message that carries tool_result blocks, but NOT between an assistant tool_use and its tool_result; other positions -> 400); consecutive system messages are merged; appending one "does not change the prefix hash and the cache still hits" but editing/removing an earlier one invalidates cache and (on Haiku 5.5/Sonnet 5.5) later thinking blocks. Source: https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages .
  • Prompt-injection note from the Haiku 5.5 guide: user text typed mid-task must NOT go inside a tool_result block ("the model can then treat it as untrusted text and ignore it"); append it as a text block after the last tool_result of the same user message.

Thinking blocks and signatures (brisvox provider_data)

Sources: https://platform.claude.com/docs/en/build-with-claude/thinking , .../build-with-claude/preserved-thinking , https://platform.claude.com/docs/en/api/errors .

  • Shapes (doc):
{"type": "thinking", "thinking": "Let me break this down. ...", "signature": "WaUjzkypQ2mUEVM36O2Txu...."}
{"type": "thinking", "thinking": "", "signature": "EosnCkYICxIMMb3LzNrMu..."}
{"type": "redacted_thinking", "data": "..."}

The middle one is display: "omitted" (default on Haiku 5.5 / Sonnet 5.5). The signature is "an encrypted copy of the full reasoning", opaque ("don't interpret or parse it"), "significantly longer in Claude 4 and later models", valid across platforms (Claude API, Bedrock, Google Cloud). redacted_thinking.data is opaque, no readable text.

  • Echo requirement: "when you return tool results, you must pass the thinking blocks from the assistant message back to the API, complete and unmodified." "Required: within a tool-use turn, pass thinking blocks back. Recommended: across turns, pass everything back. Allowed: outside tool use, omit prior turns' thinking." "Within the latest assistant message, the sequence of consecutive thinking blocks must match what the model generated in the original request: you can't rearrange, edit, or partially drop them. This includes redacted_thinking blocks." Filtering on block.type == "thinking" alone "silently drops redacted_thinking blocks and breaks the multi-turn protocol". Violations -> 400 invalid_request_error, message starts with the block position (e.g. messages.1.content.0) and contains: "thinking or redacted_thinking blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response." Text put in the empty thinking field of an omitted block is ignored.
  • Preservation by model: Haiku 5.5 (and Opus 4.5+, Sonnet 4.6+) "Keep all prior turns" in context (and bill them as input tokens when read from cache); older Haiku models keep the last turn only.
  • Haiku 5.5 / Sonnet 5.5 / Opus 5.5 / Fable 5.1 prefix binding ("preserved thinking"): a thinking block "stays valid only while everything you sent before it is unchanged": top-level system, the set of tools, and every earlier message. Editing/reordering/deleting earlier messages, changing system, adding/removing/renaming/editing a tool, or clearing an earlier tool_result makes later thinking blocks invalid; appending messages, changing effort/max_tokens/tool_choice/ metadata, and adding/moving cache_control markers do not. Result is 400 invalid_request_error (messages.{i}.content.{j}: Invalid signatureinthinkingblock. The block is bound to a different conversation. Remove the block, or setthinking.block_binding.prefix_mismatch_behavior to "drop_block".) by default for accounts created on or after 2026-08-31 00:00 UTC; older accounts get the error only when they set thinking.block_binding.prefix_mismatch_behavior. Beta header thinking-binding-controls-2026-08-01 adds block_binding.prefix_mismatch_behavior ("error"|"drop_block") and an input_transformations array on responses. A tampered or undecryptable signature is always 400. Removing thinking blocks from the start, the end or all of them is valid (the model just loses that reasoning); removing one from the middle is not.
  • Account binding: thinking blocks from Haiku 5.5 / Sonnet 5.5 "work only in the account that produced them, or in an account linked to it"; another account's request silently drops them (no error). Relevant for multi-tenant stores that replay history through different API keys: always replay a conversation through the key (account) that produced it.
  • Model switch mid-conversation: the API silently drops blocks the target model cannot read; e.g. Sonnet 5.5 does not read Opus 5.5 / Opus 5 / Fable blocks, but reads Haiku 5.5's on the Claude API and Google Cloud, and Opus 5.5 reads Sonnet 5.5's and Haiku 5.5's.
  • Implication for the interruption invariant ("after an interruption the LLM context contains ONLY what was actually played"): truncating the played text of an interrupted assistant turn edits content that comes AFTER that turn's thinking blocks, so those blocks stay valid by the prefix rule, but interleaved thinking that sits after the truncated text, or a tool_use that was never played, would be invalid or must be dropped with it. Whether a truncated assistant text block in a completed turn invalidates thinking produced in LATER turns of the same conversation is covered by the same rule (edit of an earlier message), but the requests after an interruption produce new thinking blocks bound to the edited prefix, which is valid. These readings of the rules are inferences: UNVERIFIED until a live run replays a truncated turn. The adapter's rule (review fix): only the text that was not played is removed from the replayed turn; thinking in front of it stays pending and goes out directly in front of the next kept block, so a turn whose preamble text was not played but whose tool_use survives is still echoed as [thinking..., tool_use]. Thinking in front of a call that is dropped, or with nothing kept after it, is dropped with it. A live run must confirm that the API accepts [thinking, tool_use] after the text between them was removed. The simplest safe choice for voice is thinking: {"type":"disabled"} on Haiku 5.5: no thinking blocks exist, nothing to echo, no binding errors, and no change in thinking mode between turns.
  • Mid-turn thinking mode changes: "If you toggle thinking mid-turn ... the API doesn't error. Instead, it silently disables thinking for that request" and may strip blocks; toggle between turns only.
  • Fable 5.1, Mythos 5.1, Opus 5.5, Sonnet 5.5 and Fable 5 may write short "progress update" text as thinking blocks before tool calls (thinking page, "Progress updates between tool calls"; Sonnet 5.5 between_tools returns their summary text). The page does NOT list Haiku 5.5 among the models that write them. Treat any such block as a thinking block: never speak it; echo it unchanged.

Prompt caching

Sources: https://platform.claude.com/docs/en/build-with-claude/prompt-caching (first 100,000 of 160,149 characters read; the cookbook tail was not read), .../agents-and-tools/tool-use/ tool-use-with-prompt-caching , https://platform.claude.com/docs/en/api/rate-limits .

  • Two mechanisms (doc): automatic caching = one top-level cache_control field; "The system places the breakpoint on the last cacheable block and moves it forward as the conversation grows." Explicit breakpoints = cache_control on individual blocks. Up to 4 breakpoints; automatic uses one of the 4; with 4 explicit ones already present automatic caching returns 400. Only type: {"type":"ephemeral"}. Doc request (automatic, copied):
{
  "model": "claude-opus-5-5",
  "max_tokens": 1024,
  "cache_control": {"type": "ephemeral"},
  "system": "You are an AI assistant tasked with analyzing literary works. Your goal is to provide insightful commentary on themes, characters, and writing style.",
  "messages": [
    {"role": "user", "content": "Analyze the major themes in Pride and Prejudice."}
  ]
}

Explicit on a system block (doc): "system":[{"type":"text","text":"You are a helpful assistant.", "cache_control":{"type":"ephemeral"}}]. On tools: cache_control on the LAST tool caches the whole tool-definitions prefix.

  • TTL: default 5 minutes ("refreshed for no additional cost each time the cached content is used"; "measured from the start of the request that writes or reads the cache entry, not from the end of its response"); 1 hour via "cache_control": { "type": "ephemeral", "ttl": "1h" } (2x base input price to write). Mixing: 1-hour entries must appear before 5-minute entries. For a phone call 5 min is enough per turn; a long-held, repeatedly reused system prompt across calls benefits from 1 h (UNVERIFIED cost trade off: depends on call volume).
  • Minimum cacheable length (tokens): 512 for Claude Haiku 5.5, Sonnet 5.5, Opus 5.5, Fable 5.1, Fable 5, Opus 5, Mythos 5/5.1; 1,024 for Sonnet 5, Sonnet 4.6/4.5, Opus 4.8/4.1/4; 2,048 for Opus 4.7, Haiku 3.5, Mythos Preview; 4,096 for Opus 4.6/4.5 and Haiku 4.5. Shorter prompts are processed uncached without an error; check that cache_creation_input_tokens and cache_read_input_tokens are not both 0.
  • What is cached: "the entire prompt: tools, system, and messages (in that order), up to and including the block designated with cache_control". Cacheable: tool definitions, system blocks, user/assistant text, images/documents (user turns), tool_use and tool_result blocks. Not cacheable directly: thinking blocks (cached as part of prior assistant turns; they count as input tokens when read), empty text blocks, citations sub-content.
  • Lookback: writes only at a breakpoint; on read the system checks up to 20 block positions backward from the breakpoint (consecutive tool_use blocks count as one position, likewise consecutive tool_result). If a growing conversation moves the breakpoint 20+ blocks past the last write, add a second breakpoint (automatic caching moves forward by itself; the 20-block gap applies to explicit placement).
  • Invalidation (doc table; hierarchy tools -> system -> messages): tool definitions change: all invalid; tool_choice change: messages invalid; images added/removed: messages invalid; thinking parameters and output_config.effort change: messages invalid (tools/system model-specific); web search / citations toggles: system + messages invalid. Setting a parameter to its default is equivalent to omitting it. "Hits require 100% identical prefix content".
  • Concurrency: "A cache entry only becomes available after the first response begins. If you need cache hits for parallel requests, wait for the first response before sending subsequent requests."
  • Scope: isolated per organisation and, on the Claude API, per workspace. Output: "Prompt caching has no effect on output token generation. The response you receive is identical to what you would get if prompt caching were not used."
  • Usage fields (in message_start.usage when streaming; message_delta.usage repeats the cumulative values): input_tokens (after the last breakpoint), cache_creation_input_tokens, cache_read_input_tokens, cache_creation {ephemeral_5m_input_tokens, ephemeral_1h_input_tokens} ("cache_creation_input_tokens equals the sum of the values in the cache_creation object"), output_tokens. Example (doc):
{
  "usage": {
    "input_tokens": 2048,
    "cache_read_input_tokens": 1800,
    "cache_creation_input_tokens": 248,
    "output_tokens": 503,
    "cache_creation": {"ephemeral_5m_input_tokens": 148, "ephemeral_1h_input_tokens": 100}
  }
}
  • Pricing multipliers (doc): 5 m write 1.25x base input, 1 h write 2x, read 0.1x (0.05x on Opus 5.5 / Sonnet 5.5, 0.025x on Fable/Mythos 5.1); Haiku 5.5 read $0.01/MTok (<= 100K prompt). Cache hits do not count toward rate limits for models other than Haiku 3.5 (see rate limits).
  • Cache diagnostics (request diagnostics.previous_message_id -> response diagnostics.cache_miss_reason with types model_changed, system_changed, tools_changed, messages_changed, previous_message_not_found, unavailable, each of the first four with cache_missed_input_tokens): "null means diagnosis is still pending — the response was serialized before the background comparison completed" (and diagnostics itself is null when none was requested or no divergence was detected). Whether this field arrives in a streamed message_start/message_delta: UNVERIFIED (the stream schema's message_start.message is a full Message, whose schema has the diagnostics field, but the message_delta schema lists no diagnostics; the field is likely null at message_start time). A cheap debugging aid for the live test.
  • Warm-up candidate: max_tokens: 0 "populates the prompt cache without generating a response" (Messages create reference); prompt-caching page: "The API reads your prompt into the model and writes the cache at any cache_control breakpoint, then returns immediately without generating any output." Billed as a cache write; whether it is allowed with stream: true, its latency and response shape (assume a normal non-streaming Message with empty content): UNVERIFIED. Combine with a HEAD/TLS warm-up for warm().
  • Prompt-cache friendly ordering for brisvox (callers + adapter): (1) stable persona in the leading system message(s) (top-level system); (2) stable tool list and order, cache_control on the last tool or let automatic caching cover it; (3) history append-only (interruption truncation changes only the tail); (4) volatile data (time, caller name, retrieved snippets) at the end of the newest user message or in a late role:"system" message, never in the top-level system prompt; (5) never change model, thinking mode, effort, tool_choice or tool set mid-call; (6) keep serialising tools/system/messages byte-stably (the OpenAI adapter's rendered-bytes reuse applies too). Rate-limit upside: cache reads do not count toward ITPM.

Errors, status codes, rate limits

Sources: https://platform.claude.com/docs/en/api/errors , https://platform.claude.com/docs/en/api/rate-limits .

  • Error body (doc):
{
  "type": "error",
  "error": {
    "type": "not_found_error",
    "message": "The requested resource could not be found."
  },
  "request_id": "req_011CSHoEeqs5C35K2UUqR7Fy"
}

error.type may "grow over time". The same id is in the request-id response header (log it).

  • HTTP statuses (doc): 400 invalid_request_error (also other 4XX; also the spend limit you set yourself: "begins You have reached your specified API usage limits"), 401 authentication_error, 402 billing_error, 403 permission_error, 404 not_found_error, 409 conflict_error, 413 request_too_large, 429 rate_limit_error, 500 api_error, 504 timeout_error, 529 overloaded_error ("temporarily overloaded ... when the API experiences high traffic across all users"; "ramp up your traffic gradually" to avoid 429 acceleration limits). Mapping to brisvox: pre-stream non-2xx -> Err(Rejected { status }); mid-stream error event -> LlmEvent::Error: overloaded_error -> Rejected 529 (retryable), rate_limit_error -> 429, api_error -> 500, invalid_request_error -> 400, other -> Protocol naming the sanitised type (never copy message).
  • 429 variants: rate limit hit (has retry-after); tier spend cap (monthly cap Start $500 / Build $1,000 / Scale $200,000): rate_limit_error with error.details.error_code: "enforced_spend_limit_reached" and NO retry-after header, fails until 00:00 UTC on the 1st of the next month, retries are pointless; acceleration limits on sharp usage increases.
  • Retry (doc): the official SDKs retry transient failures (connection errors, rate limits, 5xx) "twice by default, honoring the retry-after header". The brisvox adapter does not retry (turn logic owns it) but should read and log retry-after and request-id.
  • Rate limits: RPM, ITPM (input tokens/min), OTPM (output tokens/min) per model class, org level, token-bucket (continuously replenished, can trigger on shorter bursts than the minute); limits are separate per model (some older models share a combined bucket). Cache-aware ITPM: input_tokens and cache_creation_input_tokens count, cache_read_input_tokens does NOT (except Haiku 3.5). ITPM is estimated at request start and adjusted; OTPM is counted as tokens are produced, max_tokens does not count. Standard limits (RPM / ITPM / OTPM), Haiku 5.5 (same numbers for Sonnet 5.5 and Opus 5.5): Start 1,000 / 2,000,000 / 400,000; Build 5,000 / 5,000,000 / 1,000,000; Scale 10,000 / 10,000,000 / 2,000,000; Custom tier by contact. New organisations "may start in the Evaluation tier, with limits below the standard limits". Concurrency limits separate from RPM: none stated (UNVERIFIED). Hundreds of concurrent calls: each turn is one request, so Build tier 5,000 RPM is not the bottleneck; ITPM is, unless caching works.
  • Rate-limit response headers (doc): retry-after (seconds), anthropic-ratelimit-requests-limit, -requests-remaining, -requests-reset (RFC 3339), anthropic-ratelimit-tokens-limit|remaining|reset (most restrictive limit), anthropic-ratelimit-input-tokens-limit|remaining|reset, anthropic-ratelimit-output-tokens-limit|remaining|reset (remaining values "rounded to the nearest thousand"), anthropic-priority-* (Priority Tier only). Other response headers: request-id, anthropic-organization-id, anthropic-workspace-id.
  • Not fetched / not found: sample header values, a streamed-response header example: UNVERIFIED.

Keepalive, timeouts, cancel

  • ping events are the only documented keepalive ("any number of ping events"); no interval is stated: UNVERIFIED. No SSE comment lines are documented.
  • No server-side idle/stream timeout is documented for streaming; the errors page advises streaming for long requests ("Some networks may drop idle connections after a variable period of time") and "setting a TCP socket keep-alive can reduce the impact of idle connection timeouts". SDKs validate that non-streaming requests are not expected to exceed 10 minutes and set TCP keep-alive. (A specific SDK threshold of max_tokens > 21,333 for requiring streaming is reported only by third-party pages, not by the official docs fetched: UNVERIFIED.) Brisvox must set its own timeouts (connect <= ~2 s, headers, first output, inter-event). Because tool-input fragments can pause (one key/value at a time), pre-text thinking is on by default, and pings should not count as output, use the OpenAI adapter's pattern: first-output deadline separate from the idle deadline, and a generous idle limit while a tool block is open.
  • Cancel: no cancel endpoint for a streaming Messages request is documented; dropping the HTTP connection (brisvox: drop LlmStream) is the plan. Billing of tokens generated before the drop, and whether partial generation stops promptly: UNVERIFIED.
  • Docs' own interrupted-stream recovery (not used by brisvox): for Claude 4.6+ models, send a new user message "Your previous response was interrupted and ended with [previous_response]. Continue from where you left off." because prefill is unsupported; "Tool use and extended thinking blocks cannot be partially recovered."

Turkish support

Not stated for Turkish. The multilingual page (https://platform.claude.com/docs/en/build-with-claude/multilingual-support) says Claude is "capable in many languages beyond those benchmarked" and "Test with any languages relevant to your specific use cases"; its benchmark table (zero-shot CoT, MMLU translated into 14 languages, % of English) covers only Claude Sonnet 4.5 and Haiku 4.5 and does NOT include Turkish (rows: Spanish, Portuguese (Brazil), Italian, French, Indonesian, German, Arabic, Chinese (Simplified), Korean, Japanese, Hindi, Bengali, Swahili, Yoruba). Nothing on the Claude 5.5 generation. So Turkish quality and Haiku 5.5 vs Sonnet 5.5 differences: UNVERIFIED, measure with the Turkish conversation fixtures. Doc advice: state the response language explicitly in the system prompt ("Always respond in French, regardless of the language the user writes in." pattern), submit text in native script (Turkish diacritics, not transliteration), and "prompt Claude to use idiomatic speech as if it were a native speaker" for fluency. Token inflation: the 4.7+ tokenizer yields ~30% more tokens than Haiku 4.5's for the same text (1M tokens = "roughly 555k words or 2.5M Unicode characters"); Turkish-specific token counts are UNVERIFIED (count with POST /v1/messages/count_tokens).

Gotchas / things the implementer must handle

  • Anthropic usage.input_tokens EXCLUDES cache reads and writes: sum the three fields for brisvox Usage.input_tokens; cached_input_tokens = cache_read_input_tokens.
  • stop_reason is only in message_delta; usage there is cumulative; usage in message_start holds the input side. Emit Done after message_stop.
  • tool_use blocks: input: {} at start is a placeholder; fragments start with an empty partial_json; the object is complete at content_block_stop; arguments may be partial/invalid with eager_input_streaming or at max_tokens; validate before dispatch.
  • Tool results: tool_result blocks first in ONE user message, no text before them; pair every tool_use id; synthesise is_error results for cancelled calls.
  • Thinking is on by default on Claude 5.5 models: send thinking explicitly; Haiku 5.5 disabled works only at effort <= high; Sonnet 5.5 uses between_tools; reasoning-like text may leak into speech with thinking off or low effort on Haiku 5.5.
  • Thinking blocks (and redacted_thinking) must be echoed unmodified within a tool turn, and the conversation prefix must stay append-only (400 on accounts created on/after 2026-08-31).
  • No temperature/top_p/top_k on 5.x; no prefill (final message must be a user turn); forced tool_choice is rejected on Sonnet 5.5 and Opus 5.5.
  • tool_choice or disable_parallel_tool_use changes invalidate the message cache; changing thinking or effort invalidates it too.
  • Empty end_turn response after tool results is possible (dead air); handle it.
  • 529 overloaded_error can arrive as an in-stream error event after HTTP 200.
  • anthropic-version must be sent; unknown event/delta/block types must be ignored.
  • Tool names allow 128 chars (OpenAI 64); the schema root must be "type":"object".
  • Model ids and documented behaviours are from a generation newer than the model's training data.

UNVERIFIED list

  • Absolute latency numbers (TTFT, tokens/s) for Haiku 5.5 / Sonnet 5.5; effect of thinking:{"type":"disabled"}, effort:"low" and display:"omitted" on TTFT in voice turns.
  • Inter-ping interval; whether SSE comments occur; server-side idle/stream timeouts; cost of dropped streams; cancel semantics; the SDK's max_tokens > 21,333 streaming threshold (third-party sources only).
  • Whether content blocks can interleave; streaming shape of redacted_thinking; whether diagnostics.cache_miss_reason appears in streams.
  • Rules for consecutive same-role messages and first-message role (pages fetched do not say).
  • Strict-tool grammar-compile latency; oneOf support in strict schemas.
  • max_tokens: 0 with stream: true; its latency; its billing.
  • Whether truncating the played text of an interrupted assistant turn invalidates thinking blocks under the prefix-binding rule (inference only); behaviour of thinking blocks when a tool_use of the turn is dropped.
  • Regional hostnames, inference_geo values, data residency; speed/fast mode for Haiku.
  • Whether concurrency is limited separately from RPM/ITPM/OTPM; Evaluation-tier numbers.
  • Turkish quality and tokenisation for the 5.5 models.
  • Default display for thinking (reference says summarized, thinking page says omitted on 5.5 models); exact accepted temperature/top_p values on Sonnet 5.5 and Opus 5.5.
  • Community Rust crates (none evaluated); prompt-caching page tail (characters 100,000-160,149) and the structured-outputs page tail (3,037 chars) were not read.
  • Full sample values for rate-limit headers.

Sources (all fetched 2026-10-08)

  • https://platform.claude.com/docs/en/build-with-claude/streaming (via redirect from https://docs.claude.com/en/api/messages-streaming)
  • https://platform.claude.com/docs/en/api/overview
  • https://platform.claude.com/docs/en/api/versioning
  • https://platform.claude.com/docs/en/api/errors
  • https://platform.claude.com/docs/en/api/rate-limits
  • https://platform.claude.com/docs/en/api/service-tiers
  • https://platform.claude.com/docs/en/api/beta-headers
  • https://platform.claude.com/docs/en/api/messages/create
  • https://platform.claude.com/docs/en/models/overview (the /about-claude/models/overview URL was fetched and returned this page)
  • https://platform.claude.com/docs/en/models/haiku-5-5/overview
  • https://platform.claude.com/docs/en/models/haiku-5-5/migration-guide
  • https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/prompting-claude-haiku-5-5
  • https://platform.claude.com/docs/en/build-with-claude/thinking
  • https://platform.claude.com/docs/en/build-with-claude/preserved-thinking
  • https://platform.claude.com/docs/en/build-with-claude/effort
  • https://platform.claude.com/docs/en/build-with-claude/prompt-caching
  • https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons
  • https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback
  • https://platform.claude.com/docs/en/build-with-claude/working-with-messages
  • https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages
  • https://platform.claude.com/docs/en/build-with-claude/multilingual-support
  • https://platform.claude.com/docs/en/build-with-claude/structured-outputs
  • https://platform.claude.com/docs/en/agents-and-tools/tool-use/define-tools
  • https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls
  • https://platform.claude.com/docs/en/agents-and-tools/tool-use/parallel-tool-use
  • https://platform.claude.com/docs/en/agents-and-tools/tool-use/fine-grained-tool-streaming
  • https://platform.claude.com/docs/en/agents-and-tools/tool-use/strict-tool-use
  • https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-use-with-prompt-caching
  • https://platform.claude.com/docs/en/test-and-evaluate/strengthen-guardrails/reduce-latency
  • https://platform.claude.com/docs/en/cli-sdks-libraries/overview
  • https://platform.claude.com/docs/en/cli-sdks-libraries/libraries/openai-sdk

Fact-checked 2026-10-08: ~130 claims checked, 10 corrected. Corrections: (1) progress-update thinking blocks are written by Fable 5.1, Mythos 5.1, Opus 5.5, Sonnet 5.5 and Fable 5, NOT Haiku 5.5 (was "Sonnet 5.5 and Haiku 5.5"); (2) empty-response gotcha is "exactly 2–3 tokens", quote and fix re-worded to the page text; (3) the prompt-caching "Caching does not change output tokens" quote did not exist, replaced by the real sentence ("Prompt caching has no effect on output token generation ..."); (4) OpenAI-compat rationale replaced by the documented limitations (no prompt caching, thinking not returned, strict ignored, system messages hoisted), the "tool_result semantics" reason was not in the docs; (5) the "SDK requires streaming when max_tokens > 21,333" claim is not in the official docs, moved to UNVERIFIED; (6) strict-tool schema rules: anyOf/allOf ARE supported (with limits), plus const, $ref, string formats (only oneOf remains unmentioned); (7) top_p: reference says ">= 0.99 accepted" vs Haiku guide "exactly 0.99" and exact Sonnet/Opus 5.5 values unverified; (8) display default conflict (create reference says summarized, thinking page says omitted) flagged; (9) diagnostics.cache_miss_reason streaming placement and the null semantics clarified; (10) max_tokens: 0 doc sentence added ("returns immediately without generating any output"), plus the API-enum description of Sonnet 5.5, TTL measurement quote, and the legacy fine-grained-streaming header note. Remaining UNVERIFIED items are listed in "UNVERIFIED list" above.

Live verification (2026-10-13)

Real key, default model claude-haiku-5-5, tests/anthropic/live.rs: streaming, the message cache read on turn 2 (2100 of 2152 input tokens cached), a tool call and its replay, and an adaptive-thinking turn whose thinking blocks are echoed through the tool loop all pass. First token 0.7-1.3 s. Adaptive thinking skips trivial turns (no blocks for "weather in Rome"), so the live test asks a question that needs reasoning and asserts that blocks come back.

Google Gemini API — LLM (streaming generateContent with function calling)

Researched 2026-10-08. Every fact below came from a page fetched that day; URLs are listed per section and in "Sources". Pages were read through a summarising fetch tool (it paraphrases and refuses long verbatim quotes), so JSON blocks marked "(doc)" were copied from the docs and blocks marked "(illustrative)" were rebuilt from field lists and are NOT verbatim wire captures. Anything not confirmed is marked UNVERIFIED. A live capture must confirm wire shapes before they are trusted. Fact-checked a second time on 2026-10-08 (see the last line of this file).

  • Docs: https://ai.google.dev/gemini-api/docs (the markdown versions *.md.txt of each page are listed in https://ai.google.dev/gemini-api/docs/llms.txt), API reference https://ai.google.dev/api/generate-content (fetched 2026-10-08)
  • Auth: API key in the header x-goog-api-key: $GEMINI_API_KEY (the current REST examples in the function-calling, text-generation and get-started guides all use this header; the API reference page's examples still show the older query form ?alt=sse&key=$GEMINI_API_KEY; the caching guide's create example also uses ?key=.... Prefer the header: a key in a URL leaks into logs). Content-Type: application/json. Keys are server-side secrets. Sources: .../docs/generate-content/get-started.md.txt, .../docs/generate-content/function-calling.md.txt, https://ai.google.dev/api/generate-content.
  • Endpoint(s) / regions:
    • Base https://generativelanguage.googleapis.com/v1beta/ (the guides use v1beta; v1 is not mentioned in the pages fetched).
    • Non-streaming: POST /v1beta/models/{model}:generateContent.
    • Streaming: POST /v1beta/models/{model}:streamGenerateContent?alt=sse (reference: POST /v1beta/{model=models/*}:streamGenerateContent; model = models/{model}). Without alt=sse the response framing is not described in the pages fetched: UNVERIFIED (we use alt=sse, which the docs show for streaming).
    • Regional / data-residency endpoints for the Gemini Developer API: UNVERIFIED (the "Gemini API vs Vertex" page does not specify hostnames). Availability by country is on https://ai.google.dev/available_regions ("The Gemini API and Google AI Studio are available in the following countries and territories"; Türkiye is in the list).

Important: API surface decision (generateContent vs Interactions)

Google now documents TWO surfaces on the same host:

  1. generateContent / streamGenerateContent (stateless, resend full contents each turn).
  2. The Interactions API (POST /v1beta/interactions per the streaming page; stateful by default via previous_interaction_id, store=false for stateless; typed SSE events interaction.created, interaction.status_update, step.start, step.delta, step.stop, interaction.completed, error, and a terminal event: done + data: [DONE] shown in the REST examples of the streaming page; the migrate page lists a slightly different event set, e.g. interaction.in_progress, interaction.requires_action, and its REST examples appeared to use a v1beta2/interactions path in the fetch: UNVERIFIED which is current, use the streaming page). The top-level pages function-calling, thinking, thought-signatures, caching, streaming (no /generate-content/ in the path) now describe the Interactions API (the top-level thought-signatures page is just a "moved" stub); the generateContent versions live under /gemini-api/docs/generate-content/... (e.g. .../generate-content/function-calling.md.txt).

The migration page states: "While generateContent remains fully supported, we recommend the Interactions API for all new development." No sunset date is given (migrate-to-interactions page). The same page says (paraphrase, the fetcher did not return a verbatim quote) that on generateContent streaming function calls arrive whole, with no partial arguments, whereas on Interactions function call arguments stream incrementally as arguments deltas (step.delta with type: "arguments_delta" carrying partial JSON strings, per the streaming page).

Recommendation for the brisvox adapter: streamGenerateContent?alt=sse, stateless, full history replayed from the engine's played-text history. Reasons backed by fetched docs:

  1. The stateless model matches the brisvox invariant "after an interruption the LLM context contains ONLY what was actually played": we build contents ourselves. With Interactions the default is server-side state (store=true); store=false exists but the stateless replay rules there (resend all thought steps exactly as received) are a different, newer shape.
  2. The task text asks for streamGenerateContent with alt=sse, and it is fully supported.
  3. Cost of the choice: tool-call arguments arrive whole (one chunk), not as deltas. For brisvox this only affects when a tool call can start (after the call's chunk arrives), not text streaming. The adapter emits one LlmEvent::ToolCallDelta per call with the full arguments JSON.
  4. Re-evaluate Interactions (incremental arguments, typed events) as a follow-up if tool-call latency matters. Interactions was not researched in depth here: UNVERIFIED beyond the facts quoted above.

Do NOT use the streamFunctionCallArguments option (incremental partialArgs): the JS SDK reference says the field is "not supported in the Gemini API" (partial results would appear in FunctionCall.partial_args; it is a Vertex-oriented field; see below).

Models (fast ones) — names from https://ai.google.dev/gemini-api/docs/models

Model IDRole per docsNotes
gemini-3.8-flashMost capable Flash model (long-running coding, agents, enterprise workflows); "latest models: 3.5 Flash-Lite or 3.8 Flash" recommended for new projectsstable; in 1,048,576 / out 65,536 tokens (model page); text out only; function calling, caching, structured output, batch/Flex/Priority supported; not Live API; thinking levels low/medium(default per thinking page; the model page does not state the default)/high; minimal "is not supported and returns an error"
gemini-3.5-flash-lite"Fastest, most cost-effective 3.5 model"; recommended for new projectsstable; default thinking level minimal; levels minimal/low/medium/high
gemini-3.7-flash, gemini-3.6-flash, gemini-3.5-flashprevious generations (3.5 labelled legacy)stable; 3.6/3.5: minimal allowed; 3.7: minimal errors
gemini-3.1-flash-litelite tierstable; minimal default
gemini-3-flash-previewFlash (preview)the thinking page lists it as "3 Flash" (the ID gemini-3-flash-preview is from the models page); default thinking high
gemini-2.5-flash, gemini-2.5-flash-litenot deprecated; access "limited to users who have used them in the past"2.5 Flash-Lite: thinking off by default, thinkingBudget: 0 allowed (range 512-24576 for Flash-Lite; 2.5 Flash 0-24576, default dynamic -1)
gemini-flash-latestalias, not a fixed model: points to the latest release of the Flash variation and changes when new releases ship (models page)do not use for production voice (behaviour changes silently)
Shut down per the models pagegemini-2.0-flash, gemini-3.1-flash-lite-preview, gemini-3-pro-previewdo not use (gemini-2.0-flash-lite was not listed in the models page re-fetch: shutdown status UNVERIFIED, avoid anyway)

Context window / output limits were confirmed for gemini-3.8-flash only (model page; the models list page gives no token limits except 1M context for the shut-down gemini-2.0-flash); other models' limits: UNVERIFIED. Prices (standard tier, paid, USD per 1M tokens; pricing page): gemini-3.8-flash input $0.75 / cached $0.075 / output (incl. thinking) $3.75 through 2026-12-31, then $1.50 / $0.15 / $7.50 from 2027-01-01, cache storage $0.50 per 1M tokens-hour (then $1.00); gemini-3.5-flash-lite $0.30 / $0.03 / $2.50, storage $1.00; gemini-3.1-flash-lite $0.25 (text, image, video; audio $0.50) / $0.025 (audio $0.05) / $1.50, storage $1.00. Free tier: 3.8 Flash has free input/output/caching; the two Lite models are free for input/output but context caching is "not available" on the free tier.

Recommended default for brisvox voice: gemini-3.5-flash-lite (documented as the fastest 3.5 model and defaulting to minimal thinking), with gemini-3.8-flash at thinkingLevel: "low" as the quality option. No TTFT / tokens-per-second figures are published on the pages fetched, so "lowest latency" is an inference: UNVERIFIED. Measure in the live test. Model IDs must be configuration, not hard-coded. Gemini 3 guide (note: that page is marked deprecated and describes the Interactions API): minimal "Minimizes latency for chat or high throughput applications", low "Minimizes latency and cost" / best for "simple instruction following, chat, or high-throughput applications"; high means "the model may take significantly longer to reach a first (non thinking) output token".

Thinking (latency-critical for voice)

Source: https://ai.google.dev/gemini-api/docs/generate-content/thinking.md.txt, API reference GenerationConfig/ThinkingConfig (https://ai.google.dev/api/generate-content.md.txt).

  • Request fields: generationConfig.thinkingConfig.thinkingLevel (enum THINKING_LEVEL_UNSPECIFIED (default), MINIMAL, LOW, MEDIUM, HIGH; reference: "recommended for Gemini 3 or later, and earlier models return an error if it is set"), thinkingBudget (int, Gemini 2.5; accepted on Gemini 3 for backward compatibility), includeThoughts (bool; thought summaries come back as parts with thought: true). "An error will be returned if [thinkingConfig] is set for models that don't support thinking." Do not send thinkingLevel together with the legacy thinkingBudget: the Gemini 3 guide says you "cannot use both" in the same request and that it returns 400 (that guide is the Interactions-era page; the generateContent thinking page does not state it: treat as true, UNVERIFIED for generateContent specifically).
  • Examples (doc):
{"contents":[{"parts":[{"text":"Provide a list of 3 famous physicists..."}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"low"}}}
{"generationConfig":{"thinkingConfig":{"thinkingBudget":1024}}}
  • Per-model levels (thinking page table): 3.8/3.7 Flash: low, medium (default), high, minimal errors; 3.6/3.5 Flash: minimal, low, medium (default), high; 3.5/3.1 Flash-Lite: minimal (default), low, medium, high; 3 Flash: all four, default high. Thinking cannot be fully turned off on the Gemini 3 Flash/Flash-Lite models ("minimal does not guarantee that thinking is off, the model may reason very minimally for complex tasks"). Whether thinkingBudget: 0 works on gemini-3.8-flash: UNVERIFIED (the thinking page does not list 3.x models in the thinkingBudget table).
  • maxOutputTokens includes thinking tokens; hitting it during reasoning gives finishReason: MAX_TOKENS with truncated/empty output while thinking tokens are still billed.
  • Temperature: for Gemini 3 "strongly recommend keeping the temperature parameter at its default of 1.0"; values below 1.0 "may lead to unexpected behavior, such as looping or degraded performance" (Gemini 3 guide). The function-calling guide best-practice says to use a low temperature (e.g., 0) for more deterministic and reliable function calls: the two recommendations conflict for Gemini 3; do not set temperature by default, measure in the live test. (Troubleshooting page gives the model parameter range for temperature as "0.0-1.0" while the API reference says temperature "Values can range from [0.0, 2.0]": conflicting, use the API reference but UNVERIFIED which is right.)
  • Defaults on Gemini 3.x: thinking ON by default; raises latency and token use (troubleshooting page).

Request shape (illustrative, from the field lists)

Reference fields of GenerateContentRequest: contents[] (required), tools[] (Function and codeExecution listed), toolConfig, safetySettings[], systemInstruction ("Currently text only"), generationConfig, cachedContent (cachedContents/{id}), labels, serviceTier (unspecified/standard/flex/priority), store (bool, overrides project-level logging). Content: { "role": "user" | "model", "parts": [...] } (role is optional and "must be either 'user' or 'model'"; the API has no system/tool role). Part holds at most one data field of text, inlineData, functionCall, functionResponse, fileData, executableCode, codeExecutionResult, toolCall, toolResponse plus metadata thought, thoughtSignature, partMetadata.

{
  "systemInstruction": {"parts": [{"text": "You are a phone agent. Answer in Turkish."}]},
  "contents": [
    {"role": "user", "parts": [{"text": "Hava nasil Londra'da?"}]}
  ],
  "tools": [{"functionDeclarations": [{
    "name": "get_current_temperature",
    "description": "Gets the current temperature for a given location.",
    "parameters": {"type": "object",
                   "properties": {"location": {"type": "string", "description": "City name"}},
                   "required": ["location"]}
  }]}],
  "toolConfig": {"functionCallingConfig": {"mode": "AUTO"}},
  "generationConfig": {"maxOutputTokens": 512, "thinkingConfig": {"thinkingLevel": "low"}}
}
  • System instruction key spelling: the text-generation guide's REST example uses snake_case "system_instruction": {"parts":[{"text":"You are a cat. Your name is Neko."}]}, the Apps Script example camelCase systemInstruction; the reference JSON uses camelCase. The proto3 JSON mapping normally accepts both: whether it accepts both here is UNVERIFIED. Use camelCase (reference). Same for the thoughtSignature / thought_signature spelling, see below.
  • FunctionDeclaration fields (reference): name, description (required), behavior (UNSPECIFIED, BLOCKING default, NON_BLOCKING; the reference says only BidiGenerateContent (Live) supports it), parameters (OpenAPI-subset Schema), parametersJsonSchema ("Describes the parameters to the function in JSON Schema format. This field is mutually exclusive with parameters"), response, responseJsonSchema (mutually exclusive with response). Brisvox ToolSpec.parameters_schema is arbitrary JSON Schema, so parametersJsonSchema is the natural target; which JSON-Schema keywords it supports: UNVERIFIED (not on the pages fetched; the function-calling guide only says the parameters form uses a subset of the OpenAPI schema).
  • Function name: the reference for FunctionDeclaration.name allows letters, digits, underscores, colons, dots and dashes, max length 128; FunctionCall.name / FunctionResponse.name allow letters, digits, underscores and dashes, max length 128 (the two rules differ). The function-calling guide advises "descriptive names without spaces or special characters (use underscores or camelCase)". A "no spaces, periods, or dashes" best-practices bullet was NOT found on the re-fetch: UNVERIFIED. Set accepted by the adapter: [A-Za-z0-9_-]{1,64}: the intersection of the three documented rules (a name must survive in FunctionDeclaration.name, FunctionCall.name and FunctionResponse.name, so no dots or colons) and exactly the alphabet and length of the agent's ToolRegistry, so no name the engine accepts fails the request. (An earlier [A-Za-z0-9_] "safe set" was a guess, not a documented limit; it rejected get-weather and with it the whole LLM turn.)
  • Tool limits: guide says keep the active tool set to roughly 10-20 at most; function descriptions and parameters count toward input tokens; in ANY mode "very large or deeply nested schemas may be rejected" (fixes: shorten property names, reduce nesting, declare fewer functions).
  • Built-in tools can be combined with function declarations on Gemini 3 (the function-calling guide marks this feature as Preview); the guide also notes function call parts may not be the last part of the parts array, so iterate all parts. Not used by brisvox.

toolConfig.functionCallingConfig.mode (API reference, FunctionCallingConfig)

AUTO (model chooses; default when only function declarations are given), ANY (must call a function), NONE (no function calls; same as sending no declarations), VALIDATED ("Like AUTO, but function calls are validated with constrained decoding"; guide: default when built-in tools or structured output are enabled). allowedFunctionNames[] limits the callable set, "Set this only when the mode is ANY or VALIDATED" (reference; the JS SDK page says only ANY: minor conflict). Brisvox LlmRequest has no tool_choice field, so send nothing (AUTO).

Streaming response (SSE)

Sources: https://ai.google.dev/gemini-api/docs/generate-content/text-generation.md.txt, https://ai.google.dev/api/generate-content, migrate-to-interactions page.

  • Request: POST ...:streamGenerateContent?alt=sse with x-goog-api-key; the guide's curl uses --no-buffer.
  • The text-generation guide does not describe SSE framing (data: prefix) or stream end; it says the streaming body "contains a stream of GenerateContentResponse instances" and that each streamed response contains a responseId that ties the full response together.
  • Each SSE event's data: line is one complete GenerateContentResponse JSON (illustrative shape from the docs' description, not a verbatim capture): candidates[] (with content.parts[], content.role: "model", index), usageMetadata, modelVersion, responseId. That responseId is "identical across all chunks": UNVERIFIED (not found on the re-fetch; the guide only says it ties the response together). Full response fields: candidates[], promptFeedback, usageMetadata, modelVersion, responseId, modelStatus (modelStage, retirementTime, message).
data: {"candidates":[{"content":{"role":"model","parts":[{"text":"Merhaba, "}]},"index":0}],"usageMetadata":{"promptTokenCount":12},"modelVersion":"gemini-3.8-flash","responseId":"<id>"}
  • Termination: the generateContent pages fetched do NOT describe an end-of-stream sentinel. The [DONE] sentinel and event: names are documented only for the Interactions API. So the adapter must treat the end as: a candidate chunk with finishReason set, then the HTTP body closing; it must tolerate a [DONE] data line and SSE comments. Whether chunks carry an event: line: UNVERIFIED (assume no). The thought-signatures guide says to read the stream until the finish_reason is returned.
  • finishReason is "optional and output-only. It stays empty while the model is still generating"; finishMessage is populated only when finishReason is set. Whether the last chunk can arrive with empty parts (e.g. only finishReason and usageMetadata): likely, UNVERIFIED.
  • Text deltas: candidates[0].content.parts[] entries with text (and no thought: true) are answer text; parts with thought: true are thought summaries (only when includeThoughts), which brisvox must NOT speak. A text part may be an empty string that only carries a thoughtSignature (thought-signatures page: when streaming without a function call the signature may arrive in a text part with empty content): skip empty text.
  • Several parts per chunk are possible (a chunk may contain a text part and a functionCall part): iterate all parts of every chunk, do not assume one.
  • candidateCount defaults to 1 (reference): use only candidates[0].
  • Mid-stream error shape: UNVERIFIED (pre-stream errors are HTTP errors with the standard error body, below; whether a failure after the first chunk is an in-band {"error":{...}} event or a connection abort is not documented in the pages fetched).
  • Grounding note (not used): when streaming, groundingMetadata.groundingChunks[] holds only chunks not in previous responses (UNVERIFIED on the re-check, irrelevant to brisvox).

finishReason values (API reference Candidate, doc)

FINISH_REASON_UNSPECIFIED (unused), STOP ("natural stop point or a provided stop sequence"), MAX_TOKENS, SAFETY, RECITATION, LANGUAGE (unsupported language), OTHER, BLOCKLIST, PROHIBITED_CONTENT, SPII, MALFORMED_FUNCTION_CALL ("the model's function call was invalid"), IMAGE_SAFETY, IMAGE_PROHIBITED_CONTENT, IMAGE_OTHER, NO_IMAGE, IMAGE_RECITATION, UNEXPECTED_TOOL_CALL (a tool call was generated but no tools were enabled), TOO_MANY_TOOL_CALLS ("too many consecutive tool calls"), MISSING_THOUGHT_SIGNATURE ("at least one thought signature was missing from the request"), MALFORMED_RESPONSE, ESCALATION, PUP_LIMITED_DISABLED.

  • There is NO FUNCTION_CALL/TOOL_CALLS value: a response that calls a function ends with STOP as far as the list shows (UNVERIFIED in practice). The adapter must derive FinishReason::ToolCalls from "any functionCall part was seen", Length from MAX_TOKENS, else Stop. The other values (safety, recitation, language, malformed call, missing signature) have no brisvox variant: map to Stop + log, or Error(Protocol) for MALFORMED_FUNCTION_CALL / MISSING_THOUGHT_SIGNATURE / UNEXPECTED_TOOL_CALL (gap: extend the enum or decide in the adapter).
  • PromptFeedback.blockReason (prompt blocked, "no candidates are returned"): BLOCK_REASON_UNSPECIFIED, SAFETY, OTHER, BLOCKLIST, PROHIBITED_CONTENT, IMAGE_SAFETY. A chunk can therefore have promptFeedback and no candidates.
  • Troubleshooting page: RECITATION can be reduced by a more unique prompt and a higher temperature; "prompts in unsupported languages can produce unexpected or blocked responses".

usageMetadata (reference, partly truncated by the fetcher)

Fields: promptTokenCount (docs: when cachedContent is set, this is still the "total effective prompt size", i.e. it includes the cached tokens), cachedContentTokenCount ("Number of tokens in the cached part of the prompt (the cached content)"), candidatesTokenCount ("Total number of tokens across all the generated response candidates"), toolUsePromptTokenCount, thoughtsTokenCount ("Number of tokens of thoughts for thinking models"), totalTokenCount ("prompt + thoughts + response candidates"), promptTokensDetails[], cacheTokensDetails[], candidatesTokensDetails[], toolUsePromptTokensDetails[], serviceTier.

  • Mapping to brisvox Usage: input_tokens = promptTokenCount, cached_input_tokens = cachedContentTokenCount, output_tokens = candidatesTokenCount + thoughtsTokenCount (thinking is billed as output: pricing page "Output (incl. thinking)"; candidatesTokenCount excludes thoughts per totalTokenCount = prompt + thoughts + candidates). Adapter must default each missing field to 0.
  • Whether usageMetadata is cumulative per chunk (last chunk holds the totals) or only on the last chunk: UNVERIFIED (the docs' streaming example shows a truncated promptTokenCount on a text chunk). Take the usage from the LAST chunk that has it, never sum across chunks.

Function calling

Sources: https://ai.google.dev/gemini-api/docs/generate-content/function-calling.md.txt, https://ai.google.dev/api/generate-content.md.txt (Part, FunctionCall, FunctionResponse).

Call in the response

A functionCall part: fields id ("Optional. Unique identifier of the function call. If populated, the client to execute the functionCall and return the response with the matching id"), name, args (a JSON object, i.e. already parsed, NOT a JSON string; contrast OpenAI). Gemini 3 "now always returns a unique id with every functionCall"; earlier models may omit it (then key the result by name and position). Illustrative shape:

{"role":"model","parts":[{"functionCall":{"id":"<id>","name":"get_current_temperature","args":{"location":"London"}},"thoughtSignature":"<sig>"}]}
  • Arguments do NOT stream incrementally on generateContent. migrate-to-interactions (paraphrase): function calls arrive whole, no partial arguments. The Vertex-style partialArgs/willContinue mechanism is gated by FunctionCallingConfig.streamFunctionCallArguments, which the JS SDK reference says is not supported in the Gemini API. The adapter emits one ToolCallDelta {index, id, name, arguments: <args serialised to JSON text>} per functionCall part. index = dense counter in arrival order; id = functionCall.id, or a synthesised id (e.g. call_<n>) when absent so the brisvox Message::tool_result(call_id, ..) pairing still works (replay must then omit id on the part).
  • Parallel calls: the model "supports calling multiple functions in a single turn"; results can be returned in any order because the API maps each one by id (guide), BUT a later example in the same guide says to "include the responses in the same order as they were requested": the guide is inconsistent, so return results in the order the calls were emitted. Whether parallel calls can span two chunks: UNVERIFIED (assume yes).
  • MALFORMED_FUNCTION_CALL finish reason: the guide says required structured text (e.g. XML) before a tool call can trigger "Malformed_Function_Call" errors (workarounds: have notes sent via a dedicated update() function, use Markdown headers, or require no text before tool calls). Treat as a failed turn (no call executed).

Result back (stateless replay)

Append the model content exactly as received (all parts, with signatures), then a new turn with role: "user" holding one functionResponse part per call (guide: functionResponse has name, response, id; role user; "Always include the exact id" is from the thought-signature/editing guidance). response is "Required. The function response in JSON object format" (a JSON OBJECT: wrap plain strings, e.g. {"result":"..."}; the guide's examples nest output under result, and the reference says an error key can report a failed execution). Optional willContinue (applies only to NON_BLOCKING calls, otherwise ignored) and scheduling (default WHEN_IDLE; other values SILENT, INTERRUPT) belong to NON_BLOCKING functions, not used. Multimodal results nest parts[].inlineData (not used). Fragment from the guide (doc, REST multimodal example, trimmed):

"functionResponse": {"name": "get_image", "id": "UNIQUE_CALL_ID_HERE", "response": {"image_ref": {"$ref": "instrument.jpg"}}, "parts": [ {"inlineData": {"displayName": "...", "mimeType": "...", "data": "..."}} ]}

Illustrative full turn:

{"role":"user","parts":[{"functionResponse":{"id":"<id>","name":"get_current_temperature","response":{"temperature_c":18}}}]}
  • Ordering rule (thought-signatures page, parallel calls): the expected order is FC1 (+signature), FC2, FR1, FR2; interleaving FC1, FR1, FC2, FR2 returns a 400. So all functionResponse parts of one model turn go into ONE user content (or consecutive after the full model content). Whether to put all functionResponse parts in one user Content or several: UNVERIFIED, use one.
  • Brisvox Message mapping: Role::Tool messages (consecutive) -> ONE user content with one functionResponse part each, keyed by tool_call_id; name must be looked up from the matching assistant tool_calls entry (brisvox Message for tool results carries no name). Content with role: "user" must also not be adjacent-merged with a following user text turn without checking: whether two consecutive same-role contents are accepted: UNVERIFIED.
  • Dangling call without result (barge-in cancelled the tool): UNVERIFIED how the API reacts (probably 400); the LLM stage must repair history (synthetic result such as "cancelled") as for OpenAI.

Thought signatures (brisvox provider_data)

Source: https://ai.google.dev/gemini-api/docs/generate-content/thought-signatures.md.txt (the older .../docs/thought-signatures page just redirects to the Thinking guide ("This page has moved"); the top-level .../docs/thinking signatures section describes the Interactions API, not generateContent).

  • Thinking models (Gemini 3 and 2.5) "may return a thoughtSignature in content parts, such as text or functionCall". Pass it back "exactly as received when sending the conversation history". Official SDKs do this automatically; REST callers (brisvox) must do it by hand.
  • Placement: Gemini 3 function calls: on the FIRST functionCall part (in parallel calls only the first part has it; later parallel parts have none); returning it is mandatory. Gemini 2.5 function calls: on the first part regardless of type, returning it is optional. Gemini 3 without a function call: on the LAST part (if the model generated a thought), not strictly enforced. Gemini 2.5 without a function call: none.
  • Validation (Gemini 3): omitting the signature of the first functionCall in a step of the current turn = HTTP 400 validation error. It applies to the CURRENT turn only ("Only current turn is required; we don't validate on previous turns"): the current turn starts at the most recent user message with standard content such as text (not a functionResponse), and every model functionCall step after it is checked. Signatures on non-function-call parts are recommended but not validated (omitting them does not error, quality may drop). Also applies at minimal thinking on Gemini 3 Flash. A request missing one can also surface as finishReason: MISSING_THOUGHT_SIGNATURE (reference).
  • Editing history manually: keep each signature in its original Part; always include the exact id; do not merge a signed part with an unsigned one; do not combine two parts that both have signatures. (The merge/combine rules were not re-confirmed on the 2026-10-08 re-check: UNVERIFIED wording, keep as a safe rule.)
  • Injected/synthetic function-call blocks (no real signature): strongly discouraged, but the documented escape hatch is the dummy signature value "context_engineering_is_the_way_to_go" or "skip_thought_signature_validator" (either skips validation; the page names transferring history from another model / client-executed calls / imported traces as the use cases). Use this for the brisvox fallback of replaying a call without provider data (e.g. the history was reconstructed from played text).
  • Key spelling in REST JSON: model responses use thoughtSignature; the page's request-history examples show thought_signature in places (the OpenAI-compatible format nests it under extra_content.google.thought_signature). The reference Part JSON field is thoughtSignature (opaque base64 string). Send thoughtSignature (proto3 JSON parsers normally accept both: UNVERIFIED).
  • Streaming: the signature can arrive in a part with empty text; read the stream until the finish_reason is returned. Accumulate across chunks.
  • Signatures sent back add to input tokens charged (generate-content thinking page).
  • Brisvox design (same pattern as the OpenAI adapter): LlmEvent::Done.provider_data = JSON array of the model turn's parts exactly as received (text parts concatenated is NOT enough; keep functionCall parts with id, args, thoughtSignature, and any trailing signed empty-text part), stored on the assistant Message.provider_data and replayed as the model content in the next request. After an interruption the played text must replace the model's text while the functionCall parts and their signatures stay; whether a signature attached to a text part that is truncated/removed must be dropped (the docs say text-part signatures are optional) is UNVERIFIED but safe.
  • Switching between model versions mid-call: for the Interactions API the top-level thinking guide says "When switching models within a session, you should still resend the previous model's thought blocks" and "The backend manages compatibility". For generateContent nothing is stated: UNVERIFIED.

Implicit and explicit context caching

Sources: https://ai.google.dev/gemini-api/docs/generate-content/caching.md.txt, https://ai.google.dev/api/caching (CachedContent), pricing page.

  • Implicit: "enabled by default for all Gemini 2.5 and newer models", no code change (savings are not guaranteed). Minimum input tokens: 4,096 for Gemini 3.8/3.7/3.6/3.5 Flash and 3.1 Pro Preview; 2,048 for 2.5 Flash and 2.5 Pro (the minimum for gemini-3.5-flash-lite, gemini-3.1-flash-lite was not listed: UNVERIFIED; the pricing page says context caching is "not available" on their free tier). Hits are visible in usageMetadata.cachedContentTokenCount (the guide says "usage_metadata field"; the exact field name is from the API reference). Tips: put large, common content at the beginning of the prompt and send requests with a similar prefix within a short time. Discount for implicit hits: pricing page lists "context caching" prices (3.8 Flash cached input is 10% of input price); whether implicit hits are billed at that rate: UNVERIFIED. TTL / eviction of implicit caches: UNVERIFIED.
  • Explicit (Beta, v1beta): POST https://generativelanguage.googleapis.com/v1beta/cachedContents (the guide's example appends ?key=...; use the header) with a CachedContent body: model (required, immutable, models/{model}), contents, systemInstruction (text only), tools, toolConfig (all input-only, immutable), displayName (<= 128 Unicode chars), and ttl (duration in seconds ending in s, e.g. "300s") OR expireTime (RFC 3339 UTC; one of the two). Default TTL "1 hour"; "no minimum or maximum bounds on the TTL". Output: name (cachedContents/{id}), createTime, updateTime, expireTime, usageMetadata.totalTokenCount. Only ttl/expireTime can be updated (PATCH .../v1beta/{cachedContent.name=cachedContents/*} with an optional updateMask query parameter); delete with DELETE .../v1beta/<CACHE_NAME>. Use: set "cachedContent": "<CACHE_NAME>" in the generate request; usable only with the model it was created for; "The model doesn't make any distinction between cached tokens and regular input tokens. Cached content is a prefix to the prompt." (this exact quote was not re-confirmed: UNVERIFIED wording). No special rate limits (standard GenerateContent limits apply); token limits include cached tokens. Minimum tokens for explicit caching "varies by model" (no table). Storage is billed per token-hour (prices above). Whether a request may ALSO send systemInstruction/tools next to cachedContent: UNVERIFIED (the page says nothing; the cache can hold them). Create example (doc):
{
  "model": "models/gemini-3.8-flash",
  "contents": [{"parts": [{"inline_data": {"mime_type": "text/plain", "data": "<base64-encoded file>"}}], "role": "user"}],
  "systemInstruction": {"parts": [{"text": "You are an expert at analyzing transcripts."}]},
  "ttl": "300s"
}

Use example (doc): {"contents":[{"parts":[{"text":"Please summarize this transcript"}],"role":"user"}],"cachedContent":"<CACHE_NAME>"}.

  • Brisvox ordering for implicit-cache hits (same logic as OpenAI): stable system instruction and tool list first, append-only history, volatile content (time, caller name) at the END of the newest user turn, never change model or thinking level mid-call. Gemini has no prompt_cache_key; routing is by prefix only (UNVERIFIED that nothing else exists). Explicit caching needs the whole prefix created beforehand via a separate API call: only worthwhile for a large static persona/knowledge prefix; a candidate for warm()-style setup, not for v1.

Errors, status codes, rate limits

Sources: https://ai.google.dev/gemini-api/docs/generate-content/api-errors.md.txt, https://ai.google.dev/gemini-api/docs/rate-limits.md.txt, https://ai.google.dev/gemini-api/docs/troubleshooting.md.txt.

  • HTTP statuses (status string): 400 INVALID_ARGUMENT (malformed body, or newer-version features on an older endpoint), 400 FAILED_PRECONDITION (free tier not available in your country and billing not enabled), 403 PERMISSION_DENIED (key lacks permission), 404 NOT_FOUND (a referenced image/audio/video file not found), 429 RESOURCE_EXHAUSTED (RPM/TPM/RPD or spend-based limit), 500 INTERNAL (e.g. context too long), 503 UNAVAILABLE (overloaded; switching to another model is advised), 504 DEADLINE_EXCEEDED (prompt too large; raise client timeout), 499 CANCELLED (client closed early). A 402 RESOURCE_EXHAUSTED (depleted Prepay credits) must not be retried blindly. 401 is not in the table; the page's own sample for an invalid key is HTTP 400 INVALID_ARGUMENT with ErrorInfo.reason: "API_KEY_INVALID".
  • Error body: JSON with an error object: code (integer, HTTP status), message, status (gRPC name in SCREAMING_CASE, e.g. INVALID_ARGUMENT), details[] (e.g. ErrorInfo with reason, domain, metadata, or LocalizedMessage). The page has this sample (doc, trimmed):
{"error":{"code":400,"message":"API key not valid. Please pass a valid API key.","status":"INVALID_ARGUMENT","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"API_KEY_INVALID","domain":"googleapis.com","metadata":{"service":"generativelanguage.googleapis.com"}}]}}

Map: 429 -> Rejected{429}, 5xx -> Rejected{5xx}, 400/403/404 -> Rejected with the status; never copy message (may echo prompt text).

  • Retries: troubleshooting page: exponential backoff with jitter and a max retry count; retry only transient errors "429, 408, or 5xx"; do not retry client errors like 400, 402, 403. The Python SDK "automatically retries transient errors up to four times" (initial delay about 1 s, max 60 s). Retry-After / rate-limit headers: NOT mentioned in the pages fetched: UNVERIFIED. The adapter never retries itself (turn logic owns retries).
  • Rate limits: dimensions RPM, TPM (input tokens), RPD (resets at midnight Pacific; some models also have IPM or TPD); limits are per project, not per API key; tiers: Free (active project), Tier 1 (billing linked, cap $250), Tier 2 ($100 cumulative + 3 days, cap $2,000), Tier 3 ($1,000 cumulative + 30 days, cap $20,000 to $100,000+). Spend-based limits per rolling 10 minutes: Tier 1 $10, Tier 2 $50, Tier 3 $200 (exceeding gives 429). Priority inference: 0.3x the standard rate limit. The page lists NO concrete RPM/TPM/RPD numbers: read them in AI Studio (https://aistudio.google.com/rate-limit). Concurrency limits: the only one stated is 100 concurrent batch requests; simultaneous streaming request limits: UNVERIFIED (not mentioned). For hundreds of concurrent calls one project needs a high tier plus Priority/Flex decisions: open question for the maintainers.
  • Timeouts / keep-alive: no server-side stream timeout or SSE keep-alive spec on the pages fetched: UNVERIFIED. Brisvox must set its own connect, first-event and inter-event timeouts. A 504 can occur for large prompts (raise client timeout). Cancel: no cancel call documented for a streaming generateContent request; dropping the HTTP body (drop LlmStream) is the plan; billing of tokens generated before the drop: UNVERIFIED.

Turkish support

  • The text-model pages fetched (models, generate-content guides, Gemini 3) give no language list: Turkish for gemini-3.8-flash / gemini-3.5-flash-lite text generation is UNVERIFIED from official docs. Turkish (tr) IS listed in the Live API capabilities table of 99 languages (https://ai.google.dev/gemini-api/docs/live-api/capabilities.md.txt) and the gemini-3.8-flash-tts model page (https://ai.google.dev/gemini-api/docs/models/gemini-3.8-flash-tts, "over 130 languages") lists Turkish; the troubleshooting page says "The API supports only a number of select languages" and warns that prompts in unsupported languages can produce unexpected or blocked responses; finishReason: LANGUAGE exists ("unsupported language"). Test Turkish prompts in the live test and treat LANGUAGE as an error. Availability by country includes Türkiye (available_regions page).

Vertex AI differences (note only)

  • Google's "Gemini Developer API vs Gemini Enterprise Agent Platform (Vertex AI)" page (https://ai.google.dev/gemini-api/docs/migrate-to-cloud.md.txt): the same google-genai SDK and the same generateContent/Interactions APIs; Developer API auth = API key; Vertex = Google Cloud service accounts, needs a project ID and a location; the page uses the same model IDs (gemini-3.8-flash) on both. The Interactions API on Vertex is Preview and supports only location="global". Hostnames, URL paths, regional endpoints and Vertex-only features (streamed partialArgs) were NOT stated: UNVERIFIED. A Vertex adapter would differ in URL (.../projects/.../locations/.../publishers/google/models/...:streamGenerateContent: from memory, UNVERIFIED) and bearer-token auth: a separate piece of work, not in the adapter. Vertex docs: https://docs.cloud.google.com/gemini-enterprise-agent-platform/ and https://cloud.google.com/gemini-enterprise-agent-platform/overview (both linked from the migrate page; the product was renamed; not read further).

Live API (note only)

  • Stateful WebSocket (WSS) real-time voice/vision API: input raw 16-bit PCM 16 kHz little-endian, output raw 16-bit PCM 24 kHz; also image frames (JPEG or PNG, up to 1 FPS) and text; function calling supported (live-api overview lists it as an integrated tool); server VAD on by default (realtimeInputConfig.automaticActivityDetection), interruption = BidiGenerateContentServerContent with interrupted: true. That pending function calls are discarded on interruption: UNVERIFIED (not found on the re-check). Models listed (capabilities page): gemini-3.8-live (default; thinkingLevel must be omitted), gemini-3.8-live-extended-thinking (supports low/medium/high), legacy Gemini 3.1 Flash Live Preview (gemini-3.1-flash-live-preview; exact ID string UNVERIFIED, the page names it by title). gemini-3.8-flash does NOT support the Live API (model page). The WebSocket URL was not on the pages fetched: UNVERIFIED. Sources: https://ai.google.dev/gemini-api/docs/live-api.md.txt, .../live-api/capabilities.md.txt. Out of scope for the cascade pipeline (STT -> LLM -> TTS); a possible future speech-to-speech adapter.

Mapping to brisvox LlmEvent (crates/brisvox-providers/src/llm.rs)

  • TextDelta <- every non-empty parts[].text with thought != true in candidates[0].
  • ToolCallDelta {index, id, name, arguments} <- one per functionCall part, whole, in arrival order (arguments = serialised args object).
  • Done {finish, usage, provider_data} <- chunk with finishReason set and the stream end: ToolCalls if any functionCall seen, Length for MAX_TOKENS, else Stop; other reasons per the table above. provider_data = the model turn's parts as received (signatures included).
  • Error <- HTTP error before the stream -> Err(Rejected{status}) from stream(); body ending before a finishReason -> Error(Disconnected); in-band error JSON (shape UNVERIFIED) -> Error. promptFeedback.blockReason with no candidates -> error (prompt blocked).
  • The stream ends without [DONE] as far as the generateContent docs say: do not wait for one.

Guesses the implementation must make (all UNVERIFIED until a live run)

  • x-goog-api-key header + alt=sse accepted together on v1beta for the chosen model.
  • Chunk framing (data: {json}\n\n, no event: line, no [DONE]), usageMetadata placement.
  • thoughtSignature spelling accepted in requests (camelCase), dummy signature accepted for replays without provider data on Gemini 3.
  • finishReason is STOP on function-call turns; function calls always whole in one chunk.
  • A functionCall id is present on Gemini 3 models only.
  • Turkish quality/behaviour of the chosen model.

Gotchas found during research

  • The top-level docs pages (function-calling, thinking, caching, thought-signatures, streaming) now describe the Interactions API, not generateContent; use the /generate-content/ variants or the API reference for generateContent facts. Examples for the same feature differ (tool_choice, function_result steps, thinking_level, signature deltas belong to Interactions). The gemini-3.md.txt guide is likewise Interactions-oriented and marked deprecated.
  • Gemini 3.8/3.7 Flash reject thinkingLevel: "minimal" with an error; the cheapest valid level is low. gemini-3.5-flash-lite defaults to minimal.
  • maxOutputTokens counts thinking tokens: with thinking on, a small cap can yield an empty answer with MAX_TOKENS.
  • promptTokenCount includes cached tokens; thoughtsTokenCount is separate from candidatesTokenCount (add both for output).
  • Do not speak thought: true parts; skip empty-text parts that carry only a signature.
  • Do not merge a signed part with an unsigned one when rebuilding history.
  • Free tier: the Lite models have no context caching.
  • The function-call arguments are a JSON object (args), results must also be a JSON object.

Implementation: brisvox_providers::gemini

Feature provider-gemini; GeminiLlm::new(GeminiConfig, SharedClock) implements LlmProvider. Code: crates/brisvox-providers/src/gemini/{mod,config,request,schema,events,sse}.rs, tests in crates/brisvox-providers/tests/gemini/ (contract suite, protocol tests, live test).

  • Surface: streamGenerateContent?alt=sse (stateless), as recommended above. Auth header x-goog-api-key (marked sensitive; never in the URL, logs, errors or Debug). Default model gemini-3.5-flash-lite (config). No redirects, no proxy, h2 + http/1.1 via the shared rustls config; plain http:// only for loopback unless allow_insecure_http.
  • Request: leading system messages -> systemInstruction (joined with a blank line); a late system message -> a user text part System note: ... (no system role exists); user/model roles; tool results -> functionResponse parts of ONE user content in call order (results may come back in any order, the adapter sorts them: the guide says "same order as requested"); the response must be an object: a JSON-object result is sent as is, anything else becomes {"result": "<text>"}. Adjacent contents of one role are merged (UNVERIFIED whether the API accepts two in a row; an assistant turn truncated to nothing leaves two user turns). A history that starts with a model turn (the agent's greeting) gets a placeholder user content (The call has started.) in front (leading_user_placeholder, None disables): whether the API accepts a leading model content is UNVERIFIED (some Gemini endpoints answered "First content should be with role user"), so the request is made valid either way; a live run decides. Empty user text is dropped. generationConfig only when something is set (maxOutputTokens, temperature, thinkingConfig.thinkingLevel XOR thinkingBudget; new() refuses both). toolConfig only with a configured function_calling_mode and tools in the request. Tool names must match [A-Za-z0-9_-]{1,64} (see above), duplicates are refused. A request with a call lacking its result, a result lacking its call, or no content at all fails with Protocol before any I/O.
  • Tool schemas (schema.rs, SchemaMode::OpenApiSubset, default): the JSON Schema is converted to the OpenAPI subset of parameters. The fields of that subset are NOT listed on the fetched pages (UNVERIFIED), so only the classic core is kept: type (upper-cased; null unions become nullable; unions of several types become anyOf; a schema with no type information becomes STRING, logged as a warning), description, properties, required (filtered to existing properties), items (default STRING), enum (strings only), nullable, anyOf (oneOf too), minimum/maximum, minItems/maxItems, format for date-time, int32/int64, float/double. Rewritten: local $ref inlined, allOf merged, const as one-value enum, non-string enums moved into the description ("Allowed values: ..."). Dropped: $schema, $id, $defs, definitions, title, default, examples, additionalProperties, pattern, minLength, maxLength, exclusive*, multipleOf, uniqueItems, if/then/else, not, patternProperties, propertyNames, unknown keywords. A root object without properties is declared without parameters; a NESTED object without properties ({"type":"object","additionalProperties":true}) is never sent as a bare OBJECT (Gemini is believed to reject OBJECT with empty properties: UNVERIFIED on the fetched pages, and one such node would fail every tool of the request with a 400): it becomes STRING with "Pass a JSON object encoded as a string." appended to its description. The model then sends a JSON string, and the adapter turns it back into an object in the forwarded ToolCallDelta.arguments (RenderedTools::restore_args, schema::restore_objects: walks the converted schema along properties, items and anyOf, replaces only a string at a degraded node that parses as a JSON OBJECT, leaves everything else alone; arguments over 32 KiB are forwarded as received, with a warning, to keep the re-parse small on the worker), so the tool's own schema validation (type: object) passes. The replay data keeps the string as the model sent it. Each degradation (this one, and the type-less -> STRING one, which turns e.g. a number-or-object parameter into a string) is logged once per rendering as a warning naming the tool; SchemaMode::JsonSchema avoids both. Refused with Protocol naming the tool: external $ref, unknown type, non-object root, recursion deeper than 24 levels or more than 10 000 nodes (counted separately for the $ref/allOf inlining pass and for the conversion pass, so a small DAG whose $ref/allOf fan out exponentially fails fast instead of pinning a worker). SchemaMode::JsonSchema sends the schema verbatim as parametersJsonSchema (the field the reference documents; which keywords it accepts is UNVERIFIED). The rendered tool array is cached (the 8 most recently used tool sets, so sessions with different tool sets sharing one GeminiLlm do not evict each other) and byte-stable for identical ToolSpecs; a cache miss with more than 4 KiB of schema text, or with any $ref/allOf in a schema (their cost is not a function of the text size), renders on the blocking pool, not on a tokio worker.
  • Stream mapping (events.rs): each SSE data is one GenerateContentResponse; only candidates[0] is read, all parts are walked. Text -> TextDelta; thought: true parts and empty text are never spoken; a functionCall -> ONE ToolCallDelta {index, id, name, arguments} with the whole args text as received ({} when absent or null; a non-object is Protocol). index = dense arrival counter; id = functionCall.id, else the made-up brisvox_call_<index>, which is recorded without an id and never sent back (the doc says to omit it). Caps: 128 calls, 1 MiB args per call, 4 MiB args total, 16 MiB replay data (text, call names, ids and arguments, AND thoughtSignature strings, plus 32 bytes per part), SSE event cap (config). [DONE] lines, SSE comments, empty data and unknown fields are tolerated.
  • Ending: Done is emitted when finishReason AND usageMetadata have been seen (normally the same chunk), otherwise at the clean end of the body, a [DONE] line, a transport error after the finishReason (all content was delivered; the signature must not be lost), or usage_grace (250 ms) after the finishReason when the body stays open (the last chunk may not carry usage: UNVERIFIED; Done.usage is then None or the usage seen earlier). A body that ends or drops before any finishReason is Disconnected. STOP -> ToolCalls if a call was seen else Stop; MAX_TOKENS -> Length. MALFORMED_FUNCTION_CALL, MISSING_THOUGHT_SIGNATURE, UNEXPECTED_TOOL_CALL, TOO_MANY_TOOL_CALLS, MALFORMED_RESPONSE, LANGUAGE -> Error(Protocol) naming the reason (no call may run). SAFETY, RECITATION, BLOCKLIST, PROHIBITED_CONTENT, SPII, OTHER, image reasons: if the turn only spoke it ends as Stop (logged); if it produced nothing, or already emitted a function call, it is Error(Rejected {400}) (a response cut for policy never gets its side-effect tool executed). Reasons without a mapping (ESCALATION, PUP_LIMITED_DISABLED, future values): Stop (logged) after spoken text only; with no output or after a call Error(Protocol) naming the sanitised reason (never a silent empty success). promptFeedback.blockReason without candidates -> Rejected {400}. Usage: input = promptTokenCount (includes cached), cached = cachedContentTokenCount, output = candidatesTokenCount + thoughtsTokenCount, from the last chunk that has it.
  • Errors: every non-2xx status is Rejected {status} (an invalid key is 400 per the doc, not 401); the adapter logs Retry-After, error.status and the first ErrorInfo.reason (sanitised), never the message, and never retries. An in-band {"error": {...}} chunk (shape UNVERIFIED) maps by its HTTP-like code, else by the gRPC status name (RESOURCE_EXHAUSTED 429, UNAVAILABLE 503, INTERNAL 500, DEADLINE_EXCEEDED 504, ...); anything else is Protocol with the sanitised status.
  • Deadlines (injected clock): first_byte_timeout (connect -> headers, Core(Timeout) from stream()), first_output_timeout (headers -> first text/call/end; comments and usage-only chunks do not extend it), idle_timeout (between events that carry OUTPUT after the first output; comments, empty data: heartbeats, {}, usage-only and part-less chunks do not count; consumer back-pressure is not charged), usage_grace (after a finishReason without usage), ERROR_BODY_TIMEOUT (1 s, reading a refusal body), DRAIN_TIMEOUT (500 ms) with DRAIN_LIMIT (16 KiB) for the rest of the body after the finish, the warm() HEAD (bounded by first_byte_timeout), consumer_stall_timeout (30 s: a consumer that is alive but leaves the full event channel unread this long gets its response abandoned, so it cannot pin the socket and replay buffer; the stream then ends without Done), max_stream_duration. Dropping the stream hangs up at once (HTTP/1.1: TCP close; HTTP/2: RST_STREAM, connection kept). warm() = unauthenticated HEAD on the streaming URL (the pages say nothing about warm-up; any HTTP answer leaves a pooled connection), HTTP/2 keep-alive pings, 10 min pool.
  • Thought signatures: Done.provider_data = JSON array of the response's parts (adjacent unsigned text coalesced, a signed text part kept apart, functionCall parts with id/args/ thoughtSignature), None when there is no call and no signature. Replay: the recorded parts go back as the model content; if the played text differs from the recorded text, the played text replaces ALL recorded text parts (their signatures are dropped; the doc calls them optional) and calls and their signatures stay, so an interruption never sends the unplayed text back; calls the history no longer holds are dropped. Without provider data the calls are rebuilt from ToolCall, and when no part carries a signature the first call gets skip_thought_signature_validator (GeminiConfig::fallback_signature; None disables; acceptance by the API: UNVERIFIED until a live run). replay_thought_signatures = false ignores provider data. The request key is thoughtSignature (camelCase); thought_signature and function_call are accepted when reading.
  • Not implemented: explicit context caching (cachedContent; whether systemInstruction/ tools may accompany it is UNVERIFIED), the Interactions API, Vertex AI, the Live API, includeThoughts, safety settings, candidateCount > 1, multimodal parts.
  • HTTP/2: GeminiConfig::http2_prior_knowledge (off by default) makes the client speak cleartext HTTP/2 without ALPN, for the h2-crate mock in tests/gemini/h2mock.rs. Over h2 the tests prove: HEAD warm-up and streams share one connection, 40 concurrent streams multiplex on one connection, dropping a stream sends RST_STREAM and the connection is reused. Still only provable against the real endpoint: ALPN negotiation with Google's servers, h2 keep-alive pings against Google's idle timeouts, TLS-level behaviour.
  • Tests: unit tests next to the code (schema conversion, request building, stream mapping, SSE parser); tests/gemini/contract.rs runs the shared LLM contract suite against a loopback mock; tests/gemini/protocol.rs pins the request on the wire, every message shape above, error mapping, timeouts, pooling, cancel, replay and secrecy; tests/gemini/review.rs covers completion without usage, idle semantics, deadlines on the injected clock (a clock running 2 s ahead of real time delays every timeout by 2 s; all nine of the adapter's deadlines are in that join: first byte, first output, idle, stream cap, usage grace, error body, consumer stall, drain, warm), the stalled consumer, the greeting turn, the tool cache and HTTP/2; protocol.rs also pins the post-finish drain bound (a server that keeps the body open, and one that keeps sending) and Turkish text (raw 2/3-byte UTF-8 delivered one byte per HTTP chunk; the SSE unit tests split multi-byte characters at every position; the request tests send Turkish history and an interrupted turn). Where the code decides to move work off the worker (build_request_body, tools_for), the unit tests log the thread that really ran the closure and assert it differs from (large) or equals (small) the caller's thread; tests/gemini/live.rs is the BRISVOX_LIVE_TESTS=1 + GEMINI_API_KEY (or GOOGLE_API_KEY) check, optional GEMINI_MODEL.
  • Follow-up: a live run (needs a key; blocking before the Gemini adapter is relied on): confirm the UNVERIFIED wire shapes (chunk framing, usage placement, whole calls, signature spelling and fallback signature acceptance, OBJECT with empty properties, a leading model content, Turkish, time to first token of gemini-3.5-flash-lite vs gemini-3.8-flash at low) and DECIDE THE DEFAULT SCHEMA MODE from a real capture: the doc lists no keyword subset for parameters and names parametersJsonSchema as the natural target for arbitrary JSON Schema, while the default (OpenApiSubset) keeps a list taken from the implementer's memory and drops (the type-less and free-form-object cases with a logged warning) additionalProperties, pattern, minLength, maxLength, title. Also confirm over real TLS: h2 negotiation, keep-alive pings, RST_STREAM on drop, warm-up reuse.

Sources (all fetched 2026-10-08)

  • https://ai.google.dev/gemini-api/docs/llms.txt (page index)
  • https://ai.google.dev/gemini-api/docs/models and .../models/gemini-3.8-flash
  • https://ai.google.dev/gemini-api/docs/models/gemini-3.8-flash-tts (Turkish listed)
  • https://ai.google.dev/api/generate-content and https://ai.google.dev/api/generate-content.md.txt
  • https://ai.google.dev/api/caching (CachedContent, ToolConfig, FunctionCallingConfig)
  • https://ai.google.dev/gemini-api/docs/generate-content/function-calling.md.txt
  • https://ai.google.dev/gemini-api/docs/generate-content/thought-signatures.md.txt
  • https://ai.google.dev/gemini-api/docs/generate-content/thinking.md.txt
  • https://ai.google.dev/gemini-api/docs/thinking.md.txt (Interactions-oriented; model switching note)
  • https://ai.google.dev/gemini-api/docs/generate-content/caching.md.txt
  • https://ai.google.dev/gemini-api/docs/generate-content/text-generation.md.txt
  • https://ai.google.dev/gemini-api/docs/generate-content/get-started.md.txt
  • https://ai.google.dev/gemini-api/docs/generate-content/api-errors.md.txt
  • https://ai.google.dev/gemini-api/docs/rate-limits.md.txt
  • https://ai.google.dev/gemini-api/docs/troubleshooting.md.txt
  • https://ai.google.dev/gemini-api/docs/gemini-3.md.txt
  • https://ai.google.dev/gemini-api/docs/pricing
  • https://ai.google.dev/gemini-api/docs/migrate-to-interactions.md.txt
  • https://ai.google.dev/gemini-api/docs/migrate-to-cloud.md.txt
  • https://ai.google.dev/gemini-api/docs/streaming.md.txt (Interactions streaming)
  • https://ai.google.dev/gemini-api/docs/live-api.md.txt and .../live-api/capabilities.md.txt
  • https://ai.google.dev/available_regions
  • https://googleapis.github.io/js-genai/release_docs/interfaces/types.FunctionCallingConfig.html (streamFunctionCallArguments: partial results in partial_args, "not supported in the Gemini API")

Fact-checked 2026-10-08: 78 claims checked, 17 corrected. Corrections: (1) gemini-flash-latest is an alias for the latest Flash (models page), not UNVERIFIED; (2) 3.8 Flash description is "most capable Flash", not "most intelligent"; (3) gemini-2.0-flash-lite shutdown not confirmed (only gemini-2.0-flash, gemini-3.1-flash-lite-preview, gemini-3-pro-preview listed as shut down); (4) thinking page lists "3 Flash", ID gemini-3-flash-preview comes from the models page; (5) thinkingLevel + thinkingBudget 400 and temperature/latency quotes come from the Interactions-era Gemini 3 guide and their wording was corrected; (6) THINKING_LEVEL_UNSPECIFIED added to the enum; (7) function-name rules differ for FunctionDeclaration (also colons, dots) vs FunctionCall, and the "no periods or dashes" bullet was not found; (8) parallel results order: the guide says both "any order" and "same order as requested"; (9) result key convention exists in examples and the reference mentions an error key (was "not confirmed"); (10) Interactions SSE event names/endpoint: the done/[DONE] terminator comes from the streaming page, not the migrate page, and the function-call-whole quote was a paraphrase; (11) API error page DOES contain a sample body, a FAILED_PRECONDITION 400 row, and an invalid key returns 400 not 401; (12) retry guidance includes 408 and 402 is non-retryable, SDK delay 1 s to 60 s; (13) rate limits: only concurrency limit is 100 batch requests; IPM/TPD exist; (14) Live API frames are JPEG or PNG, "pending calls discarded" unconfirmed, live-api.md.txt names no models (capabilities page does); (15) Turkish is listed on the gemini-3.8-flash-tts model page (fetched, not just a web search); (16) responseId "identical across all chunks" not confirmed; (17) model-switching thought-block note is from the Interactions thinking page (confirmed there), generateContent unstated. Remaining UNVERIFIED items are marked inline.

Live verification (2026-10-13)

Real key (free tier), tests/gemini/live.rs: plain Turkish streaming, a function call with its thought signature kept (provider_data) and replayed statelessly, and the answer after the tool result all pass (gemini-3.1-flash-lite; also gemini-3.5-flash). Error mapping confirmed live: 503 UNAVAILABLE ("high demand") and 429 RESOURCE_EXHAUSTED map to Rejected { status } in under 1 s. Free tier limits seen: 5 requests/minute and 20 requests/day per model (GenerateRequestsPerDayPerProjectPerModel-FreeTier). Under overload Google answered a 503 only after 15 s (curl) and sometimes sent no response headers for 30 s+: the adapter's first_byte_timeout is what bounds that. First tokens on the free tier were 2.5-7 s, not representative of paid latency.

Cartesia — TTS (WebSocket, Sonic)

Scope: contexts/continuations, word timestamps that feed played-text truncation, cancel on interrupt. Researched: 2026-10-08. Every fact below was read from the pages listed under Sources on that date. Anything not confirmed is marked UNVERIFIED.

  • Docs: https://docs.cartesia.ai/api-reference/tts/websocket (fetched 2026-10-08); full source list at the bottom.
  • Auth:
    • Server side: header X-API-Key: <key> (WS reference page) or X-Api-Key / Authorization: Bearer <key>; header names are case-insensitive (authenticate-your-client-applications page). Use a header on the WS handshake, never put the API key in the URL.
    • Browser/client: short-lived token from POST https://api.cartesia.ai/access-token (headers Authorization: Bearer <API_KEY>, Content-Type: application/json, Cartesia-Version: 2026-08-14; body {"grants": {"tts": true, "stt": true}, "expires_in": 60}; response field token), passed as WS query ?access_token=<token>. Grant tts allows /tts/websocket (grants object also has optional agent).
    • There is NO documented api_key query parameter for WebSockets (api-conventions page).
    • Brisvox: server side, so use the X-API-Key header; never log it (Error variants must not contain credentials).
  • Endpoint(s) / regions:
    • wss://api.cartesia.ai/tts/websocket. HTTP is not supported (api-conventions). No regions documented (UNVERIFIED whether regional endpoints exist; none found).
    • Version: header Cartesia-Version: YYYY-MM-DD on the handshake, or query ?cartesia_version=YYYY-MM-DD (query takes precedence; api-conventions). The WS reference lists cartesia_version as a required parameter ("API version, e.g. 2026-08-14") but does NOT state whether it is a query parameter or header (the query form comes from api-conventions). Latest API version seen: 2026-08-14 (cleans up multilingual voice support, removes already-deprecated fields; pinned older versions keep working). 2026-03-01 introduced structured JSON errors (older versions use a legacy plain-string error envelope). The auth page's WS example uses cartesia_version=2026-03-01. Official Python SDK (cartesia-python, Apache-2.0; PyPI latest 4.2.0 as of 2026-10-08) sends the header 2026-08-14 by default. Recommendation: pin cartesia_version=2026-08-14 in the query string; brisvox parses the structured error shape (requires >= 2026-03-01).
    • Example URL: wss://api.cartesia.ai/tts/websocket?cartesia_version=2026-08-14
  • Models (current, from tts-models pages and changelog):
    • sonic-3.6 = alias to latest stable snapshot (GA Aug 27, 2026); dated snapshot sonic-3.6-2026-08-27 (never changes). sonic-preview = beta, not for production.
    • The WS schema enum for model_id lists sonic-3.6, sonic-3.5, sonic-3, sonic-latest (the reference example uses "model_id": "sonic-latest").
    • sonic-3.5 alias (GA May 2026, no day given; snapshot sonic-3.5-2026-05-04). sonic-3 snapshots: sonic-3-2026-01-12 (stable), sonic-3-2025-10-27 (sunsets Oct 20, 2026).
    • Active sonic-2 and sonic-turbo snapshots sunset Oct 20, 2026 (changelog says "after October 20, 2026"; older-models page says "sunsets October 20, 2026") and have no Turkish; do not use. (Changelog Feb 2026 also lists earlier discontinuations effective Jun 1, 2026.)
    • Sonic 3.6 is "fully backwards compatible with Sonic 3.5". Sonic 3 -> 3.5 migration: changing the model id is the only required change "for most customers"; also review punctuation-based alphanumeric pacing guidance and test beginning-of-word timestamps.
    • Recommended for brisvox: model_id = "sonic-3.6" (configurable).
  • Protocol (messages in/out, with field names):
    • One WS connection multiplexes many contexts, each identified by a client-chosen context_id (string, e.g. UUID or readable id).
    • Client -> server, generation request (required: model_id, transcript, voice, output_format, context_id). Example (shape per the WS reference; values illustrative):
      {
        "model_id": "sonic-3.6",
        "transcript": "Example text to convert to speech.",
        "voice": "your-voice-id",
        "language": "en",
        "context_id": "my-context-001",
        "output_format": {
          "container": "raw",
          "encoding": "pcm_s16le",
          "sample_rate": 24000
        },
        "continue": true,
        "max_buffer_delay_ms": 3000,
        "add_timestamps": true,
        "add_phoneme_timestamps": false,
        "use_normalized_timestamps": true,
        "generation_config": { "volume": 1, "speed": 1, "emotion": "neutral" }
      }
      
    • Fields:
      • model_id string; transcript string (text chunk appended to the context's audio); voice string voice id, or object with required id (embeddings no longer accepted since Jun 1, 2026); context_id string.
      • output_format object, all required: container = raw (only value); encoding one of pcm_f32le, pcm_s16le, pcm_mulaw, pcm_alaw; sample_rate integer one of 8000, 16000, 22050, 24000, 44100, 48000. Keep voice and output_format identical across a context. Brisvox TtsConfig = PCM16 LE mono -> pcm_s16le; for telephony pcm_mulaw/pcm_alaw @ 8000 are available directly (UNVERIFIED whether any extra latency/quality difference vs resampling).
      • language (e.g. tr) OR locale (e.g. en-GB, tr-TR; locale needs sonic-3.6 or newer): never both (400 error). accent optional (must be in the voice's accents). normalization: auto (default) | off | language/locale code; reused on later requests in the same context if omitted.
      • continue boolean, default false. true = more transcript will follow in this context.
      • max_buffer_delay_ms integer 0..5000, default 3000.
      • flush boolean. add_timestamps bool default false (word level). add_phoneme_timestamps bool default false. use_normalized_timestamps bool (true normalized, false original text). pronunciation_dict_id string. generation_config: volume 0.5-2.0 (default 1), speed 0.6-1.5 (default 1), emotion in neutral|calm|angry|content|sad (omitted = model infers: UNVERIFIED).
    • Across inputs on the same context every field except transcript, continue (and duration) must stay the same (contexts page).
    • Client -> server, cancel (exact message shape, from the WS reference and contexts page):
      { "context_id": "happy-monkeys-fly", "cancel": true }
      
    • Server -> client messages, all carry context_id; type discriminates. Examples (shapes per the WS reference; ids and some values illustrative):
      {"type":"chunk","data":"<base64-encoded audio>","done":false,"status_code":206,"step_time":123,"context_id":"my-context-001"}
      {"type":"flush_done","done":false,"flush_done":true,"flush_id":1,"status_code":206,"context_id":"my-context-001"}
      {"type":"done","done":true,"status_code":206,"context_id":"my-context-001"}
      {"type":"timestamps","done":false,"status_code":206,"context_id":"my-context-001",
       "word_timestamps":{"words":["Example","text"],"start":[0,0.5],"end":[0.4,0.9]}}
      {"type":"phoneme_timestamps","done":false,"status_code":206,"context_id":"my-context-001",
       "phoneme_timestamps":{"phonemes":["h","ə","l","oʊ"],"start":[0.09,0.17,0.26,0.34],"end":[0.17,0.26,0.34,0.42]}}
      {"type":"error","done":true,"error_code":"model_not_found","status_code":400,"title":"Invalid model",
       "message":"The model is not valid, make sure it is a valid model ID.",
       "doc_url":"https://docs.cartesia.ai/build-with-cartesia/tts-models/latest",
       "request_id":"<unique-connection-id>","context_id":"my-context-001"}
      
      (JSON reformatted onto fewer lines; the phoneme example values were not re-verified: UNVERIFIED.)
    • Field notes: chunk data = base64 audio in the requested output_format; step_time = server processing time for the chunk in ms (number); status_code 206 on streaming messages (the docs call it "HTTP-like"); done true on final message of a context; flush_id integer counting flushes in the context, starting at 1 (WS reference: "number of flush commands that have been sent for this context. Starts at 1"). The flushing page says each audio chunk payload includes a flush_id, but the WS reference chunk schema has NO flush_id field: UNVERIFIED on the wire (read it as optional, default 0/absent). error: required type,title,message,status_code; optional done,error_code,doc_url,request_id,context_id. request_id identifies the connection (api-errors: may be a UUID or a derived per-message string). The SDK exposes base64 data as audio bytes - the wire field is data.
    • Ordering: outputs for a context arrive in input order (audio for input A before B). done: true only after all outputs for the context were returned. Interleaving of timestamps vs chunk messages is not specified (UNVERIFIED). The adapter handles timestamps before or after the chunk's flush_done, within the limits written down in the implementation gotchas. Messages of different contexts interleave on the wire; route by context_id (UNVERIFIED wording; docs describe multiplexing).
  • Keepalive / timeouts:
    • TTS WebSocket closed after 5 minutes idle (concurrency-limits-and-timeouts page). Docs advise closing idle sockets and opening a new one when needed (UNVERIFIED: that advice was not re-found on the fetched pages).
    • A "180 s inactivity timeout, ping every 60-90 s" statement exists only in the Cartesia Line Agents WebSocket API reference (and another Line page states a 120 s application-level timeout that pings do not reset), not the TTS WS docs; do not rely on it. A documented TTS keepalive message or ping behavior was NOT found: UNVERIFIED (use WebSocket protocol ping frames as a harmless probe; verify in the live test). Brisvox rule: reconnect if the socket is closed; warm() can pre-open a socket.
    • Context expiry: a context expires 1 second after its last audio output is streamed; sending another input on an expired context id is "not supported". If continue stays true for too long the server eventually expires the context ("contexts eventually expire on their own", compare-tts-endpoints page; duration not documented: UNVERIFIED). Brisvox: use a fresh context_id per utterance (ContextId -> string); never reuse one after done.
    • No documented per-message connect/handshake timeout; brisvox applies its own timeouts.
  • Finalize / flush / cancel semantics:
    • End an utterance: send the last text with continue:false, or if the last text is unknown send {"transcript":"", "continue":false, ...same fields..., "context_id":...}. Server then emits remaining chunks/timestamps and a final done. (Maps to TtsInput::Flush, i.e. no more text: synthesize remaining, then TtsEvent::Flushed on done.) The contexts page explicitly documents an empty transcript with continue:false to close a context. A claim that an empty transcript causes a 400 on the plain generate path could not be re-found on the pages checked on the second pass: UNVERIFIED. Verify live.
    • Manual flush (boundary inside a context): send continue:true, flush:true, transcript:"", same context_id. Server replies flush_done with an incrementing flush_id (first = 1); audio chunks carry the flush_id of the transcript that produced them (per the flushing page; see field-note caveat above). The context staying alive is implied, not stated. The flushing page does not mention flush_done and does not say whether flush_done is sent after all chunks of the previous transcript (assumed, UNVERIFIED).
    • Cancel: {"context_id": "...", "cancel": true}. Docs say: "It will only halt requests that have not begun generating a response yet" and "Any currently generating request will continue sending responses until completion." CRITICAL GOTCHA for barge-in: cancel may NOT stop audio already being generated. Brisvox must therefore (a) send cancel, (b) immediately drop every later message for the cancelled context_id client-side (epoch/ContextId filter), (c) emit TtsEvent::Cancelled itself without waiting for the server, and (d) not reuse the id. Whether the server sends a done/error after cancel is not documented: UNVERIFIED. Whether late cancelled-context audio still counts toward billing/concurrency is UNVERIFIED.
    • Cancel is a normal text frame on the same socket; the brisvox cancel channel must be polled with priority over input (as per the trait docs).
  • Timestamps / interim results / tool-call deltas:
    • Enable with add_timestamps: true (words) / add_phoneme_timestamps: true. Message type timestamps with word_timestamps: { words: [string], start: [number], end: [number] } (parallel arrays); phoneme variant phoneme_timestamps: { phonemes, start, end }.
    • Units: the reference says "Start times in seconds for each word" / "End times in seconds for each word" (floats, e.g. 0.4).
    • Reference point (relative to what: context start, chunk start, or cumulative across continuations): NOT documented in any page fetched (the contexts page says nothing about timestamps; the SSE timestamps page was not re-checked on the second pass). UNVERIFIED. Played-text truncation must treat it as unknown and be validated against a live recording: check whether start[0] of the second timestamps message of a context restarts at ~0 (per-message relative; brisvox must add the audio duration already received/sample offset) or continues (cumulative per context). Until verified, a safe implementation computes offsets from its own byte count of received audio only if per-message relative is observed.
    • Changelog / migration page: for the Sonic 3 -> 3.5 move, end-of-word timestamps (used for interruption handling) are "unchanged"; beginning-of-word timestamps may regress, test carefully. Whether the same holds for Sonic 3.6: UNVERIFIED.
    • use_normalized_timestamps chooses whether words are the normalized form (e.g. spoken-out numbers) or the original text; for played-text truncation (matching spoken text to LLM text) the original (false) is likely wanted. Which default applies if omitted: UNVERIFIED (schema lists no default).
    • "Subject to model and language limits": timestamps support for Turkish specifically is UNVERIFIED (compare-tts-endpoints says WS supports word/segment timestamps subject to model and language limits; no list found).
  • Concurrency / rate limits:
    • Concurrency is counted per unique active context_id (not per connection); extra inputs on the same context add nothing (UNVERIFIED on the second pass: this exact statement was not re-found on the concurrency page). Plans (cartesia.ai/pricing and concurrency page agree): TTS concurrent requests Free 2, Pro 3, Startup 5, Scale 15, Enterprise custom. TTS and STT pools separate (UNVERIFIED on second pass).
    • Max parallel TTS WebSocket connections = 10x the TTS concurrency limit (Scale: 150); exceeding it returns 429 on opening a new connection. Exceeding concurrency returns 429 ("429 Too Many Requests"). The error code concurrency_limited is defined as "over its plan's concurrency limit" (api-errors), and api-errors shows a 429 WebSocket error example; the concurrency page itself does not mention an error_code for WS errors, so the exact WS pairing error_code: "concurrency_limited" + status_code: 429 is inferred: UNVERIFIED.
    • Rule of thumb in docs: for conversational use cases the number of parallel conversations is "effectively 4X your concurrency limit" ("just a rule of thumb"; limit 15 ~ 60 calls), because most of a call is idle.
    • Maximum number of contexts per connection: NOT documented (UNVERIFIED). Brisvox design: one connection per call/session, one active context at a time (plus a cancelled one draining).
    • No requests-per-minute limit documented. Quota: quota_exceeded error when credits exhausted (unless overage is enabled on the plan).
    • Pricing: ~1 credit per character for TTS (all endpoints incl. WS; "can vary slightly due to transcript pre-processing"); no extra charge for the WS endpoint is mentioned (whether multiple concurrent contexts per connection cost extra: not stated, UNVERIFIED). Plans: Free $0 / 20K credits; Pro $5 / 100K; Startup $49 / 1.25M; Scale $299 / 8M; overage per 1M credits Pro $65, Startup $45, Scale $38 (Free: overage not available). Pricing page: https://cartesia.ai/pricing (docs mirror https://docs.cartesia.ai/pricing lists only ~1 credit/char and points to the pricing page). Prices as read 2026-10-08.
    • Error codes (api-errors page; mapping to status not listed there): quota_exceeded, concurrency_limited, voice_model_mismatch, voice_not_found, model_not_found, language_not_supported, file_too_large, unsupported_audio_format, plan_upgrade_required; error_code may be null and new values may appear: handle unknown ones. status_code is "HTTP-like"; values seen in docs examples: 400 (model_not_found, WS reference), 429 (WebSocket example in api-errors), 500 (SSE example). 401/403 for bad auth not documented on the fetched pages (SDK has AuthenticationError): UNVERIFIED on the WS handshake (likely HTTP 401 before upgrade).
  • Turkish support: YES. Sonic 3.6 supports 44 languages (changelog: "44 languages / 61 locales"), includes Turkish (tr, locale tr-TR); Sonic 3.5 and Sonic 3 list Turkish (42 languages). Active sonic-2 / sonic-turbo snapshots do NOT include Turkish (only already-sunsetted snapshots did). Use "language": "tr" (or locale, with 3.6+). Voice must support the language: a voice has an accents list; if it has no accent for the language it falls back to its original accent (documented with a Japanese example; for Turkish assumed the same: UNVERIFIED; the multilingual voices page does not mention Turkish). Pinning an unsupported accent -> 400 ("Unsupported accent for voice"). Pick/verify a voice id that is good for Turkish (the voice library filters by language; voice ids not researched: UNVERIFIED). Error language_not_supported if model/voice cannot do it.
  • Chunking / buffering guidance for continuations (buffering page, stream-inputs guide):
    • Joined transcript chunks must form valid text (stream-inputs guide): keep spacing and punctuation (" How are you?" may follow "Hello, world!", "How are you?" may not).
    • Two modes (buffering page). Managed buffering: stream LLM tokens straight in with max_buffer_delay_ms > 0 (default 3000); the API buffers until it has enough context or the delay elapses, whichever comes first. Custom buffering: you aggregate sentences/phrases yourself and set max_buffer_delay_ms: 0 so generation starts immediately. Do not combine client-side sentence aggregation with the default 3000 ms (adds unnecessary latency).
    • Brisvox recommendation (derived from the above, a design choice not a doc claim): aggregate in the engine (the LLM->TTS segmenter) and send phrase/sentence chunks with continue:true, max_buffer_delay_ms:0; finish with continue:false (empty transcript if needed).
    • End sentences with ., ?, !; without terminal punctuation the model may hold text until the delay expires and the audio may show artifacts. Final segment should have terminal punctuation and continue:false.
    • Do not split decimal values (1.0 into 1,.,0) when using speed/volume SSML tags.
    • Minimum characters per chunk: not documented.
    • Contexts page tip: "Without this option set [max_buffer_delay_ms], the model will start generating immediately on the first request". Interaction with the 3000 default is not fully spelled out; treat time-to-first-audio as needing a live measurement (no numeric latency figures in the docs: UNVERIFIED).
    • Reuse one WS across turns (saves TCP+TLS, "often tens to low hundreds of ms per turn"; compare-tts-endpoints page).
  • Mapping to the brisvox trait (crates/brisvox-providers/src/tts.rs) - proposal:
    • ContextId(u64) -> context_id string (e.g. format!("{session}-{n}")), maintain a reverse map; drop messages whose context id is unknown/cancelled.
    • TtsInput::Text -> generation request with continue:true; first request for a context carries all config fields, later ones repeat them unchanged. TtsInput::Flush -> continue:false (empty transcript) and TtsEvent::Flushed when done:true arrives for that context. (Do not use flush:true for this; that is the mid-context boundary.)
    • chunk.data base64-decode -> TtsEvent::Audio (decode off the hot loop if large; chunks are small, but note the hot-path allocation rule: reuse buffers).
    • timestamps.word_timestamps -> one TtsEvent::Word per word with start seconds -> Duration; the offset basis question above must be resolved first.
    • cancel channel -> send {"context_id":..,"cancel":true}, mark context cancelled locally, emit TtsEvent::Cancelled.
    • Server error with context_id may be per-context (e.g. bad voice) vs. connection-level; whether the socket remains usable after an error is UNVERIFIED.
  • Gotchas found during implementation (brisvox_providers::cartesia, feature provider-cartesia; tested against a local mock of this protocol, not against the live service):
    • Doc inconsistency noted: the WS reference lists the header X-API-Key; api-conventions only mentions Authorization: Bearer; authenticate-your-client-applications lists both. Both accepted.
    • Docs page docs.cartesia.ai/<ver>/asyncapi.yml redirects to a login page (not fetchable); the rendered API-reference page was used instead.
    • Implementation choices where the doc is silent or UNVERIFIED (all configurable or handled both ways):
      • Version: sent as the query cartesia_version=2026-08-14 (api-conventions, query form). Key: X-API-Key header marked sensitive, never in the URL. voice is sent as the plain string id (the WS reference example), not as an object.
      • language is optional in the adapter (omitted when unset: server default); max_buffer_delay_ms defaults to 0, add_timestamps is always true, use_normalized_timestamps defaults to false (words as written, for played-text matching), model sonic-3.6. All fields except transcript, continue and flush are repeated unchanged on every request of a context.
      • One empty-transcript flush: true request follows every text chunk ("flush marker", CartesiaConfig::flush_after_text, default on). Reason: with continue: true the server sends nothing that says "this chunk is finished", so a silent server cannot be told from one waiting for more text. The marker's flush_done lets the session arm the stall deadline only while output is owed (a long LLM or tool-call pause between sentences must not kill the session) and gives the audio duration at each chunk boundary (word time base, next point). ASSUMED, not verified: flush_done arrives after all chunks and timestamps of the preceding transcript; a flush with nothing buffered (delay 0) does not change prosody. If a live test shows otherwise, set flush_after_text = false: then only the first response to a text chunk and the final done are timed and word times are taken as context-cumulative.
      • Word time base (the open question above): handled both ways, per chunk. A chunk is the text between two flush_done acknowledgements; the audio duration received at a boundary (counted from the bytes, so exact) is what separates the two cases. If the first word of a chunk starts more than 20 ms before that audio duration, the server restarted its clock and the boundary audio is added; otherwise the times are used as sent (a cumulative server cannot start a word before the earlier chunks' audio has ended). This replaces an earlier rule that compared with the previous word's end, which misread a short first chunk followed by a chunk whose first word comes early. Offsets never go backwards. Ordering of timestamps vs flush_done (UNVERIFIED upstream, so both are handled): if a chunk's timestamps arrive after its own flush_done, a cumulative server is still classified correctly (not shifted either way) and a restarting server is shifted by the start of the chunk before the last boundary. What is NOT resolvable: a chunk that gets no timestamps at all (no words) followed by a restarting server is shifted by the earlier chunk's start; offsets are monotonic, so the damage is bounded. timestamps before the audio need no special handling (only byte counts at flush_done are used). A word time that is negative (clamped to 0), missing, NaN or absurd is clamped to the previous offset; words/start arrays of different length never fail the call (word times only feed played-text truncation).
      • Context expiry (the docs: a context expires 1 s after its last audio, more input on it is "not supported"; what the server does is UNVERIFIED, so three outcomes are handled): (1) the adapter's estimate (context_expiry, default 1 s, only with flush markers, only when the server owes nothing): the next Text opens a new server context (new id) instead of sending to the old one; (2) the server answers input on an expired context with an error (not a refusal like 401/429/5xx, and the context had produced output): a new server context is opened and the text that was not answered yet (kept up to 8 KiB, cleared at each flush_done) is sent again, at most twice in a row; (3) the server sends an unsolicited done: if nothing is unanswered the server context is marked closed, the engine context lives on (no Flushed), the next Text opens a new one. If the done finds a flush marker unanswered or text not yet confirmed (a solicited done always follows the flush_done of every marker, so this means the server expired the context just as that text arrived), it is handled like (2): the unanswered text, and the closing request if one was sent or wanted, go to a new server context; it is never a completion and never a false Flushed. A server that keeps answering with such a done and no flush_done in between fails the session (Protocol) after two re-sends. Limit (also of (2)): the re-sent text is everything not yet confirmed by an all-clear, so if the loss happens while an earlier chunk's flush_done is still in flight, that earlier chunk is spoken a second time. In all three the engine context keeps its audio count, and the new server context's word times are offset by the audio already delivered, so TtsEvent::Word offsets stay continuous across the pause (the trait requires this). A Flush for a context whose server context is closed or expired reports Flushed at once without sending anything. Messages from the old server context are dropped (routing is by the current server id).
      • Input closed: dropping input closes every open server context (continue:false, empty transcript), waits for the dones (stall-timed like any owed output: the timer is armed when the closing requests go out, also if nothing was owed before; a server that never sends the done ends the session with Timeout after io_timeout), delivers what is left and ends events; Flushed is only emitted for contexts the engine flushed. Contexts the engine never flushes or cancels are evicted (oldest first, server context closed) when more than 64 are open.
      • Cancel during I/O: the cancel channel is also polled while a send or a (re)connect is in progress. Cancelled is handed to the consumer before any socket work; the wire cancel is queued behind the write in flight. A cancel that arrives while a new context is still connecting suppresses its text. Not covered by a test: a TCP send buffer that is full (a stalled connect is tested instead; both go through the same select).
      • Close: TtsInput::Flush sends continue:false with an empty transcript; Flushed is emitted on the server's done (or any message with done: true) for a context the engine closed. A done the engine did not ask for only closes the server context (no Flushed, see context expiry). Flush for a context with nothing sent yields Flushed immediately; Text after the Flush of the same context is ignored (the server context is closed); a ContextId used again after Flushed starts a new engine context (word offsets restart at 0).
      • Cancel: the cancel message is sent AND the context is forgotten locally at once: later server messages for it are dropped, events for it that the consumer has not received yet are purged from the adapter's queue, Cancelled is queued first. Barge-in latency does not depend on the server. Queued Text/Flush for a cancelled id are ignored (last 64 ids remembered); a cancel for an unknown context still yields Cancelled. Messages are routed in two steps: first only type and context_id are read, leniently; a message of a context that is not live (cancelled, finished, replaced by a re-open, unknown) is dropped without further parsing, so a malformed late message of a cancelled context cannot fail the call. Only messages of live contexts are parsed strictly.
      • Errors: handshake refusals map to Rejected { status } (401, 403, 429, 5xx, ...). A server error message for a live context ends the session (the trait's Error event is final), except the two recoverable cases (expiry above, concurrency below): concurrency_limited -> Rejected 429, quota_exceeded -> Rejected (status as sent, else 402), plan_upgrade_required -> Rejected (else 403), status_code 401/403/408/429/5xx -> Rejected, anything else (model/voice/language problems) -> Protocol with error_code, title and message. An error for a cancelled or unknown context is ignored; an error without a context_id is treated as about the connection. Unparsable text, bad base64 of a live context and binary frames are Protocol. A close or reset while output is owed is Disconnected. Concurrency interaction with barge-in (docs: concurrency is counted per active context and a cancelled request that is already generating keeps running; whether it still counts is UNVERIFIED): the follow-up utterance after a barge-in can be refused with concurrency_limited on plans with a limit of 2-3. A refusal of a context that has produced nothing is retried for that engine context only, on a new server context id, after concurrency_retry_delay x attempt (default 300 ms, 600, 900, 1200 ms); the other contexts and the socket are untouched, text and a flush that arrive meanwhile are kept. After the fourth retry the refusal ends the session as Rejected 429. Anything beyond that (a plan limit) is the caller's: do not let cancelled contexts pile up.
      • Idle: no keepalive is sent (none is documented). If the server closes the socket while it owes nothing (5-minute idle rule) the session survives, every server context is marked closed and the next Text reconnects lazily with its own connection (one retry if the socket turns out dead on that first write); it does NOT take the socket that warm() parked for the next connect. warm() parks one socket (no task, no keepalive); connect takes it if it is younger than warm_max_age (60 s), still open and the provider's shutdown token is not cancelled, otherwise connects. A cancelled token releases the parked socket the next time warm or connect runs (no task watches it while it waits).
      • Transport: ws:// is accepted only for loopback hosts (tests, a local proxy) unless allow_insecure_ws is set, because the API key travels in the handshake header; anything other than wss:///ws:// is refused at CartesiaTts::new.
      • Odd-sized PCM16 chunks are re-aligned across chunk boundaries so every Audio event holds whole samples. pcm_mulaw / pcm_alaw are available for 8 kHz sessions only; the Audio bytes are then G.711, not PCM16 (the caller must know).
      • Timeouts: one io_timeout (default 10 s) covers connect, every send, the close and every wait for output the server owes; it restarts on each message for a live context and does not run while the adapter has stopped reading because the consumer is behind.
      • Backpressure: the socket is not read while 64 or more events wait for the consumer; nothing is dropped except by cancel.
    • Still UNVERIFIED on the live service (see the open items below): everything marked ASSUMED above, the timestamp time base and the order of timestamps vs flush_done, voice as plain string with sonic-3.6, the empty-transcript close, whether flush_done carries flush_id semantics as documented, the concurrency error shape on the socket, what the server does with input on an expired context (error vs ignore vs done; a silent ignore ends in the stall timeout of io_timeout, because the adapter then waits for a marker answer), and whether a cancelled but still generating context counts against concurrency.

Live run (2026-10-13)

Docs re-fetched 2026-10-13 (https://docs.cartesia.ai/api-reference/tts/websocket): endpoint wss://api.cartesia.ai/tts/websocket; auth X-API-Key header (server) or access_token query (browser), no api_key query parameter; cartesia_version required, example 2026-08-14; generationRequest and the chunk/flush_done/timestamps/phoneme_timestamps/done/error messages as described above. No protocol fact changed, so the mock server is unchanged.

Root cause of the first live failure (Core(Timeout { what: "cartesia connect" })): not the protocol, but TCP dialing. api.cartesia.ai (CloudFront) resolves to 8 IPv6 and 4 IPv4 addresses (the other adapters' hosts resolve to IPv4 only). On a machine whose IPv6 path silently drops packets, tokio_tungstenite::connect_async tries the addresses one after the other and waits for the OS to give up on each one (a Python probe with an 8 s timeout per address needed about 64 s in total) before it reaches the first IPv4 address, so the 10 s handshake deadline fires first. Evidence: a Python websockets client hung the same way; curl (which races address families) got 101 Switching Protocols in about 0.9 s with the identical headers and query; a raw socket.connect per address showed 8 IPv6 timeouts and 4 IPv4 successes in 10-30 ms. Fix: the shared dialer brisvox_providers::net::connect_ws (net/dial.rs + net/ws.rs, moved there from cartesia/dial.rs; see docs/PROVIDERS.md, "Dialing (happy eyeballs)") resolves the host and races the addresses (RFC 8305: alternate families, next attempt after 250 ms or as soon as the previous one failed, at most 8 addresses, first connected socket wins), then runs TLS and the WebSocket handshake over that socket (client_async_tls_with_config). The whole dial stays inside the existing io_timeout deadline. Every WebSocket adapter now uses it.

Measured (one run, Istanbul, residential link, sonic-3.6, pcm_s16le 24000 Hz, language en, max_buffer_delay_ms: 0, use_normalized_timestamps default):

  • warm() (DNS + raced TCP + TLS + WebSocket upgrade): 921 ms.
  • Time from the first text send (socket already warm) to the first audio chunk: 207 ms.
  • Word timestamps: 11 words for "Hello there, this is a short test." + " It has two chunks."; first word at 120 ms, last word at 2.68 s, 3.36 s of audio in total. Offsets were non-decreasing across the two chunks and ended inside the audio duration (as emitted by the adapter, which handles both time bases).
  • Flushed arrived after all audio and all 11 words of the utterance.
  • Barge-in: TtsEvent::Cancelled is emitted by the adapter itself (120 us after the cancel was sent); no further event for the cancelled context in the following 1.5 s; the next utterance (new context) on the same socket synthesised and flushed normally, so the socket stays usable after a cancel.
  • Confirmed accepted on the wire: X-API-Key header with cartesia_version=2026-08-14 in the query, voice as a plain string id, model sonic-3.6, add_timestamps, manual flush, cancel.

Resolved by this run: item 7 below (flush_done follows the chunk's audio, as far as the adapter reports Flushed), the plain-string voice with sonic-3.6, and the version placement (query parameter accepted, also with the older value 2025-04-16 in a curl probe; the Cartesia-Version header form was not exercised). Partly settled: item 2 (the adapter's view only) and item 5 (one data point: pcm_s16le 24 kHz, delay 0: 207 ms). Everything else in the list below, in particular the raw timestamp time base, stays UNVERIFIED because the live test observes only adapter-normalised output.

Open items to settle with a live test

Run BRISVOX_LIVE_TESTS=1 CARTESIA_API_KEY=... CARTESIA_VOICE_ID=... cargo nextest run -p brisvox-providers --all-features --no-capture -E 'test(=live_cartesia_session)'; it prints time to first audio, audio length, first and last word offsets and the cancel acknowledgement time (never the key).

  1. Timestamp time base (per message vs cumulative per context) and whether it spans continue chunks; relation to total audio duration.
  2. What the server sends after a cancel (nothing / done / audio tail) and how much audio still arrives for an already-generating request.
  3. Keepalive: does a WS ping frame reset the 5 min idle timer; what close code/reason is used.
  4. Max contexts per connection; behavior when a second context starts while the first still generates (ordering across contexts).
  5. Time to first audio with max_buffer_delay_ms:0 vs default, with pcm_s16le 24000/16000/8000.
  6. Turkish voice ids and whether timestamps are produced for tr.
  7. Whether flush_done arrives after all chunks of the preceding transcript.
  8. Whether chunk messages really carry flush_id on the wire; empty-transcript behavior.
  9. Expiry: what a request on a context idle for more than 1 s produces (error code/status, silence, or an unsolicited done), and how long after the last audio the expiry really is; then tune context_expiry and the error classification in session.rs::on_error.
  10. Whether a cancelled context that keeps generating still counts against concurrency, and how long it takes to stop (sizes concurrency_retry_delay).

Sources (all fetched 2026-10-08)

  • https://docs.cartesia.ai/api-reference/tts/websocket (and .md)
  • https://docs.cartesia.ai/use-the-api/tts-websocket/contexts.md
  • https://docs.cartesia.ai/api-reference/tts/working-with-web-sockets/contexts
  • https://docs.cartesia.ai/use-the-api/tts-websocket/context-flushing-and-flush-i-ds.md
  • https://docs.cartesia.ai/use-the-api/tts-websocket/buffering.md
  • https://docs.cartesia.ai/build-with-cartesia/capability-guides/stream-inputs-using-continuations.md
  • https://docs.cartesia.ai/use-the-api/concurrency-limits-and-timeouts.md
  • https://docs.cartesia.ai/use-the-api/api-conventions.md
  • https://docs.cartesia.ai/use-the-api/api-errors.md
  • https://docs.cartesia.ai/get-started/authenticate-your-client-applications.md
  • https://docs.cartesia.ai/use-the-api/compare-tts-endpoints.md
  • https://docs.cartesia.ai/build-with-cartesia/tts-models/latest.md (Sonic 3.6)
  • https://docs.cartesia.ai/build-with-cartesia/tts-models/sonic-3-5.md
  • https://docs.cartesia.ai/build-with-cartesia/tts-models/sonic-3-to-sonic-3-5.md
  • https://docs.cartesia.ai/build-with-cartesia/tts-models/older-models.md
  • https://docs.cartesia.ai/build-with-cartesia/capability-guides/multilingual-voices.md
  • https://docs.cartesia.ai/changelog/2026.md
  • https://docs.cartesia.ai/pricing.md and https://cartesia.ai/pricing
  • https://docs.cartesia.ai/examples/ (tts-websocket-flushing, -continuations, -concurrent-contexts, -response-handling, tts-sse-with-timestamps, error-handling) - not re-checked on the second pass
  • https://github.com/cartesia-ai/cartesia-python (Apache-2.0; no Rust SDK is listed on its page, but no official Rust SDK was positively ruled out: UNVERIFIED; brisvox uses tokio-tungstenite directly)
  • https://pypi.org/pypi/cartesia/json (latest 4.2.0)
  • https://docs.cartesia.ai/api-reference/agents/websocket (Line 180 s / 60-90 s ping statement, via search)

Fact-checked 2026-10-08: 62 claims checked, 14 corrected. Corrections: (1) SDK version "v4.0.1 examples" -> PyPI latest 4.2.0 (default header 2026-08-14 confirmed); (2) cartesia_version placement: WS reference does not say query vs header; (3) timestamps units wording is "Start/End times in seconds for each word"; (4) step_time example 120.5 -> 123, error message uses a comma ("is not valid, make sure"), timestamps start is 0 not 0.0, examples are not verbatim copies; (5) chunk flush_id not in WS reference chunk schema (only claimed by the flushing page) -> flagged UNVERIFIED; (6) "Without it, generation starts on the first request" was a mis-quote -> actual: "Without this option set, the model will start generating immediately on the first request"; (7) "tens to low hundreds of ms" is from compare-tts-endpoints, not stream-inputs; "keep leading spaces" is from stream-inputs, not buffering; (8) "Sonic 3.6 same" timestamps note was unsupported -> UNVERIFIED (note is from the Sonic 3 -> 3.5 migration); (9) Sonic 3 -> 3.5 "only the model id changes" -> only for most customers, plus punctuation and timestamp checks; (10) concurrency_limited + 429 on WS is inferred, not stated; "concurrency per context_id", "separate TTS/STT pools" and "contexts cost nothing extra" could not be re-found -> UNVERIFIED; (11) 4x rule wording and "180 s idle" source clarified (Line Agents API, with a conflicting 120 s rule on another Line page); (12) empty-transcript-400 claim not re-found -> UNVERIFIED; (13) normalization "initial default" -> auto default, emotion "omitted = model infers" unconfirmed, sonic-3.5 GA has no day, sunset wording differs between changelog and older-models page; (14) pricing docs mirror lists no prices; Free plan has no overage; Python SDK page lists no Rust SDK (not positively ruled out). Remaining UNVERIFIED: see items marked above (timestamp time base, cancel aftermath, keepalive, contexts per connection, Turkish voices/accent fallback/timestamps, flush_done ordering, auth failure status, regions, phoneme example values, sources under /examples).

Live check, Turkish (2026-10-13)

Docs re-fetched 2026-10-13 (https://docs.cartesia.ai/build-with-cartesia/tts-models/latest): sonic-3.6 supports 44 languages including Turkish (tr); the page does not say whether voices are cross-lingual (the voice of CARTESIA_VOICE_ID was used with language=tr and works).

Measured (live_checks.rs, model sonic-3.6, language=tr, pcm_s16le 24 kHz, add_timestamps, defaults of CartesiaConfig): item 6 of the open list is settled:

  • Word timestamps are produced for Turkish; ç ğ ı İ ö ş ü are intact in the word texts (as written, not normalized); start times monotonic and the last start is inside the audio (e.g. 3.48 s of 4.00 s).
  • First audio 204-238 ms (Turkish and English alike). Round trip through Deepgram: WER 0.000 on a sentence with ç ğ İ ş ü, 0.167 on a short question (an STT error).
  • Number reading: raw 14.30'da is read digit by digit (bir dört üç sıfır da), 250 lira is read correctly (iki yüz elli). With the repo normaliser (on dört otuzda) the reading is right (WER difference is only the spacing otuz da). The engine's TTS stage always applies the normaliser before Cartesia (docs/TURKISH.md).
  • One-word first chunk Okay, / Tamam, + rest 150 ms later in one context: no silence beyond the single chunk's own comma pause (0-90 ms vs 0-160 ms), no pacer underrun (bench/results/live-checks-2026-10-13.md).

ElevenLabs — TTS (WebSocket: stream-input and multi-stream-input)

Scope: WebSocket streaming, alignment/timestamps for played-text truncation, cancel; contract tests pass. Researched: 2026-10-08. Every fact below was read from the pages listed under Sources on that date (the API-reference pages are rendered AsyncAPI specs and mostly carry field names and defaults, little prose). Anything not confirmed is marked UNVERIFIED. No live call was made.

  • Docs: https://elevenlabs.io/docs/api-reference/text-to-speech/v-1-text-to-speech-voice-id-stream-input and https://elevenlabs.io/docs/api-reference/text-to-speech/v-1-text-to-speech-voice-id-multi-stream-input (fetched 2026-10-08); full source list at the bottom.
  • Auth:
    • Handshake header xi-api-key: <key> (both endpoints). Use the header; never put the key in the URL.
    • Alternatives in the spec: query authorization (bearer token) and query single_use_token (no further detail on the page: UNVERIFIED how a single-use token is obtained). Also the first message may carry the key instead of the header: single-stream xi-api-key and authorization fields ("only needed if not already provided in the header"); multi-context xi_api_key and authorization fields (apply to that context's (re)initialisation). The realtime-tts guide Python example sends xi_api_key in the first message (underscore), the API reference for the single-stream first message names the field xi-api-key (hyphen). Name mismatch in the docs; brisvox uses the header only, so it does not matter.
    • Brisvox: server side, so use the header (marked sensitive); never log it (Error variants must not contain credentials).
    • Failure shape on bad key over WebSocket: NOT documented (UNVERIFIED whether HTTP 401 before the upgrade or a close frame after it). HTTP errors use authentication_error / invalid_api_key / missing_api_key (errors page), but the errors page has no WebSocket section.
  • Endpoint(s) / regions:
    • Single context: wss://api.elevenlabs.io/v1/text-to-speech/{voice_id}/stream-input?model_id={model_id}

    • Multi context: wss://api.elevenlabs.io/v1/text-to-speech/{voice_id}/multi-stream-input?model_id={model_id}

    • The voice is fixed in the URL path (one voice per connection; voice_id path param).

    • Servers listed in both specs: wss://api.elevenlabs.io/ (default), wss://api.us.elevenlabs.io/, wss://api.eu.residency.elevenlabs.io/, wss://api.in.residency.elevenlabs.io/, wss://api.sg.residency.elevenlabs.io/. Latency guide: models are served from several regions (USA, Netherlands, Singapore), global routing is the default on api.elevenlabs.io, check the x-region response header, and api.us.elevenlabs.io pins US. Latency guide also says "Enterprise customers can use dedicated EU and India data residency environments", so the *.residency.* hosts presumably need an Enterprise plan (the specs do not say; the Singapore residency host is not mentioned in the guide: UNVERIFIED).

    • Query parameters (identical set on both endpoints; defaults from the spec). The specs give NO description text for most of them:

      ParamType / defaultNotes
      model_idstring, default eleven_multilingual_v2Set it explicitly (see Models)
      output_formatspec type "Any type", no default, no enum on the WS pagesValues below come from the HTTP convert endpoint page; assumed identical for WS: UNVERIFIED. Per the stream-input API reference the default audio is MP3 (AudioOutput.audio description: "encoded using the selected output_format, by default this" is MP3; the realtime-tts guide itself states no default, its examples just save .mp3), so ALWAYS set it
      auto_modeboolean, default falseNo description in the spec. Latency guide: "Setting auto_mode to true automatically handles generation triggers, removing the need to manually manage chunk strategies" so you do not manage chunk strategies yourself. Audio-streaming concept page: "auto_mode attempts to find a good balance automatically by analysing the incoming text". Exact algorithm and whether it overrides chunk_length_schedule: UNVERIFIED
      inactivity_timeoutinteger, default 20, max 180 (seconds)Blog 2024-09-24: "The default remains at 20 seconds", "a new maximum of 180 seconds"
      sync_alignmentboolean, default falseNo description. TTS-vs-TTD page: "Optional sync_alignment (TTS field naming in API reference)". Effect MEASURED live 2026-10-13: it changes the alignment time base. Off: every message's times start at 0 for that message's own audio. On: the times run from the start of the server context (message 2 starts where message 1's alignment ended, about 836 ms, not at 0)
      apply_text_normalizationspec type "Any type", no default on WS pagesHTTP convert page: auto (default) / on / off; "when set to 'auto', the system will automatically decide whether to apply text normalization (e.g., spelling out numbers)". Same values on WS assumed: UNVERIFIED. Latency/plan implications of on: not found, UNVERIFIED
      language_codestringNo description on WS pages. HTTP convert page: optional ISO 639-1 code; ignored if the model does not support the code; "This parameter is not supported for multilingual_v2 models". Behaviour on WS: UNVERIFIED
      enable_loggingboolean, default trueNo description (UNVERIFIED that false = zero retention; plan restrictions unknown)
      enable_ssml_parsingboolean, default falseNo description in the WS spec. Guide: needed for phoneme-based pronunciation dictionaries (UNVERIFIED, not re-confirmed on 2026-10-08)
      seedinteger 0..4294967295Determinism not described
      authorization, single_use_tokenstringSee Auth
    • output_format allowed values (HTTP convert page, default there mp3_44100_128): alaw_8000, mp3_22050_32, mp3_24000_48, mp3_44100_128, mp3_44100_192, mp3_44100_32, mp3_44100_64, mp3_44100_96, opus_48000_128, opus_48000_192, opus_48000_32, opus_48000_64, opus_48000_96, pcm_16000, pcm_22050, pcm_24000, pcm_32000, pcm_44100, pcm_48000, pcm_8000, ulaw_8000, wav_* (8000/16000/22050/24000/32000/44100/48000). Tiers: mp3_44100_192 needs Creator or above; pcm_44100 and wav_44100 need Pro or above. The page calls ulaw_8000 "commonly used for Twilio audio inputs". Brisvox TtsConfig = PCM16 LE mono at 8k/16k/24k -> pcm_16000, pcm_24000 (and pcm_8000, ulaw_8000 for telephony). The docs say only "PCM" (not "s16le", not endianness): that pcm_* is signed 16-bit little-endian mono raw samples, with no WAV header, is UNVERIFIED and must be checked in the live test. ulaw_8000 bytes are G.711, not PCM16.

    • Example URL (brisvox target): wss://api.elevenlabs.io/v1/text-to-speech/{voice_id}/multi-stream-input?model_id=eleven_flash_v2_5&output_format=pcm_24000&inactivity_timeout=180&sync_alignment=false (parameter combination assembled from the documented names; not run).

  • Models (models overview page, fetched 2026-10-08):
    • Low-latency, usable on the TTS WebSocket: eleven_flash_v2_5 (~75 ms inference latency, 32 languages, 40,000 characters/request, recommended by the realtime-tts guide as the low-latency model) and eleven_flash_v2 (~75 ms, English only, 30,000 chars). eleven_multilingual_v2 (29 languages, 10,000 chars, higher latency and cost than Flash). eleven_turbo_v2_5 / eleven_turbo_v2 are marked deprecated ("functionally equivalent" to the Flash models, "We recommend using the Flash models over Turbo models in all use cases").
    • The TTS WebSocket does NOT support eleven_v3 or eleven_v4 (realtime-tts guide: "That endpoint does not support the eleven_v3 or eleven_v4 models"; TTS-vs-TTD page: "Flash, Multilingual v2, and other supported TTS models. No eleven_v3 or eleven_v4 on this endpoint"). The multi-context guide does not address eleven_v3 at all (its examples use eleven_flash_v2_5); the exclusion for multi-stream-input is only implied by the two pages above (UNVERIFIED for multi-stream-input specifically).
    • Newer real-time models exist: eleven_v4_turbo (~100 ms median inference latency, 90+ languages incl. Turkish, ONE registered voice) and eleven_v3_conversational (~280 ms, 70+ languages), but they are served via the separate Text to Dialogue (TTD) WebSocket (https://elevenlabs.io/docs/eleven-api/guides/how-to/websockets/realtime-tdd.md). Per that page: endpoint wss://api.elevenlabs.io/v1/text-to-dialogue/stream-input, model_id must start with eleven_v3 or eleven_v4, responses use snake_case (e.g. is_final), the first message carries a voices array, eleven_v4_turbo allows exactly one registered voice and eleven_v4 up to 10. It has its own multi-context API-reference page .../api-reference/text-to-dialogue/ttd-multi-websocket (page exists). The rest of the TTD protocol was NOT researched here: UNVERIFIED. Whether eleven_v3_conversational is accepted there: the TTD guide does not name it (models page says it comes with the TTD WebSocket): UNVERIFIED. Possible follow-up if Flash quality is insufficient. Whether eleven_v4_turbo also works on the plain TTS stream-input endpoint: not stated, treat as NO.
    • Recommended for brisvox: model_id = eleven_flash_v2_5 (configurable). Latency guide (TTFB with Flash + WebSockets, excluding the ~75 ms nuance noted there that it is inference only): North America 100-150 ms, Europe 100-150 ms, South East Asia 100-150 ms, South Asia 150-200 ms, North East Asia 150-200 ms. Turkey is not listed as a region: UNVERIFIED (expect the Europe figure).
  • Protocol (messages in/out, with field names):
    • Naming warning: client messages use snake_case (context_id, close_context, close_socket, voice_settings, generation_config), server messages use camelCase (contextId, isFinal, normalizedAlignment, charStartTimesMs, charDurationsMs). The multi-context guide example code reads a key it calls is_final (Python data.get("is_final"), JS data.is_final); the API reference schema says isFinal, and the single-stream guide's Python example uses data.get('isFinal'). Parse both (UNVERIFIED which one is on the wire for multi-context; the AsyncAPI schema isFinal is treated as authoritative).
    • Single-context endpoint (stream-input), client -> server (spec V1TextToSpeechVoiceIdStreamInputPublish):
      1. InitializeConnection (first message): required text = " " (single space). Optional voice_settings, generation_config, pronunciation_dictionary_locators, and (first message only) xi-api-key, authorization. Guide example (verbatim):
        {
          "text": " ",
          "voice_settings": {"stability": 0.5, "similarity_boost": 0.8, "use_speaker_boost": false},
          "generation_config": {"chunk_length_schedule": [120, 160, 250, 290]},
          "xi_api_key": "your_elevenlabs_api_key_here"
        }
        
      2. SendText: required text ("Should always end with a single space string"); optional try_trigger_generation (bool, default false: "attempt to immediately trigger the generation of audio, overriding the chunk_length_schedule"; only generates above a minimum buffer threshold; docs recommend the default), voice_settings, generator_config (note the different name; "must be omitted or unchanged" after initialisation), flush (bool, default false: "Flush forces the generation of audio. Set this value to true when you have finished sending text, but want to keep the websocket connection open."). Guide examples: {"text": "Your text to synthesize."} (guide code: JSON.stringify({ text: text })) and {"text": "Generate this audio immediately.", "flush": true}.
      3. CloseConnection: {"text": ""} ("End the stream with an empty string"; guide: "Send an empty string to close the WebSocket connection after sending the last text message"). Buffered text is generated before the stream ends (guide: closing the socket also forces generation of buffered text).
    • voice_settings (RealtimeVoiceSettings): stability 0.5, similarity_boost 0.75, style 0 (V2+ models only), use_speaker_boost true (V2+ only), speed 1 (range 0.7..1.2). The docs list defaults 0.5/0.75; the guide example uses 0.8 for similarity_boost.
    • generation_config.chunk_length_schedule: array of numbers, default [120, 160, 250, 290], "Each item should be in the range 50-500". Text is buffered; generation starts when the buffer reaches the first threshold (120 characters), later chunks follow the schedule; lower values reduce latency but can reduce quality. Latency guide: without auto_mode, the model waits for enough text to match the chunk schedule before starting to generate audio and "stalls" if text arrives slowly (guide example: "if you set a chunk schedule of 125 characters but only 50 arrive, the model stalls until additional characters come in").
    • Multi-context endpoint (multi-stream-input), client -> server (all identify the context by context_id; fields from the spec, example JSON from the multi-context guide):
      MessageFieldsExample (guide)
      Initialize connection (InitializeConnectionMulti, first message)text = " " (required), context_id (optional; "unique identifier for the first context created in the websocket"; if omitted a default context is used), voice_settings, generation_config, pronunciation_dictionary_locators, xi_api_key, authorizationnot shown
      Initialise context (InitialiseContext)text (required, should end with a single space), context_id (spec: "If omitted, a default context ID may be assigned by the server"), voice_settings, generation_config, pronunciation_dictionary_locators, xi_api_key, authorization. "Creates a context with this ID, or re-initializes an existing one with new settings and text" (UNVERIFIED wording, not re-found in the 2026-10-08 re-fetch of the spec){"text": "Hello! I'm your virtual assistant.", "context_id": "greeting", "voice_settings": {...}} (guide code comment: "Only include voice_settings for the first message in a context")
      Send text (SendTextMulti)text (required), context_id, flush (default false; true flushes that context's buffer, false appends to the buffer){"text": "More text...", "context_id": "greeting"}
      Flush context (FlushContextClient)context_id (required), flush (required), optional text appended to the buffer before flushing{"context_id": "greeting", "flush": true} (guide: "use the flush: true flag at the end of complete sentences")
      Close context (CloseContextClient)context_id (required), close_context (required; "Must set the close_context to true, to close the specified context"; if false the context stays open and text is ignored; if the context was set to flush, the flush finishes first; the same ID can be reused but is not linked to the earlier context){"context_id": "greeting", "close_context": true}
      Close socket (CloseSocketClient)close_socket (bool): closes all contexts and the entire WebSocket connection; "Any context that was previously set to flush will wait to flush before closing"{"close_socket": true}
      Keep alive (KeepContextAlive)text = "" (required) and context_id (required): spec "This text is ignored by the server but its presence resets the inactivity timeout for the specified context" (guide: "send an empty text message to reset the timeout clock"){"context_id": "greeting", "text": ""}
      There is NO message literally named initialiseContext as a JSON field: InitialiseContext is
      the spec's schema name for "text with a context_id (new or re-init)". The first message with a
      new context_id creates the context (guide); no separate create message is needed.
    • Server -> client, single context (stream-input):
      • AudioOutput: audio (string, required, base64 partial audio in the selected output_format), alignment (optional), normalizedAlignment (optional).
      • FinalOutput: isFinal: true (always true; "Indicates if the generation is complete. If set to True, audio will be null.").
    • Server -> client, multi-context: AudioOutputMulti: audio (required, "Base64 encoded audio chunk"), contextId, alignment (nullable), normalizedAlignment (nullable); FinalOutputMulti: isFinal: true and contextId ("Indicates this is the final message for the context"). No full wire examples of server messages exist in the docs. Shape per the schema (values illustrative, assembled from field names, NOT copied from the docs):
      {"audio":"<base64>","alignment":{"chars":["H","i"," "],"charStartTimesMs":[0,60,120],"charDurationsMs":[60,60,40]},"normalizedAlignment":{...},"contextId":"greeting"}
      {"isFinal":true,"contextId":"greeting"}
      
      Whether audio may be the empty string or null on alignment-only messages, whether alignment is null or absent when not produced, and whether a message can carry both audio and isFinal: UNVERIFIED (spec says audio is null on final, single-stream only).
    • Ordering / termination: not specified beyond "isFinal = generation complete" (single) and "final message for the context" (multi). After a flush the docs do NOT say whether isFinal is sent (UNVERIFIED; the multi-context guide explicitly does not state whether a final message follows a flush): the single-stream guide treats isFinal as the end of the whole stream (Python example stops listening on it), and flush: true keeps the socket open. For multi-context the signal that all audio of a flushed segment has arrived is therefore UNVERIFIED; there is no flush_done-like message in the spec. Brisvox plan: end a bot turn with close_context (or flush
      • close_context) and take isFinal for that contextId as TtsEvent::Flushed (to be confirmed live; see open items).
    • Message size: the docs examples set a 16 MB max message size on the client; reason not explained (UNVERIFIED, not re-confirmed on 2026-10-08).
  • Keepalive / timeouts:
    • Default inactivity timeout 20 s (single stream: "The default value is 20 seconds", max "180 seconds", help center; multi guide: "Contexts by default timeout after 20 seconds and are closed automatically" and "The inactivity timeout is a websocket level parameter that applies to all contexts and can be up to 180 seconds if needed"). Set inactivity_timeout=180 in the query to maximise it (blog, help-center article, and both specs agree on default 20/max 180).
    • Single stream keepalive: send {"text": " "} (a single space) to reset the timer (help center: repeat every 20 s for longer sessions). A fully empty string "" is an end-of-sequence message and closes the socket (help-center: "will send an End of Sequence (EOS) message").
    • Multi-context keepalive: {"context_id": "...", "text": ""} (empty text WITH a context_id is ignored and resets that context's timer). Note the opposite semantics of "" between the two endpoints; do not reuse code across them. Whether a message for context A resets the timer of context B or the socket: UNVERIFIED. Whether a WebSocket protocol ping frame resets any timer: UNVERIFIED (the blog does not mention keepalive/ping at all).
    • What the server does on timeout (close code / error message / whether the socket closes with a final message): NOT documented (UNVERIFIED). A third-party (Vapi) log is reported as "ElevenLabs input timeout exceeded" (not re-checked), not an ElevenLabs doc: do not rely on the wording.
    • Brisvox: one connection per session; send a keepalive for the active context while text is owed but delayed (LLM/tool pause) or use inactivity_timeout=180; reconnect lazily if the socket was closed. No documented connect timeout; brisvox applies io_timeout.
  • Finalize / flush / cancel semantics:
    • Flush: flush: true (with text, or with only context_id in multi-context) forces generation of whatever is buffered without closing the context/socket ("generation of any buffered audio in the context", multi guide). Closing the stream (single) also forces generation of the buffer.
    • Brisvox TtsInput::Flush ("no more text for this utterance, synthesize rest, report Flushed") maps to: send the final text with flush: true, then close_context for that context; emit Flushed on isFinal for that contextId. UNVERIFIED that isFinal is emitted after a flush or close_context and after ALL audio of the context; confirm live.
    • Cancel (barge-in): docs (multi-context guide): "When a user interrupts your agent, you should close the current context and create a new one" (example sends {"context_id": old_context_id, "close_context": true}, then starts a new context with its own context_id). That is the only documented cancel. The docs do NOT say whether (a) audio already generated and in flight stops, (b) buffered-but-not-generated text is discarded or generated (the spec says a context set to flush finishes flushing before closing, which implies a flushed context may keep producing audio after close_context; whether audio continues to arrive after close_context is sent is not covered), (c) an isFinal follows. CRITICAL for barge-in: do not rely on the server. Brisvox must (1) send close_context for the cancelled context, WITHOUT a preceding flush, (2) drop every later message for that contextId client-side immediately (turn-epoch / ContextId filter), (3) emit TtsEvent::Cancelled itself, (4) never reuse the id (docs allow reuse but "not linked", so a late message of the old context could be mistaken for the new one; use a fresh id per utterance, e.g. format!("{session}-{n}")).
    • Single-stream endpoint has no cancel: the only way to stop is to close the socket (then reconnect, which costs a handshake). This is why multi-stream-input is the right choice for barge-in.
    • Text normalization/continuity: within a context, prosody continuity is a stated purpose of contexts ("Maintain prosodic consistency for utterances within the same logical context", guide); how much previous-text conditioning exists across flush boundaries is not documented. UNVERIFIED.
  • Timestamps / interim results / tool-call deltas:
    • Alignment objects on audio messages (both endpoints): alignment (input text) and normalizedAlignment (normalized text, e.g. numbers spelled out). Each has three parallel arrays of equal length: chars (array of string; "may include spaces and punctuation"), charStartTimesMs (array of integer, start of each char in ms), charDurationsMs (array of integer, duration in ms). There is NO word-level alignment in the schema: words must be derived by grouping chars between spaces (the realtime-tts guide says "You can use alignment to get the word-level timestamps for each word in the text", which contradicts the spec's char-level arrays; the spec is treated as authoritative: UNVERIFIED which is right).
    • Time base: the spec says "Times are relative to the returned chunk, not the full audio." So the offset of a message's chars = the audio already delivered for that context before that message. Brisvox should compute that offset from its own PCM byte count for the context (exact), not from the alignment itself. Live 2026-10-13 (eleven_flash_v2_5, pcm_24000, "Hello there, this is a short test. It has two chunks."): a message's alignment arrives in the SAME message as its audio (3 audio messages with alignment, then an empty isFinal), and the statement above holds only with sync_alignment=false (the adapter's default, the server default). With sync_alignment=true the times are NOT chunk-relative: they run from the start of the server context. Recorded message start times (ms) with audio of 997 / 685 / 1277 ms: [0 .. 789], [836 .. 1579], [1718 .. 2798]; with false: [0 .. 824], [0 .. 731], [0 .. 1022] for audio of 997 / 748 / 1153 ms. Adding the audio delivered so far to the cumulative times counted it twice: the last word landed at 4.0-4.3 s in 2.9 s of audio (the failure seen in the live run). Audio length matched the format: 2.90-2.96 s from the pcm_24000 byte count (2 bytes/sample at 24 kHz) against a last character start of 2.8 s, so the pcm_* bytes are 16-bit mono at the requested rate. The adapter now uses the audio before the message as the origin when off and the audio before the server context when on ( Live::alignment_origin; fixtures SYNC_RECORDED and CHUNK_RECORDED in crates/brisvox-providers/tests/elevenlabs.rs). normalizedAlignment had one more character than alignment in the first and third message (22 vs 21, 20 vs 19, an extra entry right after the first character) and the same times otherwise. Whether the sync clock restarts for a new server context after an unsolicited isFinal is not measured (assumed, as the id is a new context).
    • Which text is spoken: use normalizedAlignment when present for timing of what was actually spoken, but played-text truncation matches against the LLM text, which corresponds to alignment (input text). Choose alignment for matching; handle normalizedAlignment being the only one present. Whether chars of alignment can map 1:1 onto the original text (leading spaces, SSML, trailing-space convention): UNVERIFIED.
    • No interim results or tool-call deltas apply (TTS).
  • Concurrency / rate limits:
    • Plan concurrency (help-center TTS page and the rate-limiting blog agree), per model family:

      PlanFlash and Turbo modelsAll other models (e.g. Multilingual v2)
      Free42
      Starter63
      Creator105
      Pro2010
      Scale3015
      Business3015
      Enterprisecustom (help center: Enterprise department can offer a custom plan; blog: "Elevated")

      Flash and Multilingual draw from separate budgets (blog). The blog's queue weights are Free 3, Starter 4, Creator 5, Pro 5, Scale 5, Business 5, Enterprise 6.

    • WebSocket counting: "With the Text to Speech WebSocket, only the time where our model is generating audio counts towards your concurrency limit" (models page; blog: "Over a WebSocket, only the time the model is actively generating audio counts"; HTTP counts for the full request). Whether several contexts generating at once on one connection count as several in-flight requests, and whether a closed context that keeps generating still counts: UNVERIFIED.

    • Over the limit: requests are queued by plan priority (blog: queue "typically adds around 50ms of latency"), and "if the system is still over capacity after queueing, you receive an HTTP 429". Errors page: 429 with code rate_limit_exceeded or concurrent_limit_exceeded; system_busy (type rate_limit_error, 429): "The system is currently busy. Try again later." Response headers current-concurrent-requests and maximum-concurrent-requests (HTTP responses; whether present on the WS upgrade response or WS messages: UNVERIFIED). How a concurrency refusal is reported on an already-open WebSocket (handshake 429, error message, close frame): NOT documented (UNVERIFIED). Third-party summaries mention too_many_concurrent_requests (not re-checked); the official errors page uses concurrent_limit_exceeded: use the official name, handle unknown codes.

    • Contexts per connection: 5 concurrent contexts ("Each connection is limited to 5 concurrent contexts to reflect this", multi-context guide, which calls the API "optimized for voice applications"; also "The server can maintain up to 5 concurrent contexts per connection, but you should close contexts when they are no longer needed"); the guide also says "Establish one WebSocket connection for each end-user session" (an earlier claim here that it forbids using the connection for unrelated parallel audio streams was not found on re-fetch: UNVERIFIED). The error text when exceeding 5 is not in the docs (a third-party community post quotes "Maximum simultaneous contexts": UNVERIFIED). Brisvox design: one connection per call/session, one active context at a time plus the just-closed one draining, so at most 2-3 live; always close_context finished contexts.

    • Request size limits (models page): Flash v2.5 40,000 chars, Multilingual v2 10,000. Per-WebSocket-message / total-per-connection limits: not found (UNVERIFIED).

    • Pricing / credits: not researched here (credits per character differ by model; Flash has "a lower API price per character", UNVERIFIED wording). insufficient_credits (HTTP 402, type payment_required) exists on the errors page; WS shape UNVERIFIED.

  • Errors:
    • HTTP error body (errors page): JSON object with detail containing type, code, message, status (legacy, no longer used), request_id, param; types/codes seen: authentication_error type (codes invalid_api_key, missing_api_key, 401), rate_limit_exceeded, concurrent_limit_exceeded, system_busy (429), payment_required/insufficient_credits (402), internal_error (500, "An unexpected server error occurred").
    • WebSocket error messages and close codes: NOT documented anywhere fetched (the errors page does not mention WebSockets; the specs list no error messages or close codes). UNVERIFIED, including: whether the server sends a JSON error object (and with which fields) before closing, which close codes it uses for auth failure, invalid voice/model/format, timeouts, and context limit. Adapter plan: treat any message that is not audio/isFinal and contains an error/message/detail key leniently as an error (log shape), map handshake HTTP failures to Rejected { status }, unexpected close while output is owed to Disconnected, and record the real shapes from the live test back into this file.
  • Turkish support: YES on eleven_flash_v2_5 and eleven_multilingual_v2 (models page: multilingual v2 language list includes tr, and the models table marks Turkish for both; Flash v2.5 = "All eleven_multilingual_v2 languages plus: hu, no, vi" = 32 languages). eleven_flash_v2 is English only (en), so it is unusable for Turkish. eleven_v4_turbo and eleven_v3_conversational also list Turkish but are TTD-only here. Language is inferred from the text ("The language is determined by the text, while the accent and pronunciation are determined by the voice itself", help center); language_code can force it on models that support it (ignored otherwise; not supported for multilingual_v2 per the HTTP page). Whether language_code=tr is honoured on the WS with Flash v2.5, whether alignment is produced for Turkish, Turkish text-normalization quality (numbers, dates, currency) and a good Turkish voice id: UNVERIFIED (voices not researched; use apply_text_normalization experiments live).
  • Chunking / buffering guidance for continuations (derived from the docs; the recommendation itself is a design choice, not a doc claim):
    • Default behaviour waits for 120 characters before first audio, which is bad for first-audio latency. Options: auto_mode=true; a small chunk_length_schedule (docs example [50, 120, 160, 290], items 50..500); try_trigger_generation; or flush at sentence ends. Recommended design (to measure live): send LLM text aggregated by the engine's segmenter (phrase or sentence, each ending with a single space) with flush: true on each segment so audio is generated promptly (realtime-tts guide: use flush: true "along with the text at the end of conversation turn" and flush: true "to clear out the buffer and force generate any buffered text"; the "so audio is generated promptly" rationale is the author's, not a quote; multi-context guide: flush at the end of complete sentences). Lower thresholds "may reduce audio quality".
    • Flushing mid-turn is allowed and keeps the context open (docs). Effect of many small flushes on prosody: not documented; UNVERIFIED.
    • The send-text rule "should always end with a single space" applies to every text chunk; the guide's examples omit it. Add it in the adapter (single space, don't double).
    • Reuse one WS across turns (inactivity_timeout=180) to save connect time.
  • Mapping to the brisvox trait (crates/brisvox-providers/src/tts.rs) - proposal:
    • Use multi-stream-input, one socket per connect (one session = one call). TtsConfig.voice -> {voice_id} path segment (voice is fixed per socket); TtsConfig.sample_rate -> output_format (pcm_8000/pcm_16000/pcm_24000; other rates only through resampling or pcm_22050/ pcm_44100 etc., 44.1 kHz needs Pro).
    • ContextId(u64) -> context_id string; maintain a reverse map; drop messages whose contextId is unknown/cancelled/closed. First message of the socket: {"text":" "} (+ voice_settings, generation_config) per the spec; whether that initialises a default context that then counts toward the 5-context limit: UNVERIFIED. (A plain InitialiseContext message with a real context_id per utterance may avoid this.)
    • TtsInput::Text -> {"text": "<text> ", "context_id": id} (first one with voice_settings), optionally flush: true per segment. TtsInput::Flush -> {"context_id": id, "flush": true} then {"context_id": id, "close_context": true}; Flushed on isFinal for that id (UNVERIFIED).
    • audio base64-decode -> TtsEvent::Audio (hot path: reuse buffers; decode where allocation is acceptable; the format is raw PCM only if requested; verify no WAV header).
    • Alignment chars -> words -> TtsEvent::Word with start = chunk byte offset + charStartTimesMs of the word's first char (time base above; word grouping on spaces; handle null alignment and empty arrays).
    • cancel channel -> send close_context, forget the context locally, queue Cancelled first; must be polled with priority over input (as per the trait docs).
  • Official SDKs / licenses: ElevenLabs Python SDK elevenlabs/elevenlabs-python is MIT licensed; its GitHub page shows HTTP-stream TTS (text_to_speech.stream()) and a "Speech Engine" WebSocket SERVER feature, but nothing on the TTS stream-input / multi-context client WebSocket (no release version on the page: UNVERIFIED). No official Rust SDK was found. Community crates (not ElevenLabs): elevenlabs-sdk 0.1.0 (docs.rs; Apache-2.0; repo longcipher/elevenlabs-sdk-rs, crates.io owner Akagi201; describes "WebSocket streaming" incl. TTS input-streaming) and elevenlabs_rs 0.7.1 (released 2026-07-27, repo rwxbytes/elevenlabs_rs, "An unofficial Rust crate for ElevenLabs", optional ws feature whose functionality the docs.rs page does not explain, MSRV 1.85, license not shown on the docs.rs page: UNVERIFIED). Not evaluated further (quality not checked). Brisvox should use tokio-tungstenite directly, as for Cartesia.
  • Gotchas found during implementation (crates/brisvox-providers/src/elevenlabs, tested against a local mock only; nothing here was seen on the real service):
    • There is no per-flush completion message, so the adapter infers what the server still owes by comparing the non-whitespace characters sent with those covered by alignment (the plain one once seen; normalizedAlignment only while no plain one was seen, because the spelled-out text can be longer than the input and would otherwise end the wait early and split words). If the real alignment turns out not to cover the characters sent (UNVERIFIED: normalization, SSML, trimmed spaces), a server that went quiet after responding is tolerated when at most 4 characters (or a quarter of the text) are missing: coverage is then dropped for that context. A larger shortfall is a Timeout after io_timeout. ElevenLabsConfig::completion_by_alignment = false times only the first response after each text instead. Confirm live (open items 1 and 2).
    • Flushed is delivered on isFinal for the context after flush + close_context. If no isFinal follows (UNVERIFIED), final_grace (default 1 s) after the last message ends the context once all text is aligned (5 s when nothing was ever aligned: with no alignment the first audio chunk is all there is to go by, and a gap longer than the grace between chunks would otherwise yield Flushed early and drop the rest of the audio).
    • Every text frame carries flush: true (and ends in a space); no try_trigger_generation, no auto_mode. chunk_length_schedule is only sent if configured. Effect of many small flushes on prosody: open item 7.
    • A server context starts with {"text":" ","context_id":..} (carrying voice settings and chunk_length_schedule), not with a context-less first message, so no default context is created (open item 6 stays relevant for the first message of the socket).
    • The voice is in the URL, so warm() needs ElevenLabsConfig::warm_target = (voice, rate); without it warm() does nothing.
    • Alignment times are taken relative to the audio delivered before the message. An alignment in its own message is anchored at the audio delivered so far (either order of audio and alignment is handled; the real service sends them in one message, measured 2026-10-13). With sync_alignment=true the origin is the audio delivered before the server context opened. Words may span messages.
    • Unsolicited isFinal while nothing is owed closes the server context (the adapter also sends close_context for it, so it cannot count against the 5-context limit; how a close for an already closed context is answered is unverified, an error carrying that old id is dropped) and the engine context gets a new server context on its next text. While text is still owed it is ignored (HIGH RISK, open item 2): if the server does send isFinal per flushed piece, ignoring is right; if it really means "context closed by the server", the unspoken text is lost and the stall deadline ends the session with a Timeout (no text re-send, unlike the Cartesia adapter).
    • Text with no letter or digit (blank, ..., -, emoji only) is not sent and not counted as owed output: if the server answers such a chunk with no audio and no alignment (UNVERIFIED), counting it would arm the stall deadline and end the session with a Timeout. A mixed chunk (Hi 😀) is sent whole; the emoji is then an alignment shortfall, which is tolerated (see above).
    • After the Flushed of an engine context, later text and flushes for the same id are ignored (the last 64 such ids are remembered, as for cancels; do not reuse ids). Before this was remembered, the outcome depended on timing: a late text was dropped if the server's isFinal had not arrived yet, but spoken in a new server context with restarted word offsets if it had. A flush for an id nothing was sent for is answered with Flushed once.
    • Server message bounds: a message over 1 MiB (the WebSocket frame limit; real chunks are a few KiB to a few tens of KiB) or an alignment / normalizedAlignment with more than 16384 entries ends the session with Protocol. Without the caps one message could queue millions of Word events (and reserve a 12 MiB decode buffer) for every call in the container. The alignment characters are parsed as borrowed strings (one Vec per array, no String per character) and a message is parsed once, not once for routing and once more in full.
    • When all 4 engine context slots are in use, the one evicted for a new context is, in this order: one without an open server context, the oldest the engine has not flushed, and only if all await their isFinal the oldest of those, which then gets its Flushed at once (its remaining audio is lost).
    • The provider contract suite's "idle gap after the first event" check needs a frame that is one event, so its harness mock sends the alignment of a word as its own message ahead of the audio (AlignMode::AlignmentFirst); with audio and alignment in one message a single frame is two events (Audio, Word). The adapter's own protocol tests use the real shape (audio and alignment in one message) for completion, cancel, shutdown and eviction; see docs/TESTING.md.

Open items to settle with a live test

  1. Time base and sync of alignment: SETTLED 2026-10-13 (per message when sync_alignment=false, from the server context start when true; alignment and audio arrive in the same message; see the Time base entry). Still open: whether alignment arrives for Turkish; alignment vs normalizedAlignment presence when apply_text_normalization is auto/on/off; the sync clock after a server context restart.
  2. isFinal semantics (HIGH RISK: an unsolicited one while text is owed is ignored, see the gotchas): after flush, after close_context, after close_socket; is it per context; key spelling (isFinal vs is_final); is audio still sent after close_context (and how much); message order of final audio vs isFinal.
  3. Behaviour of try_trigger_generation, auto_mode, and chunk_length_schedule in multi-context; time to first audio for eleven_flash_v2_5 from Turkey/Europe with pcm_16000/pcm_24000/ulaw_8000 and with auto_mode on/off.
  4. Exact output_format support on the WS (pcm_* encoding, header, ulaw_8000), default when omitted.
  5. WebSocket error payloads and close codes: bad key, bad voice, bad model, bad output_format, inactivity timeout, 6th context, concurrency refusal; do HTTP handshake failures occur.
  6. Whether a keepalive message resets the socket-level or only the context timer; whether the initial {"text":" "} creates a counted default context.
  7. Whether many flush: true calls in one context hurt prosody; effect on billing.
  8. Turkish: language_code=tr effect, text normalization of numbers/dates, a good Turkish voice id.
  9. Whether concurrency counts a cancelled (closed) context that keeps generating.

Sources (all fetched 2026-10-08; the time-base fact was re-read on 2026-10-13)

  • https://elevenlabs.io/docs/api-reference/text-to-speech/v-1-text-to-speech-voice-id-stream-input (and .md)
  • https://elevenlabs.io/docs/api-reference/text-to-speech/v-1-text-to-speech-voice-id-multi-stream-input (and .md; re-fetched 2026-10-13: "relative to the returned chunk", sync_alignment default false, no description; output_format untyped)
  • https://elevenlabs.io/docs/api-reference/text-to-speech/convert (output_format and apply_text_normalization values, HTTP endpoint)
  • https://elevenlabs.io/docs/eleven-api/guides/how-to/websockets/realtime-tts (TTS WebSocket guide)
  • https://elevenlabs.io/docs/eleven-api/guides/how-to/websockets/multi-context-web-socket.md
  • https://elevenlabs.io/docs/eleven-api/guides/how-to/websockets/tts-vs-ttd-websockets.md
  • https://elevenlabs.io/docs/eleven-api/guides/how-to/websockets/realtime-tdd.md
  • https://elevenlabs.io/docs/api-reference/text-to-dialogue/ttd-multi-websocket (existence only)
  • https://elevenlabs.io/docs/overview/models
  • https://elevenlabs.io/docs/eleven-api/resources/errors.md
  • https://elevenlabs.io/docs/eleven-api/guides/how-to/best-practices/latency-optimization.md
  • https://elevenlabs.io/docs/eleven-api/concepts/audio-streaming.md
  • https://elevenlabs.io/docs/help-center/technical/how-many-text-to-speech-requests-can-i-make-and-can-i-increase-it (concurrency table)
  • https://elevenlabs.io/blog/ai-rate-limiting-for-voice (queue, headers, WebSocket counting)
  • https://elevenlabs.io/docs/help-center/technical/how-can-i-keep-the-websocket-open
  • https://elevenlabs.io/blog/websocket-improvements-reliability-and-custom-timeout (published 2024-09-24, updated 2026-10-06)
  • https://elevenlabs.io/docs/help-center/product/core-capabilities/text-to-speech/what-languages-can-the-ai-speak.md (only the "language is determined by the text" sentence; the language lists are on the models page)
  • https://elevenlabs.io/docs/llms.txt (page index)
  • https://github.com/elevenlabs/elevenlabs-python (MIT)
  • https://docs.rs/crate/elevenlabs_rs and https://docs.rs/elevenlabs-sdk

Note: page contents were read through a summarising fetch tool, not raw page text. Quoted phrases are as returned by that tool; the JSON examples in the guides were returned verbatim for the realtime-tts guide and as reconstructed one-liners for the multi-context guide (the tool refused to reproduce the full code). Verify exact examples against the live pages when implementing the contract fixtures.

Fact-checked 2026-10-08: about 85 claims checked, 15 corrected. Corrections: (1) default MP3 attribution moved from the realtime-tts guide (states no default) to the stream-input API reference; (2) multi-context "Each context times out after 20 seconds..." quote replaced by the real guide wording; (3) interruption quote replaced by the real guide sentence; (4) "voice-focused design" quote replaced by "to reflect this" / "optimized for voice applications", and the unconfirmed "no unrelated parallel streams" claim marked UNVERIFIED; (5) prosody quote corrected; (6) keep-alive spec quote corrected; (7) auto_mode concept-page quote corrected; (8) "at the end of each conversation turn" corrected to "at the end of conversation turn"; (9) alignment "word-level" quote corrected; (10) claim that the multi-context guide says it is unavailable for eleven_v3 removed (guide does not address it); (11) *.residency.* hosts: latency guide says Enterprise customers get EU and India residency environments; (12) added internal_error (500) to the errors list; (13) Python SDK page does show HTTP stream TTS and a Speech Engine WebSocket server (earlier text said no WebSocket mention); (14) Rust crates: added versions, licenses, repos, ownership (elevenlabs-sdk is Apache-2.0 0.1.0 by Akagi201; elevenlabs_rs 0.7.1 unofficial); (15) TTD facts added (endpoint, snake_case, voice limits; multi-context TTD page exists) and InitialiseContext "Creates a context with this ID, or re-initializes..." wording marked UNVERIFIED (not found in the re-fetch).

Live check, Turkish (2026-10-13)

Docs re-fetched 2026-10-13 (https://elevenlabs.io/docs/overview/models): eleven_flash_v2_5, eleven_multilingual_v2 and eleven_v3 all list Turkish (tr).

Measured (live_checks.rs, eleven_flash_v2_5, language_code=tr, output_format=pcm_24000, sync_alignment=false):

  • Open item 1 (Turkish alignment) SETTLED: alignment arrives for Turkish and the words keep ç ğ ı İ ö ş ü; starts monotonic, inside the audio (3.45 s of 4.39 s). Open item 4 (pcm_* encoding): pcm_24000 is signed 16-bit little-endian mono without a header (the audio is intelligible and Deepgram recovers the text).
  • First audio 135-200 ms. Round trip through Deepgram: WER 0.000 on sentence 2, 0.000-0.154 on the number sentence (raw 250 lira ... 14.30'da is read correctly: iki yüz elli, on dört otuz), 0.167 on the short question (an STT error, same as for Cartesia).
  • One-word first chunk: English split pause 200 ms vs 170 ms for the single chunk; Turkish split file has a 210-230 ms pause after Tamam, that the single file lacks (to be judged by ear); no pacer underrun.
  • Still open: items 2, 3, 5, 6, 7 of the list above.

Deepgram Aura — TTS (WebSocket /v1/speak)

Scope: WebSocket streaming TTS ( flush/clear, word timing if offered, cancel, Turkish recorded). Researched: 2026-10-08. Every fact below was read from the pages listed under Sources on that date (the fetch tool summarizes pages, so quotes are short; JSON blocks are as shown on the pages unless marked "illustrative"). Anything not confirmed is marked UNVERIFIED.

Headline findings for the implementer:

  • Aura (/v1/speak) has NO contexts, NO word timestamps and NO Turkish voice. It is one sequential text buffer per connection. Cancel = Clear, whose acknowledgement (Cleared) is delayed behind already-generated audio, so brisvox must drop audio client-side from the moment it sends Clear.

  • Deepgram's newer model family "Flux TTS" (/v2/speak) is described as "Recommended for all new builds", has real interrupt semantics (text_spoken / text_remaining), but is English-only today. See the Flux section; it does not change the Turkish answer.

  • Flush is limited to 20 per 60 s and the Flush page warns "Very frequent flushes can affect audio output quality": flush once per bot turn, never per sentence.

  • Docs: https://developers.deepgram.com/reference/text-to-speech/speak-streaming (fetched 2026-10-08); full source list at the bottom.

  • Auth:

    • HTTP handshake header Authorization: Token <DEEPGRAM_API_KEY>. The authentication guide documents exactly Authorization: Token YOUR_DEEPGRAM_API_KEY; the "Sending LLM outputs to a WebSocket" page shows a raw Python WebSocket client against /v1/speak passing additional_headers={"Authorization": f"Token {DEFAULT_TOKEN}"}. The speak-streaming reference only lists "an Authorization header" (type string) with no prefix.
    • Temporary/short-lived tokens exist (Auth API, /reference/auth/tokens/grant); whether they are accepted on the /v1/speak WebSocket, and with which prefix (Bearer), is not stated on any fetched page (the authenticating guide gives no Bearer format and does not mention WebSocket auth): UNVERIFIED.
    • Browser subprotocol/query-string auth: not documented on the fetched pages: UNVERIFIED. Brisvox is server side: use the header, never put the key in the URL, never log it (Error variants must not contain credentials).
    • Failed upgrade: HTTP 4xx/5xx with response headers dg-request-id (included on every request, success or failure) and dg-error (only on a failed upgrade; detailed reason, also in the response body) (TTS troubleshooting page). HTTP error table (errors page): 401 INVALID_AUTH ("Invalid credentials."); 401 INSUFFICIENT_PERMISSIONS ("User does not have sufficient permissions."); 403 INSUFFICIENT_PERMISSIONS ("Project does not have access to the requested model."). Map these to Rejected { status }. That these exact bodies occur on the WS upgrade (rather than only on REST) is UNVERIFIED.
  • Endpoint(s) / regions:

    • wss://api.deepgram.com/v1/speak (AsyncAPI server wss://api.deepgram.com/, channel /v1/speak). Example URL from the docs: wss://api.deepgram.com/v1/speak?model=aura-2-thalia-en&encoding=linear16&sample_rate=48000.

    • Regions: the latency page says "Deepgram's servers are only in the United States" (non-US clients see more network latency); self-hosted is the documented alternative. No regional hostnames found: UNVERIFIED that none exist (the concurrency table does distinguish "North America" vs "Europe/Australia/India" for Growth; see Limits).

    • Query parameters (speak-streaming reference):

      NameTypeAllowed valuesDefault
      modelstringenum of Aura-1, Aura-2 English and Aura-2 multilingual voices (see Models)aura-asteria-en
      encodingstringlinear16, mulaw, alaw (streaming only; compressed formats are REST-only)linear16
      sample_ratestring8000, 16000, 24000, 32000, 48000 (per encoding, see below)24000
      speednumber0.7 .. 1.5 ("Not yet supported in all languages")1
      mip_opt_outanyschema type "Any type"false
    • Encoding/sample-rate combinations (media-output-settings page, "Audio Format Combinations"): linear16 8000, 16000, 24000 (default), 32000, 48000; mulaw 8000 (default), 16000; alaw 8000 (default), 16000. Streaming default (page note): linear16, no container, 24000 Hz. "Currently we only support linear16, mulaw, and alaw for our Streaming TTS Websocket." Sample rate does not affect latency (latency page).

    • Containers: the TTS WebSocket "does not support containerized audio formats" (handling-audio-issues page): the stream is raw samples (no WAV header; clients who need playback must prepend their own header). The page also recommends container=none for streaming to an output device (clicks/static), but container is not a documented query parameter of the streaming reference: whether it is accepted/ignored/rejected on /v1/speak is UNVERIFIED; do not send it.

    • Brisvox TtsConfig = PCM16 LE mono at a SampleRate: encoding=linear16&sample_rate=<rate> (8000/16000/24000/32000/48000 available). For G.711 telephony mulaw/alaw at 8000 are available directly; the events then carry G.711 bytes, not PCM16 (caller must know). Whether a rate the brisvox SampleRate type offers is outside this list: check at construction.

  • Models (current):

    • Docs recommend Flux TTS for all new builds (English only) and Aura-2 where Flux does not cover the language; Aura (Aura-1) is first generation, English only (models-languages-overview). Model string formats: Flux flux-{voice}-en (/v2/speak; the overview page writes flux-{voice}-{language}), Aura-2 aura-2-{voice}-{language} (/v1/speak), Aura aura-{voice}-en (/v1/speak). Aura voices run only on /v1/speak, Flux voices only on /v2/speak (migration page).
    • Aura-2 voices (reference enum, matches tts-models page; 90 voices): English 41 (amalthea, andromeda, apollo, arcas, aries, asteria, athena, atlas, aurora, callista, cora, cordelia, delia, draco, electra, harmonia, helena, hera, hermes, hyperion, iris, janus, juno, jupiter, luna, mars, minerva, neptune, odysseus, ophelia, orion, orpheus, pandora, phoebe, pluto, saturn, selene, thalia, theia, vesta, zeus; e.g. aura-2-thalia-en); Spanish 17 (agustina, alvaro, antonia, aquila, carina, celeste, diana, estrella, gloria, javier, luciano, nestor, olivia, selena, silvia, sirio, valerio); German 7 (aurelia, elara, fabian, julius, kara, lara, viktoria); French 2 (agathe, hector); Dutch 9 (beatrix, cornelia, daphne, hestia, lars, leda, rhea, roman, sander); Italian 9 (cesare, cinzia, demetra, dionisio, elio, flavio, livia, maia, melia); Japanese 5 (ama, ebisu, fujin, izanami, uzume). Spanish voices Aquila, Carina, Diana, Javier, Selena support English-Spanish codeswitching. A remark that Deepgram is "constantly adding additional language support" was not re-found on the fetched pages: UNVERIFIED.
    • Aura-1 voices (English, aura-<name>-en): angus, arcas, asteria, athena, helios, hera, luna, orion, orpheus, perseus, stella, zeus. No deprecation notice found on the tts-models page.
    • Recommended default for brisvox: aura-2-thalia-en (the model used throughout the docs' examples), configurable. The voice id in TtsConfig.voice is the model string (it carries voice and language together); voice and output settings cannot change after connecting ("one WebSocket per conversation", getting-started page).
  • Protocol (messages in/out, with field names):

    • All control messages are JSON text frames; audio is binary frames of raw samples in the requested encoding. There are NO context ids: a connection has one text buffer and one audio stream.
    • Client -> server (speak-streaming reference):
      {"type": "Speak", "text": "<input text>"}
      {"type": "Flush"}
      {"type": "Clear"}
      {"type": "Close"}
      
      Speak: required type, text. Behavior of Speak without a Flush (when does the server start generating?): NOT documented (the Flush page only says that in some real-time cases you may need to "force the server to process all (or flush) unprocessed speech-to-text data immediately" [sic, the page says speech-to-text], implying some automatic processing; trigger unknown): UNVERIFIED. Whether the server inserts whitespace between consecutive Speak texts is documented only for Flux ("The server doesn't add whitespace between messages"; the migration page says v1 and v2 differ here, without saying what v1 does): UNVERIFIED for Aura; keep leading/trailing spaces in the text you send so concatenation is valid.
    • Server -> client (speak-streaming reference):
      {"type": "Metadata", "request_id": "<uuid>", "model_name": "<string>", "model_version": "<string>",
       "model_uuid": "<uuid>", "additional_model_uuids": ["<uuid>"]}
      {"type": "Flushed", "sequence_id": 0}
      {"type": "Cleared", "sequence_id": 0}
      {"type": "Warning", "description": "<string>", "code": "<string>"}
      
      (JSON reformatted onto fewer lines; the angle-bracket values are the schema's placeholders. Metadata requires type, request_id, model_name, model_version, model_uuid; additional_model_uuids optional. Flushed/Cleared require type, sequence_id (integer). Warning requires type, description, code.) Binary audio: "Receive audio chunks as they are generated", no JSON wrapper. Schema quirk: the spec's type enum for Flushed and Cleared lists both values; use the per-message values.
    • sequence_id semantics (a counter of flushes/clears? per connection? starts at 0?): only 0 appears in examples; meaning is not documented: UNVERIFIED. Do not rely on it for routing; treat as opaque and log it.
    • When Metadata is sent: the migration page says v1 "sends Metadata" on open (v2 sends Connected); exact timing relative to the first audio is UNVERIFIED. Handle it at any time and ignore unknown message types.
    • Warning code values: no list found on any fetched Aura page (the only listed codes are Flux's, below). Treat unknown codes as non-fatal (the Flush page says that past the flush limit "you will receive a warning message"). Warning is not an error: do not end the session.
    • Ordering/termination:
      • After Flush the server processes the remaining text, returns the final audio, and confirms with a Flushed message (Flush page; the exact "It confirms with" wording was not re-found verbatim). Read as: all audio for text sent before the flush arrives before Flushed. A strict "all binary frames of that text precede Flushed" guarantee is not worded as a guarantee: UNVERIFIED (verify live; the adapter should fail safe by treating Flushed as the end marker and discarding audio that arrives after it only if a later Flush/Clear demands it).
      • After Clear the server "will stop sending new audio chunks as soon as possible"; it "will process all remaining audio data" before it sends Cleared (Clear page). So audio frames CAN still arrive between sending Clear and receiving Cleared.
      • Binary frames carry no marker saying which flush/utterance they belong to; attribution is purely by order. Therefore brisvox can run only ONE active utterance per connection at a time (see mapping).
      • Alignment of binary frames to whole PCM16 samples is not documented (chunk alignment/odd bytes "not covered"): UNVERIFIED; the adapter should re-align odd-sized frames across frame boundaries (as the Cartesia adapter does).
    • Close: a client may send a WebSocket Close frame or the optional {"type":"Close"} message; the server then closes the connection and reports status code 1000 (Normal Closure, "Connection was closed successfully.") (Close page). The reference describes Close as "Flush the buffer and close the connection gracefully after all audio is generated", while the Close page's conclusion says it "ensures the server halts processing and closes the connection promptly" and the page does not address buffered text. The two descriptions conflict on whether buffered text is synthesized: UNVERIFIED. For end-of-turn use Flush and wait for Flushed; use Close only at session end.
  • Keepalive / timeouts:

    • Session limit: an active WebSocket has "a 60-minute timeout period from the initial connection" (even when active); open a new connection for longer sessions (getting-started page). Brisvox: calls longer than 1 h need a transparent reconnect between utterances.
    • No KeepAlive message exists in the v1 speak schema and none is mentioned (reference: KeepAlive "not mentioned"). Idle timeout for /v1/speak: not documented. A close code 1011 NET-0001 ("The server failed to receive a message", troubleshooting page; the Flux page words the same code "Failed to receive message") exists but whether it is an idle timeout, its duration, and whether a WebSocket ping frame resets it, are UNVERIFIED. Flux (v2) documents a 60 s inactivity timeout (NET-0004) reset by a WebSocket Ping or Pong; do not assume the same for v1. Brisvox rule: if the socket is closed when the next text arrives, reconnect lazily; warm() can pre-open a socket.
    • No documented connect timeout; brisvox applies its own (explicit timeout on every await).
  • Finalize / flush / cancel semantics:

    • End of an utterance (maps to TtsInput::Flush): send {"type":"Flush"} after the last Speak. The docs state: "It is required that the last fragment of speech be Flushed when the LLM is at the end of the LLM response" (send-LLM-outputs page). Server returns remaining audio, then Flushed. Emit TtsEvent::Flushed when Flushed arrives.
    • Flush limits: at most 20 Flush messages every 60 s; beyond that "you will receive a warning message stating that we cannot process any more flush messages" until the 60-second time window has passed (Flush page, getting-started page). So a flush swallowed by the limit never yields Flushed: the adapter must not wait forever (stall timeout) and should count its own flushes (rate guard). "Very frequent flushes can affect audio output quality." Whether Clear counts toward the 20: UNVERIFIED.
    • Cancel (barge-in; maps to TtsSession::cancel): send {"type":"Clear"}. The server resets its text buffer, stops sending new audio as soon as possible, and replies Cleared after processing remaining audio data. Pending unspoken text in the buffer is discarded (the reference calls Clear a "Potentially destructive operation for any text in the buffer"). Brisvox must therefore: (a) send Clear, (b) from that moment drop every binary frame and Flushed that arrives for the cancelled utterance (the consumer already treats the epoch as dead), (c) emit TtsEvent::Cancelled itself immediately without waiting for Cleared, (d) resume mapping new audio to the next utterance only after Cleared has arrived (frames before Cleared are the cancelled utterance's tail), or alternatively close the socket and open a new one (v1 migration page: "v1 requires reconnecting the WebSocket" to reset context; a reconnect costs TCP+TLS+handshake, so prefer Clear). Text sent for the NEXT utterance after Clear but before Cleared: whether it is kept or cleared is UNVERIFIED; hold the next utterance's text until Cleared arrives (costs one round trip, avoids the ambiguity).
    • Clear when nothing is queued: behavior not documented (presumably still Cleared): UNVERIFIED.
    • The reference says Clear "Clear the buffer and start a new audio generation"; the Clear page does not say anything about it. No context state is preserved (v1 has no cross-turn prosody state; Flux does).
  • Timestamps / interim results / tool-call deltas:

    • Word, character or any timestamps are NOT offered on /v1/speak: the server messages are binary audio, Metadata, Flushed, Cleared, Warning only, and Metadata has no timing data (reference page, checked). TtsEvent::Word therefore cannot come from the provider. Played-text options (design choices, not doc claims): (1) emit no Word events and let the consumer fall back to an estimate; (2) approximate from text length and the exact audio duration derived from the received bytes (bytes / (rate * 2) for linear16) at Flushed, with the estimate clearly marked as such. Per-word precision is impossible; interruption truncation accuracy will be coarse (per utterance/segment). The adapter documents this ("word timing if offered, else documented").
    • Flux TTS (v2) does not give word timestamps either (checked: only audio_duration_ms per turn, audio_played_ms on interrupt, and total_audio_duration_ms), but it reconciles interruptions server-side (text_spoken).
  • Concurrency / rate limits (api-rate-limits page, "Rate limits apply per project, not per account or API key"):

    PlanREST (Aura / Aura-2)Streaming (Aura / Aura-2)
    Pay as You Go1545
    Growth1560 in North America, 45 in Europe/Australia/India
    Enterprisestarting at 25Aura: starting at 150; Aura-2: starting at 100
    • Secondary projects of a self-serve account are limited to one concurrent stream. Exceeding concurrency returns HTTP 429 ("This error indicates that your project has more concurrent requests than allowed", concurrency guide; the guide does not single out TTS or streaming); docs recommend exponential backoff retry; Pay As You Go and Growth limits cannot be raised, Enterprise can (concurrency guide). The getting-started page also says that new requests get a 429 when in-progress requests reach the project limit. Whether a streaming "concurrent request" is counted per WebSocket connection or per active generation: not stated: UNVERIFIED (assume per connection). For the 429 on the WS upgrade, the handshake fails with an HTTP status: map to Rejected { status: 429 } (that a 429 is delivered on the WS upgrade specifically is UNVERIFIED).

    • Brisvox: one WebSocket per call/session (docs: "a single websocket per conversation is required"); at 45 streams on Pay As You Go, a container with hundreds of calls needs a Growth/Enterprise plan or multiple projects (note: spreading traffic across multiple projects "violates our Terms of Service" per the rate-limits page; secondary projects get 1 concurrent stream anyway).

    • Text limits (getting-started page): up to 2000 characters per request for Aura-2 and Aura-1 (longer input errors with 413 and "no audio file is created"); 422 "Unprocessable Content" can be returned "if the client fails to send the request successfully"; character throughput "2400 characters per minute, measured by characters sent over the WebSocket"; flush limit 20/60 s; connection limit 60 min. Whether 2000 applies per Speak message or per flush/turn or per connection request is not stated ("per request"): UNVERIFIED (the latency page also says "2000 characters per request"; the troubleshooting table has 1009 BIG-0001 "The input text has too many characters" and 1009 BIG-0000 "The input message is too large", with a 2MB request body limit for REST per the errors page). Safe rule for the adapter: split any single Speak text to <= ~1000 characters at sentence/space boundaries (a design choice). The 2400 chars/min throttle is believed to be the cause of 1008 DATA-0001 ("Too many characters were submitted within a recent time window"; the link between the two is an inference: UNVERIFIED), so the adapter should rate-account characters sent (a single 1000-character reply per 25 s average is far below typical speech, but bursty LLM output can exceed it: measure live).

    • Chunking guidance (tts-text-chunking page, applies to /v1/speak): split at sentence ends; suggested sizes 50-100 characters for voice assistants, complete sentences for call-center bots, 200-400 characters for long-form. For Flux the server places flush boundaries, no client chunking.

    • Pricing: https://deepgram.com/pricing (read 2026-10-08): per 1K characters, Pay As You Go / Growth: Aura-1 $0.0150 / $0.0135; Aura-2 $0.030 / $0.027; Flux TTS $0.0450 / $0.0405 (Flux: promotion through 2026-12-31, each $1 spent on Flux TTS adds $1 credit, up to $500, Pay As You Go and Growth). Characters are counted by characters sent (Flux reports billable_character_count; for Aura no usage message exists).

    • Error close codes (TTS troubleshooting page; the close "payload"/reason is the code string; message text paraphrased):

      Close codePayloadMeaning
      1003MESSAGE-0000message type not supported (send only text messages)
      1008DATA-0000message is not a recognized command
      1008DATA-0001too many characters submitted within a recent time window
      1009BIG-0000message too large
      1009BIG-0001text exceeds the character limit
      1011NET-0000internal server error (retry)
      1011NET-0001the server failed to receive a message
      1011NET-0002server failed to send a message to the client
      1011NET-0003time limit exceeded

      Map: 1008/1009/1003 -> Protocol (with code and payload; never credentials); 1011 NET-0000/0002 -> retryable Disconnected; 1011 NET-0003 (also the 60-min limit? UNVERIFIED which code the 60 min limit uses on v1; Flux uses NET-0003 for its 1 h session limit) -> reconnect; a close while output is owed -> Disconnected. Whether an error JSON frame precedes the close on v1: not documented (the page shows no error JSON bodies): UNVERIFIED. The reference's Warning is the only documented JSON error-like frame. ("retry" on NET-0000 and "send only text messages" are paraphrases not confirmed verbatim.)

  • Turkish support: NO. Aura-2 languages are English, Spanish, German, French, Dutch, Italian, Japanese (tts-models page and the models-languages-overview table: Turkish is not listed for Flux TTS, Aura-2 or Aura; not mentioned as planned). The reference model enum has no Turkish voice (suffixes only -en -es -de -nl -fr -it -ja). Flux TTS voices are English only. A web search on 2026-10-08 (repeated during the fact-check) found no evidence of Turkish either (Deepgram supports Turkish for speech-to-text only). Consequence for brisvox: this adapter is NOT usable for Turkish calls; the engine's Turkish TTS must be Cartesia (Sonic 3.x includes tr; not re-verified in this fact-check, see cartesia.md) or another provider. Documented as: "Turkish: not supported by Deepgram TTS as of 2026-10-08; adapter ships for the supported languages, tests use English voices". Using an English voice for Turkish text produces unintelligible/accented output; not tested: UNVERIFIED but do not do it.

  • Flux TTS (/v2/speak) — noted, NOT the scope of this adapter but the likely better v2 adapter for English (docs: "Recommended for all new builds"):

    • wss://api.deepgram.com/v2/speak?model=flux-haley-en, header Authorization: Token ... (Flux quickstart). Params: model (required, flux-*), encoding (linear16 default, mulaw, alaw), sample_rate (linear16 8000, 16000, 24000, 32000, 44100, 48000; mulaw/alaw 8000 or 16000), speed 0.5-1.5 step 0.05, expressivity (beta, integer -2..2, default 0), mip_opt_out, tag (repeatable); unknown and batch-only params (container, bit_rate, callback, callback_method, priority) are rejected. Voices: 13 featured + 23 additional (36), all flux-{voice}-en.
    • Client messages: {"type":"Speak","text":...}, {"type":"Flush"} (ends the turn), {"type":"Interrupt","playback_offset":{"type":"time_ms","value":2340}} (offset measured from the start of the session's audio, must advance past the previous interrupt's offset; without it the text_spoken/text_remaining split is omitted; the example value 2340 is illustrative), {"type":"Configure","speed":1.15}, {"type":"Close"}. Server: Connected {request_id, model_name, model_version, model_uuids}, SpeechStarted {speech_id}, SpeechMetadata {audio_duration_ms, input_character_count, billable_character_count, controls_applied}, SpeechInterrupted {audio_played_ms, text_spoken, text_remaining, metadata}, Flushed {speech_id}, SessionMetadata {total_audio_duration_ms, total_input_character_count, total_billable_character_count}, ConfigureSuccess, ConfigureFailure, Warning {code, description} (non-fatal; e.g. NO_ACTIVE_SPEECH, NO_SYNTHESIZABLE_TEXT, SYNTHESIS_RETRYING, PRONUNCIATION_WARNINGS, INTERRUPT_IN_PROGRESS, NO_AUDIO_GENERATED, INVALID_INTERRUPT_OFFSET, plus several markup/pronunciation/break codes), Error {code, description} (fatal; the socket then closes; codes MESSAGE-0000, DATA-0000, DATA-0002, BIG-0000, NET-0000..NET-0004). 60 s inactivity timeout (NET-0004; WebSocket Ping or Pong resets it), 1 h session limit (NET-0003). Interrupt does not halt audio already on the wire: the interrupt-handling page says to stop playback locally, now (the Interrupt round trip is for context reconciliation), and frames arriving between sending Interrupt and SpeechInterrupted "were already on the wire. Drop them."
    • GA/beta status of Flux TTS: not stated on the fetched pages (only expressivity and inline pronunciation are labelled beta/early access): UNVERIFIED. A follow-up adapter is the better route than extending this one.
  • Mapping to the brisvox trait (crates/brisvox-providers/src/tts.rs) - proposal:

    • One connection per TtsSession; query model=<TtsConfig.voice>&encoding=linear16&sample_rate=<TtsConfig.sample_rate>; header Authorization: Token <key> (mark sensitive). ws:// only for loopback tests (as the Cartesia adapter does).
    • Single-flight utterances: the provider has no ids, so the adapter keeps current: Option<ContextId> and a FIFO of utterances awaiting Flushed. Text{ctx} -> Speak (if a different context's text arrives while another is unflushed, queue it until that context's Flushed; the engine normally flushes a turn before starting the next). Flush{ctx} -> Flush; Flushed -> TtsEvent::Flushed{ctx} for the oldest flushed context. Binary frames -> TtsEvent::Audio{ctx, ..} for the context at the head of the FIFO.
    • Pipelining the next utterance's text before the previous Flushed: Aura could generate it contiguously, but the attribution of frames to contexts would then rest only on counting Flusheds; this is allowed by the protocol description but UNVERIFIED (ordering). Safer first version: one in-flight flush at a time.
    • cancel -> Clear; local forget + TtsEvent::Cancelled{ctx} at once (before any socket I/O completes, cancel polled with priority over input); drop frames until Cleared; a cancel for an already-flushed (finished) or unknown context still yields Cancelled; a Clear for a context with nothing sent is not sent. Text for cancelled ids is ignored (remember last 64 ids).
    • Words: none from the provider (see Timestamps). Do not emit invented Word events as if provider-supplied unless explicitly decided (and record the decision in the design docs).
    • Flush rate guard: refuse to send more than ~20 Flush/60 s (error Protocol or merge flushes); stall deadline only while a Flushed is owed.
    • Reconnect: socket closed while idle -> lazy reconnect on next Text; 60 min limit -> same.
  • Gotchas found during implementation (adapter brisvox_providers::deepgram_aura; all verified against a local mock only, nothing live yet):

    • The mock-free reading of the docs is implemented as: one FIFO of engine contexts, only the head on the wire; Clear on cancel with local-first Cancelled, frames and Flushed dropped until Cleared, the next context's text held until then; a Cleared that does not arrive within io_timeout of the last frame is given up on with a warning (UNVERIFIED #2 above: if the server does not answer a Clear on an empty buffer this costs one deadline once; if it answers late, a stale Cleared can be matched to a later Clear). A Clear is only sent when the server may hold text (written on the current connection, unflushed or a Flushed owed), so the empty-buffer case is not provoked by a cancel during a reconnect or of an answered context. A Flushed that follows Cleared directly (ordering UNVERIFIED, open item 1) would be taken for the answer to the next utterance's flush if that was already sent.
    • Metadata may arrive on a parked (warmed) socket before anything is sent: the "is it still open?" probe consumes benign text frames instead of treating them as a dead socket.
    • Fault injection counts TtsEvents, not wire frames: the mock does not count Metadata.
    • Not implemented / left out: mip_opt_out; Flux TTS (/v2/speak); Close is sent only at the end of a session after the input was dropped (the Flush-vs-halt ambiguity of Close does not matter there because everything is flushed first); no keepalive message (no documented idle timeout); no JWT/Bearer auth. A WebSocket Ping is sent only as a liveness probe while unflushed text is on the server and nothing is owed (half an io_timeout after the last sign of life); that Aura answers pings (RFC 6455 says it must) is UNVERIFIED, the live test shows it. If it does not, a turn whose LLM is slower than io_timeout between the first text and the flush ends the session with a Timeout: switch to PerText.
    • Guessed around (UNVERIFIED in the docs): whether Speak without Flush starts generation (both are handled: the mock has an auto switch and FlushPolicy picks the strategy), whether Flushed always follows all frames of its flush (assumed; late frames would be attributed to the next utterance), whether the frames are whole samples (odd ones are re-aligned), whether Clear counts toward the 20 flushes (assumed not), what the 2000-character limit applies to (pieces are cut at 1000 and no flush interval carries more than 1800 characters), which close code ends a 60-minute connection (any close while idle reconnects; the connection is replaced before it at 55 minutes).
    • A 1008 DATA-0001, 1009 or 1003 close is Error::Protocol even while idle (it means we sent something wrong); 1011 and every other close are Disconnected, benign when no text of an unfinished utterance is on the server.
    • Voice ids are checked by their language suffix (the last - or _ separated part, e.g. aura-2-ozge-tr, tr_TR), compared case-insensitively: aura-2-ozge-TR is refused like -tr (review fix). Only the seven languages above pass.
    • Test coverage that pins the behaviour above (review fixes; tests/deepgram_aura.rs, mock in tests/deepgram_aura/mock.rs):
      • Bounded queue: with a consumer that reads nothing, the mock (32 MB of audio, no pacing) is held back by TCP flow control after a bounded amount (at most a quarter of the frames are sent); the adapter stops reading the socket at 64 queued events.
      • Clear: the mock can discard Speak text that arrives between Clear and Cleared (the docs allow it); the barge-in tests send the next utterance immediately and still get complete audio, so the hold until Cleared is proven, not assumed. A connection lost while a Clear is pending resets that state (the next utterance does not wait for the stall deadline).
      • Dead socket: the first Speak of a context written to a socket that was reset (the session was not reading, the consumer was behind) is retried once on a new connection; a server that drops every connection gets exactly that one retry. The mock resets with SO_LINGER 0. The exactly one retry check races the reset of the second connection against the write, so it detects an endless reconnect loop reliably but not on every run of a broken build.
      • Alignment: mu-law/A-law frames pass through byte for byte (each event is one server frame, an odd total keeps its last byte); only PCM16 is re-aligned to whole samples.
      • Limits: the defaults of DeepgramAuraConfig::new (20 flushes, 2400 characters, 60 s, 1000 per Speak, 55 min, 10 s), the flush reserve (4) and the mid-turn threshold (1800) are asserted. Flush intervals are measured in characters from what the server received: exactly 1800 unflushed characters are allowed, 1801 force a flush first. The character budget, the 1000 character split and the 1800 threshold count characters, not bytes (tested with ñ and Japanese).
      • PerText after Flushed: a pause far above io_timeout is neither a timeout nor a ping (nothing is owed); the PerTurn pause test covers the probe path (unflushed text on the server).
      • Idle close codes: 1008/1009/1003 while idle is an error; 1011 and 1000 reconnect lazily.

Open items to settle with a live test

Run with BRISVOX_LIVE_TESTS=1 DEEPGRAM_API_KEY=... (never print the key).

  1. Whether all binary frames of a flush precede Flushed, and whether sequence_id counts flushes/clears (per connection or per kind).
  2. What Clear does to text sent between Clear and Cleared; how much audio arrives after Clear; whether Clear with an empty buffer still answers Cleared.
  3. When the server starts generating without Flush (sentence-boundary auto-trigger?), and whether Speak text needs leading spaces.
  4. Idle timeout of /v1/speak and whether a ping frame resets it; which close code/payload the 60 min limit uses.
  5. The warning code the server sends when the 20 flushes/60 s limit is hit, and whether the connection survives; whether Clear counts.
  6. What the 2000-character limit applies to (per Speak, per flush, per connection) and the exact effect of the 2400 chars/min throttle (1008 DATA-0001).
  7. Time to first audio with linear16 at 16000/24000 and mulaw 8000; whether frames are whole-sample aligned.
  8. Whether container=none or unknown query params are accepted/ignored/rejected; whether a JWT (Bearer) is accepted on /v1/speak.
  9. Whether concurrency (429) is counted per connection or per active generation.
  10. Whether a Turkish text sent to an Aura-2 voice is rejected, spoken badly, or rejected by language (expect: not supported).

Sources (all fetched 2026-10-08)

  • https://developers.deepgram.com/reference/text-to-speech/speak-streaming (and .md) - endpoint, query parameters, messages, model enum
  • https://developers.deepgram.com/docs/tts-websocket.md - intro only
  • https://developers.deepgram.com/docs/streaming-text-to-speech.md - getting started, limits (2000 chars, 2400 chars/min, 20 flush/60 s, 60 min)
  • https://developers.deepgram.com/docs/tts-streaming-feature-overview.md - only links to Flush/Clear/Close pages, no descriptions
  • https://developers.deepgram.com/docs/tts-ws-flush.md, https://developers.deepgram.com/docs/tts-ws-clear.md, https://developers.deepgram.com/docs/tts-ws-close.md
  • https://developers.deepgram.com/docs/tts-websocket-streaming.md - SDK examples (WebSocket)
  • https://developers.deepgram.com/docs/send-llm-outputs-to-the-tts-web-socket.md - raw WebSocket URL and Authorization: Token header example
  • https://developers.deepgram.com/docs/tts-troubleshooting-websocket-net-and-data-errors.md - close codes, dg-request-id/dg-error
  • https://developers.deepgram.com/docs/errors.md - HTTP errors (401/403/413/422/429)
  • https://developers.deepgram.com/reference/api-rate-limits.md and https://developers.deepgram.com/docs/working-with-concurrency-rate-limits.md - concurrency
  • https://developers.deepgram.com/docs/tts-models.md and https://developers.deepgram.com/docs/tts-models-languages-overview.md - voices, languages (Turkish not listed)
  • https://developers.deepgram.com/docs/tts-encoding.md, https://developers.deepgram.com/docs/tts-sample-rate.md, https://developers.deepgram.com/docs/tts-media-output-settings.md
  • https://developers.deepgram.com/docs/handling-audio-issues-in-text-to-speech.md, https://developers.deepgram.com/docs/text-to-speech-latency.md, https://developers.deepgram.com/docs/tts-text-chunking.md
  • https://developers.deepgram.com/guides/fundamentals/authenticating - Authorization: Token scheme (general API)
  • https://developers.deepgram.com/docs/flux-tts/overview.md, /quickstart.md, /client-messages.md, /server-messages.md, /interrupt-handling.md, /migrating.md, /voices.md
  • https://deepgram.com/pricing - TTS prices
  • https://crates.io/api/v1/crates/deepgram - Rust crate version 0.12.0, MIT
  • https://developers.deepgram.com/llms.txt - doc index
  • Web search "Deepgram Aura-2 Turkish language support text-to-speech" (no Turkish evidence)

Fact-checked 2026-10-08: 85 claims checked, 12 corrected. Corrections: (1) Flush-page wording: the "forces the server to process all unprocessed text immediately" quote is actually "force the server to process all (or flush) unprocessed speech-to-text data immediately"; (2) the quality warning is "Very frequent flushes can affect audio output quality" (not "degrade"); (3) close code 1011 NET-0001 means "server failed to receive a message", not "didn't receive a client message in time" (the idle-timeout reading is now UNVERIFIED); (4) Growth non-NA streaming limit applies to Europe/Australia/India, not "elsewhere"; (5) Clear quote: reference says "Potentially destructive operation for any text in the buffer" (not "This can discard buffered text"), and Clear wording "Clear the buffer and start a new audio generation"; (6) Clear page says server "will process all remaining audio data"; (7) Close reference/page quotes corrected ("Flush the buffer and close the connection gracefully after all audio is generated"; page conclusion "ensures the server halts processing and closes the connection promptly"); (8) 429 text comes from the concurrency guide (rate-limits page has none) and is not TTS-specific; (9) the 2000-character limit is worded "per request" on both pages; (10) Flux sample_rate also allows 44100 for linear16, expressivity default 0, and the interrupt-handling quotes were fixed ("Stop local audio playback immediately" is not verbatim); (11) removed unconfirmable quote "constantly adding additional language support" (now UNVERIFIED) and the unconfirmed claim that 1008 DATA-0001 is a hard disconnect caused by the 2400 chars/min throttle (now an inference, UNVERIFIED); (12) pricing promotion details clarified, 413/422/Node-SDK/dg-error details sharpened (dg-error only on failed upgrade). Remaining UNVERIFIED are marked inline (Bearer/JWT on WS, container param, Speak auto-trigger, whitespace on v1, sequence_id, v1 idle timeout, ordering guarantee, per-Speak 2000 scope, Flux GA status, Cartesia tr not re-checked here).

Live check, prosody (2026-10-13)

Per the 2026-10-08 model list (not re-fetched today) Aura-2 has no Turkish voice, so Aura is checked in English only (aura-2-thalia-en, linear16 24 kHz, FlushPolicy::PerTurn). Open item 10 (Turkish text) not run.

  • Open item 3 (does generation start without a Flush): for a complete sentence yes (earlier live test); for a one-word chunk Okay, NO: the first audio arrived 413-418 ms after the first text, i.e. only after the second chunk (150 ms later) and the flush, against 257-271 ms for the same answer as one chunk. A one-word first chunk gives no first-audio gain on Aura and delays it by about the inter-chunk time.
  • Silence between Okay, and the rest: 290 ms in one of two runs (three 80-100 ms pauses in the other); the single chunk had 90 ms and a 260 ms lead-in. No word timestamps exist, so the position is measured from the PCM.
  • No pacer underrun (the one-word burst is longer than the wait).

OpenAI-compatible speech (local TTS servers)

Facts fetched 2026-10-14 (URLs under "Sources"). The pages were read through a summarising fetch tool: field lists and quoted strings are its output, not verbatim wire captures. Status. The sections up to "UNVERIFIED / open items" were written from documentation before any server ran, and their UNVERIFIED marks stand unless a later section says otherwise. The sections "Live" and "Cold first request and the model warm-up" report what was run on 2026-10-14: Kokoro-FastAPI (the kokoro preset; first audio 50-164 ms on an RTX 5070) and Chatterbox-TTS-Server (Turkish, about 3 s to the first byte, not streamed). Speaches and vLLM-Omni have not been run, and the Docker images named in "Try it" were not used for those runs (a source checkout was). Code: brisvox_providers::openai_speech (feature provider-openai-speech); config name provider = "openai_speech".

  • Docs: see "Sources".
  • Auth: none for the local servers read (Kokoro-FastAPI: "The API key can be any placeholder string, since the local server doesn't check it"); an optional Authorization: Bearer <key> is supported by the adapter for gateways and Speaches (which has optional auth).
  • Endpoint: POST {base_url}/audio/speech, base_url carries the /v1 prefix.
  • Protocol: HTTP request/response; the response body is raw audio, chunked.
  • Keepalive / timeouts: none documented; the adapter applies its own (below).
  • Finalize / flush / cancel: one request per text piece; cancel = abandon the HTTP response.
  • Timestamps / interim results: none requested (see "Words").
  • Concurrency / rate limits: not documented by any server read; Kokoro-FastAPI handles requests concurrently on one model (README), throughput unmeasured.
  • Turkish: Kokoro has none; Chatterbox Multilingual has (see local-speech.md section 4).
  • Gotchas: see "Per-server facts" (Chatterbox has no pcm, no streaming; Speaches PCM carries no rate).

The protocol

OpenAI text-to-speech guide (developers.openai.com/api/docs/guides/text-to-speech, via local-speech.md section 1): body {model, input, voice, response_format, speed, ...}; pcm is "raw 24kHz, 16-bit signed samples, little-endian ... without the header"; the response streams chunked. The whole text is one request, so incremental text-in is done the only way the protocol allows: one request per segment.

Request the adapter sends (Content-Type: application/json, key order not significant):

{"model":"kokoro","input":"Hello there.","voice":"af_heart","response_format":"pcm","stream":true}

speed only when configured; stream: true only for the Kokoro preset; sample_rate only for the Speaches preset (the session's rate); extra_body fields on top (the adapter's own keys cannot be overridden).

Per-server facts

ServerRoute and fieldspcm?Streams?Sample rateErrorsCancel
Kokoro-FastAPIPOST /v1/audio/speech (/v1 from the mount; router openai_compatible.py). OpenAISpeechRequest: model (default "kokoro"; accepted: tts-1, tts-1-hd, kokoro, gpt-4o-mini-tts, anything else is 400 invalid_model), input, voice (default from settings; +/weight combinations), response_format mp3 (default) / opus / aac / flac / wav / pcm, speed 0.25-4.0, stream (default True), lang_code, volume_multiplier, normalization_optionsyes, media type audio/pcmyes: StreamingResponse, headers Transfer-Encoding: chunked, X-Accel-Buffering: no, Cache-Control: no-cache; one body chunk per generated audio chunk, empty ones skipped24 kHz (StreamingAudioWriter(.., sample_rate=24000)); README's PCM example: 16-bit mono 24000 Hzbefore streaming: 400 invalid_model / validation_error (voice, tags, pause budget, "speakable" check), 403 voice tags disabled, 500 processing_error; after the first byte the status cannot changestream_audio_chunks checks client_request.is_disconnected before each chunk and stops; generators are closed
SpeachesPOST /v1/audio/speech: model (required), input, voice (required), response_format (pcm, mp3, wav, flac, opus, aac), speed (model dependent; 422 when unsupported), stream_format "audio" (default) or "sse", sample_rate (int, "If omitted, the model's default sample rate is used")yes, audio/pcmalways a StreamingResponsemodel default unless sample_rate is sent (the adapter sends the session rate)422 for unsupported speed; others not shownnot documented
Chatterbox-TTS-Server (devnen, port 8004 in the README examples)POST /v1/audio/speech, handler openai_speech_endpoint: model (required, unused), input, voice (required), response_format only wav (default), opus, mp3, speed, seed, language. stream does not exist on this model (it belongs to /tts)no (validation rejects it, status not shown; FastAPI usually 422)no: the audio is synthesized, stitched and encoded first, then sent as a StreamingResponse over an in-memory bufferfrom config (get_audio_sample_rate()); the config value was not on the page (UNVERIFIED, expected 24000 = the model's native rate)400 invalid voice, 404 voice file not found, 503 model not loaded, 500 engine errors (the handler's except Exception also turns its own 400/404 raised inside the try into 500 with the old status in the detail)not applicable (nothing streams); the connection close does not stop synthesis
vLLM-Omni (Voxtral TTS)POST {BASE_URL}/audio/speech {input, model, response_format, voice} (local-speech.md section 3)yesyes (card)24 kHznot readnot read

voice meaning differs: a Kokoro voice name (af_heart; combinations like af_bella(2)+af_sky(1) are Kokoro's), a Chatterbox file name from its voices directory (GET /v1/audio/voices lists them; the preset's example Emily.wav is UNVERIFIED), a Speaches voice of the loaded model.

Presets

OpenAiSpeechConfig::preset / [tts.openai_speech] preset; none has a default base URL.

Presetmodelformatother
genericrequiredpcmnothing extra
kokorokokoropcmstream: true; example voice af_heart
chatterboxchatterboxwavheader read, rate checked; one whole segment per response
speachesspeaches-ai/Kokoro-82M-v1.0-ONNXpcmsample_rate = session rate (any rate)
vllm_omnimistralai/Voxtral-4B-TTS-2603pcmweights non-commercial; set model to what the server serves

Design

  • No connection at connect. The HTTP client comes from the shared pool (net::shared_client, keyed by origin); warm() is an unauthenticated HEAD that parks a connection in it. A refusal or an unreachable server therefore shows up as TtsEvent::Error after the first text, and the session ends (the engine reconnects at its next round). To let the contract suite judge that, TtsHarness got faults_at_connect() (default true); this adapter says false, and the checks then demand the same errors as events (Rejected { status }, Core(Timeout) at the deadline) after the first text.

  • One request per text piece, sequential. The TTS stage sends the segmenter's chunks as Text and a Flush per response. Jobs run FIFO across contexts, so audio is never interleaved and a context that never flushes holds nothing back. While a request is in flight, pending pieces of the same context are joined (up to coalesce_chars, default 300) so a slow server does not fall behind with one request per clause; the first piece of an utterance always goes out alone and at once. Flush is answered when every earlier piece has been received; it triggers no request. Not done: sending request N+1 before N ends (it would buffer audio and double GPU load); a streaming server finishes a segment faster than real time, so the first-byte latency of the next request hides behind the previous segment's playback.

  • Audio. Body chunks become Audio events as they arrive, cut to whole samples (the one trailing byte of an odd chunk waits for the next; a copy only then). pcm and a wav data section are PCM16 mono; rate must be 24000 (or the Speaches preset's sample_rate): connect refuses another, the WAV header's rate is checked against the session, and the server config rejects tts.sample_rate != 24000 at load. The engine resamples to the call.

  • Words. None requested: no Word events, so played-text truncation after a barge-in uses the engine's estimate from text length and audio duration (as for Deepgram Aura). Kokoro-FastAPI's /dev/captioned_speech returns JSON items {audio (base64), audio_format, timestamps: [..]} per chunk (development.py, CaptionedSpeechResponse), but the pages read do not show how the items are framed on the wire (newline-delimited? JSONStreamingResponse is in a module not read), nor the WordTimestamp fields, and the route is under /dev. Left as a follow-up that needs a live server: then add format = "captioned" parsing base64 audio and mapping timestamps onto Word.

  • Cancel. Served before everything (also during send and body read): the context's queued text and undelivered events are dropped, Cancelled is queued at once, the response is dropped (RST_STREAM on HTTP/2, connection close on HTTP/1.1; the next request opens a new connection). Input for a cancelled context is ignored afterwards.

  • Refusals. Non-2xx ends the session with Rejected { status } and the body is not read, except 400 and 422: those refuse one piece (Kokoro-FastAPI 400s a segment with nothing speakable), which is skipped with a warning; two in a row end the session. A 200 with a JSON, text or XML content type is Protocol.

  • Timeouts (injected clock): connect_timeout 3 s; first_audio_timeout 15 s from sending to the first audio byte (a CPU Kokoro needs seconds; Chatterbox answers only after synthesizing the whole segment: raise it); idle_timeout 8 s between chunks; max_request_duration 120 s.

  • Backpressure. The body is not read while 8 events wait for the consumer; the input channel is not read while 128 pieces queue; channels are the standard bounded ones; the cancel channel is never gated.

  • Plain HTTP. http:// is allowed for loopback, and for any host while no API key is configured (nothing secret is sent: a GPU box on the LAN works); with a key, https:// or allow_insecure_http. The server config checks the same at load.

  • Model warm-up. warm() (HEAD) is not enough for a GPU server that has idled: the first synthesis after idle is slow (below). warm() therefore also spawns, not awaited (the engine's TTS warm budget is min(connect_timeout, 400 ms), tts.rs warm_provider), one tiny POST with warm_text whose audio is drained and dropped. connect starts the same warm-up (the first call of a process has no voice at warm() time). One is in flight at most; the next starts only after warm_interval (default 60 s, injected clock, one shared state per provider, so many calls do not each fire one); it is bounded by warm_timeout (10 s) and the provider's shutdown token; a failure is logged at debug and does not use up the interval. The voice is the latest session's, else warm_voice. Default on for the kokoro preset ("Hi.", voice af_heart), off for the others: Chatterbox-TTS-Server is not streaming and takes ~3 s of GPU per sentence, so a warm-up per minute would compete with real turns (opt in with warm_text and a long warm_interval_ms); Speaches and vLLM-Omni have no measurement yet. warm_text = "" turns it off.

Config (docs/CONFIG.md)

[tts]
provider = "openai_speech"        # api_key_env is optional for this provider only
voice = "af_heart"

[tts.openai_speech]
preset = "kokoro"
base_url = "http://localhost:8880/v1"
# model, speed (0.25..=4), format ("pcm"|"wav"), first_audio_timeout_ms (500..=120000)
# warm_text = "Hi."        # background model warm-up (kokoro preset default; "" = off)
# warm_interval_ms = 60000 # at most one warm-up per interval, across calls

Example: examples/agents/local-kokoro.toml (and .yaml): Deepgram STT + Ollama LLM + Kokoro TTS.

Try it with Kokoro-FastAPI

Commands from the project README (fetched 2026-10-14). The images below were not run for this page (the live runs used a source checkout), but the server and the adapter were. -p 127.0.0.1:8880:8880 keeps the unauthenticated port on this machine; the README's own examples publish it on every interface.

# NVIDIA GPU (README: ships CUDA 12.6)
docker run --gpus all -p 127.0.0.1:8880:8880 ghcr.io/remsky/kokoro-fastapi-gpu:latest
# NVIDIA RTX 50-series / Blackwell (README: ships CUDA 12.8) - the RTX 5070 of the live runs
docker run --gpus all -p 127.0.0.1:8880:8880 ghcr.io/remsky/kokoro-fastapi-gpu:latest-cu128
# CPU only
docker run -p 127.0.0.1:8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest

The README recommends a pinned release tag over :latest for stable use. API docs at http://localhost:8880/docs, voices at /v1/audio/voices. Then:

BRISVOX_LIVE_TESTS=1 LOCAL_TTS_BASE_URL=http://localhost:8880/v1 \
  cargo nextest run -p brisvox-providers --features provider-openai-speech \
  --test openai_speech live_local_tts_server --no-capture

or run the server with examples/agents/local-kokoro.toml.

Live check

live_local_tts_server in crates/brisvox-providers/tests/openai_speech.rs (BRISVOX_LIVE_TESTS=1 and LOCAL_TTS_BASE_URL; optional LOCAL_TTS_PRESET kokoro (default) / chatterbox / speaches / vllm_omni / generic, LOCAL_TTS_VOICE, LOCAL_TTS_MODEL, LOCAL_TTS_API_KEY, never printed; skips without them). It prints the time to first audio of a two-piece utterance, the cancel acknowledgement time and the first audio after a barge-in on the same session, and asserts the audio is whole samples.

UNVERIFIED / open items for a live run

  1. All wire shapes above were read from source summaries, none observed: confirm pcm bytes are headerless 24 kHz on Kokoro-FastAPI latest-cu128.
  2. Kokoro-FastAPI first-chunk latency (README claims about 300 ms GPU at chunk size 400, about 3.5 s on an older i7) and whether the server stops generating on disconnect in practice.
  3. Chatterbox-TTS-Server: the output sample rate, the real voice names (GET /v1/audio/voices), Turkish selection through language (the server mapping sends the TTS language's primary tag, e.g. tr, for the chatterbox preset only; the accepted codes are unconfirmed), and its time per segment; its /tts route with stream: true (chunk-level WAV) might beat /v1/audio/speech and is not used.
  4. Speaches: PCM rate with sample_rate sent, auth, Blackwell build.
  5. Word timestamps through Kokoro's captioned route (see "Words").
  6. HTTP/1.1 vs HTTP/2 cancel behaviour of each server (the adapter only drops the response).

Sources (fetched 2026-10-14)

  • raw.githubusercontent.com/remsky/Kokoro-FastAPI/master/api/src/routers/openai_compatible.py
  • raw.githubusercontent.com/remsky/Kokoro-FastAPI/master/api/src/routers/development.py
  • raw.githubusercontent.com/remsky/Kokoro-FastAPI/master/api/src/structures/schemas.py
  • raw.githubusercontent.com/remsky/Kokoro-FastAPI/master/README.md
  • raw.githubusercontent.com/devnen/Chatterbox-TTS-Server/main/server.py
  • github.com/devnen/Chatterbox-TTS-Server (README; the page was truncated before the /v1/audio/speech section, so the route facts come from server.py)
  • raw.githubusercontent.com/speaches-ai/speaches/master/src/speaches/routers/speech.py
  • docs/providers/local-speech.md (the survey this adapter was chosen from), vLLM-Omni row.

Live (2026-10-14)

Kokoro-FastAPI (git main, uv sync --extra gpu-cu128, PyTorch 2.8.0+cu128, CUDA on the RTX 5070, WSL2) through openai_speech (kokoro preset), 3 runs of the live test: time to first audio 164 ms (cold), then 52 and 50 ms; 4.84 s of audio done after 102-228 ms; cancel acknowledged in 0.06-0.2 ms. Cartesia's first audio over the internet is ~200 ms.

Chatterbox-TTS-Server (git main, Python 3.10 venv via uv, requirements-nvidia-cu128.txt + chatterbox-v2 --no-deps; protobuf had to be raised to 4.25 for onnx 1.16; model.repo_id: chatterbox-multilingual, language: tr), RTX 5070: a two-sentence Turkish input ("Merhaba, yarın saat üçte randevunuz var. Başka bir konuda yardımcı olabilir miyim?") took ~3.06-3.10 s to the first byte (the whole WAV; the route does not stream) for 4.3-5.1 s of audio. Usable for offline/batch Turkish speech, too slow for a live turn's first audio; the adapter's live test (15 s first-audio limit) timed out on the cold first request. Requests with non-ASCII text must be sent as UTF-8 (a cp1254 body got HTTP 400 "error parsing the body").

Cold first request and the model warm-up (2026-10-14)

The maintainers' local call showed tts_ttfb 2.78 s on the first response after idle and 52-104 ms on the later ones (Kokoro-FastAPI GPU, RTX 5070, WSL2). Measured with a script against the running server (time from sending the request to the first audio byte; the server is shared with other work, so these are not lab numbers):

statefirst audio
after 420 s idle, no warm-up524 ms, 464 ms
right after, same request again56 ms, 83 ms
after 420 s idle, "Hi." warm-up first (the warm-up request itself took 463 ms and 514 ms, in the background)the real request then 59 ms, 47 ms
after 200 s idle (total time of the request, curl), no warm-up687, 322, 105 ms against 55-71 ms warm

So the penalty grows with the idle time (about 0.1-0.7 s after 3-7 minutes here, 2.8 s in the maintainers' run, which came after a much longer idle). The warm-up pays it once, in the background, while the call connects and the caller starts to speak (the engine's first user turn takes seconds to reach the TTS, so a 0.5-3 s warm-up is finished well before it). Tests (mock server, real sockets): warm_returns_at_once_and_the_synthesis_runs_in_the_background, warm_ups_are_one_at_a_time_and_spaced_by_the_interval, the_warm_up_uses_the_latest_sessions_voice_and_empty_text_turns_it_off, a_failed_warm_up_is_silent_and_retried_by_the_next_warm, and the preset/limit unit test. The example examples/agents/local/local-en.toml now sets preset = "kokoro"; a definition with the generic preset and model = "kokoro" has no warm-up unless it sets warm_text.

sherpa-onnx — self-hosted streaming STT (WebSocket server)

Code: brisvox_providers::sherpa_onnx (feature provider-sherpa-onnx, server feature of the same name, provider = "sherpa_onnx"), on the shared session core brisvox_providers::wsstt. Survey and alternatives: docs/providers/local-speech.md.

Read 2026-10-14. Everything marked (source) was read from the server source files below, not from a summary. Status. The protocol sections were written from the server source before any server ran; the sections "Live" and "First-turn check" at the end report what was run afterwards (sherpa-onnx 1.13.8, the Python streaming_server.py, with the Turkish seda-v0.1 model and the English zipformer, on WSL2). Still not run: the C++ server and 8 kHz telephone audio. The "Nothing below was run" remark in "Models and the commands" and the UNVERIFIED marks refer to what the live runs did not cover. Mocks in crates/brisvox-providers/tests/sherpa_onnx.rs implement the protocol exactly as read here.

  • Python server: https://raw.githubusercontent.com/k2-fsa/sherpa-onnx/master/python-api-examples/streaming_server.py (885 lines; handle_connection_impl, recv_audio_samples, process_request, argparse flags)
  • Python client: https://raw.githubusercontent.com/k2-fsa/sherpa-onnx/master/python-api-examples/online-websocket-client-decode-file.py
  • C++ server: https://raw.githubusercontent.com/k2-fsa/sherpa-onnx/master/sherpa-onnx/csrc/online-websocket-server-impl.cc (+ -impl.h for the defaults), result JSON in sherpa-onnx/csrc/online-recognizer.cc (OnlineRecognizerResult::AsJsonString)
  • C++ server usage: https://k2-fsa.github.io/sherpa/onnx/websocket/online-websocket.html
  • English model files: https://k2-fsa.github.io/sherpa/onnx/pretrained_models/online-transducer/zipformer-transducer-models.html
  • Turkish model card: https://huggingface.co/atasoglu/seda-v0.1

Protocol (source)

One WebSocket per recognition stream; no handshake message, no parameters, no authentication.

Python streaming_server.pyC++ sherpa-onnx-online-websocket-server
client -> server audiobinary message = raw little-endian float32 samples (np.frombuffer(message, dtype=np.float32)) at --sample-rate (default 16000); "at present, we assume the sampling rate of the received audio samples equal to --sample-rate" (no resampling)binary message = float32 samples (payload.size() / sizeof(float)) at --input-sample-rate (default 16000); the option help says the server resamples to the model's rate
end of audiotext Donetext Done
result messagetext JSON {"text": <hypothesis of the open segment>, "segment": n}, sent after every decode step (a message may repeat the previous text)text JSON from AsJsonString: text, tokens, timestamps, ys_probs, lm_probs, context_scores, segment, words, num_trailing_blanks, start_time, is_final, is_eof; sent per decode batch
endpointnone flagged. After an endpoint the message that carries the endpointed text is sent first, then the stream is reset and segment goes up by one: the next message has segment + 1is_final: true on the message that carries the endpointed text; the stream is reset
after Donethe server pads 0.3 s of zeros (int(sample_rate * 0.3)), calls input_finished(), decodes the rest, sends one more {text, segment} and returns, which closes the connectionend-tail-padding (default 0.8 s) of zeros, InputFinished(); the last result has is_final: true and is_eof: true; then the text message Done!
connection limit--max-active-connections (default 200): the HTTP upgrade of an extra connection is answered 503none seen
other--max-message-size 1 MiB, --max-queue-size 32 messages per connection (backpressure)--max-batch-size, --loop-interval-ms (default 10)

Endpointing (both): --use-endpoint 1 (default), --rule1-min-trailing-silence 2.4, --rule2-min-trailing-silence 1.2, --rule3-min-utterance-length 20 (seconds, Python flags; the help texts only say "duration of trailing silence >= this value", which rule needs decoded text first was not read from the recognizer source).

Not in the protocol: a mid-stream finalize or flush, a keepalive, a language parameter (the model decides), word timestamps in the Python flavour.

What the adapter does

  • Audio. PCM16 from the engine is resampled to sample_rate when the rates differ (brisvox_audio::StreamResampler, 1–2 us per 20 ms frame, docs/LATENCY.md) and converted to float32 LE in a reused buffer (frames are cut off the front of one BytesMut; the steady state does not allocate per frame).
  • Interims. The hypothesis of the open segment, forwarded when it changes (identical results are not repeated). While a Done exchange is open the hypotheses are not forwarded: the answer carries the text.
  • Server endpoint = final. Python: a result with a higher segment ends the previous segment, whose last hypothesis becomes a final transcript with speech_final = true (from_finalize = false). This final is therefore seen one decode step after the endpoint (as soon as the next audio frame is decoded; audio never stops in the engine). C++: the is_final message is the final. Both flavours are told apart by the presence of is_final.
  • Errors. HTTP status of the upgrade (503 for the connection limit) -> Rejected; close code 1008 -> Rejected; a text message that is not JSON -> Protocol; everything else as in wsstt (reconnect, silence watchdog with a half-way ping, Core(Timeout)).

Finalize (the design)

The engine's SttInput::Finalize means "the caller stopped talking: give me the final text of what you heard now". sherpa-onnx has no such message; the only thing that makes it decode the tail and emit its last result is Done, which ends the connection. Options considered:

  1. Done, then a new connection (implemented). Finalize sends Done. The server's last result is the answer (from_finalize, speech_final), delivered when the server closes (Python) or marks is_eof (C++). The spent connection is replaced; the replacement is opened when the caller sends again (audio follows within a frame, so this is immediate in practice, and a call that ends right after a turn leaves no stray connection). Audio that arrives while the answer is pending is held in the bounded ring (replay_window, oldest dropped first) and goes to the replacement in order. A Finalize on a connection that has heard no audio is answered empty without a round trip. Further Finalize requests queue and are answered in order.
  2. Trailing silence and wait for the server's endpoint (rejected). Send enough zeros to trip the trailing-silence rules (>= 1.2 s for rule2) and treat the next endpoint result as the answer. It keeps the connection but depends on the server's endpointing being on and tuned (--use-endpoint, the rules), which the adapter cannot see; with endpointing off a Finalize could never be answered, and the 1.2 s of padding is decoded as real audio (~40 ms at the community model's RTF 0.03, but 1.2 s of extra stream length per turn).
  3. Per-turn connection with a pre-warmed spare (not needed). Same effect as 1 with a connection parked idle per session; saves one loopback/LAN handshake (~1 ms local, 1–3 RTT on a LAN) at the cost of a second server connection per call against --max-active-connections.

Latency trade-off of option 1: the answer arrives after the server decoded the audio it had not yet decoded plus 0.3 s (Python) / 0.8 s (C++) of tail padding. For the zipformer models the model card quotes a final about 40 ms after the end of audio (RTF 0.027–0.034 on one CPU thread, seda card; not measured here), so finalize_timeout (default 1.5 s) is a safety bound, not the expected wait. The replacement connection costs one handshake that overlaps the next turn's first audio; the recognizer state is reset every turn (the models are reset at endpoints anyway, so no context is lost that the server would have kept).

If the answer does not arrive within finalize_timeout, the latest hypothesis is delivered as the answer and the connection is replaced. A late result cannot arrive (the old connection is gone).

Models and the commands to start a server

The Python server was run with the Turkish model (section "Live") and the English zipformer (section "First-turn check"); the command lines below are assembled from the model pages and the server's flags, and the C++ server line was not run. The flags are those of streaming_server.py (source, --help list: --encoder --decoder --joiner --tokens --sample-rate --decoding-method --use-endpoint --rule1/2/3-... --port --nn-pool-size --max-batch-size --max-wait-ms --max-active-connections --num-threads); file names are from the model pages.

English (CPU, streaming zipformer, sherpa-onnx-streaming-zipformer-en-2023-06-26; the page lists fp32 and int8 variants of each file):

git clone https://github.com/k2-fsa/sherpa-onnx          # python-api-examples/ (it imports http_server.py next to it)
pip install sherpa-onnx websockets numpy
wget https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-en-2023-06-26.tar.bz2
tar xvf sherpa-onnx-streaming-zipformer-en-2023-06-26.tar.bz2
python3 ./python-api-examples/streaming_server.py \
  --tokens  ./sherpa-onnx-streaming-zipformer-en-2023-06-26/tokens.txt \
  --encoder ./sherpa-onnx-streaming-zipformer-en-2023-06-26/encoder-epoch-99-avg-1-chunk-16-left-128.int8.onnx \
  --decoder ./sherpa-onnx-streaming-zipformer-en-2023-06-26/decoder-epoch-99-avg-1-chunk-16-left-128.onnx \
  --joiner  ./sherpa-onnx-streaming-zipformer-en-2023-06-26/joiner-epoch-99-avg-1-chunk-16-left-128.int8.onnx \
  --port 6007

Turkish, atasoglu/seda-v0.1 (community model, Apache-2.0 acoustic model; streaming Zipformer2 transducer, 16 kHz, chunk 32 frames = 0.64 s; files from the card: encoder.int8.onnx 70 MB, decoder.onnx, joiner.int8.onnx, tokens.txt, optional lm/rnnlm.int8.onnx and lm/2gram.fst under CC BY-SA 4.0):

git clone https://github.com/k2-fsa/sherpa-onnx          # for python-api-examples/
pip install sherpa-onnx websockets numpy huggingface_hub
huggingface-cli download atasoglu/seda-v0.1 --local-dir ./seda-v0.1
python3 ./python-api-examples/streaming_server.py \
  --tokens  ./seda-v0.1/tokens.txt \
  --encoder ./seda-v0.1/encoder.int8.onnx \
  --decoder ./seda-v0.1/decoder.onnx \
  --joiner  ./seda-v0.1/joiner.int8.onnx \
  --sample-rate 16000 \
  --port 6006

UNVERIFIED for seda: the model card gives no server command, so that streaming_server.py loads the files unchanged (it takes the model type from the ONNX metadata of the transducer) is an inference from the server source, to be confirmed by a live run. streaming_server.py has no language-model flag, so the card's headline WERs (11.52 FLEURS-TR with beam 4 and the LM) do not apply to this setup; greedy search without the LM is worse by an unmeasured amount. The output is lowercase, unpunctuated, numbers spelled out. Telephone (8 kHz) and noisy speech are unmeasured by the card; the adapter upsamples 8 kHz to 16 kHz.

C++ server (from the usage page; arguments must be --key=value): ./build/bin/sherpa-onnx-online-websocket-server --port=6006 --tokens=... --encoder=... --decoder=... --joiner=... --max-batch-size=5 --loop-interval-ms=20, optionally --input-sample-rate=8000 to let the server resample (then set sample_rate = 8000 in the agent file and the adapter sends the telephony audio as it is).

Ports. The server's --port default is 6006, which is also the adapter's default URL (ws://127.0.0.1:6006). The examples pass the port explicitly so that both can run on one machine: 6006 for the Turkish model (examples/agents/local/local-tr.toml) and 6007 for the English one (local-en.toml, the port of the live English run below). Any other port works if the agent file's base_url says so.

Bind address. streaming_server.py has no host or bind flag (its flags: --port, --certificate, --doc-root, --nn-pool-size, --max-active-connections and the model and decoding options). Its source starts the server with websockets.serve(self.handle_connection, host="", port=port, ...), which listens on all interfaces (fetched from https://raw.githubusercontent.com/k2-fsa/sherpa-onnx/master/python-api-examples/streaming_server.py, 2026-10-14; the localhost/127.0.0.1 addresses it prints at startup are only log text). The server has no authentication, so keep its port closed to the network: a host firewall rule, or run the script in a container that publishes the port with -p 127.0.0.1:<port>:<port>.

Agent file: examples/agents/local/local-en.toml, local-tr.toml; keys in docs/CONFIG.md; step by step with Ollama, Kokoro and the browser page: the book's "A fully local assistant".

Tests

crates/brisvox-providers/tests/sherpa_onnx.rs: mocks of both servers (partials, repeats, segment endpoint, C++ is_final/is_eof/Done!), the Done exchange with audio arriving meanwhile, unanswered Done, finalize without audio, queued finalizes, reconnect with replay and a pending finalize, disconnect, garbage, refusals, silent-but-alive server (pings), dead server (timeout), keepalive, 8 kHz resampling, bearer header, the provider contract suite (7 checks). Live: BRISVOX_LIVE_TESTS=1 SHERPA_ONNX_URL=ws://127.0.0.1:6006 [SHERPA_ONNX_LANGUAGE=tr] cargo test -p brisvox-providers --features provider-sherpa-onnx --test sherpa_onnx live_ -- --nocapture streams the two-utterance fixture (fixtures/tr_two_utterances.wav for tr, else en_two_utterances.wav) at real-time pace, finalizes per utterance and prints partial counts, first-partial time, finalize latency and a folded WER against the fixture's reference text. SHERPA_ONNX_TR_URL=... live_sherpa_onnx_turkish runs every Turkish fixture utterance at 16 kHz and as 8 kHz mu-law phone audio and prints a WER and finalize-latency table (section "Turkish phone audio" above).

Open points

  • Live wire check against a running server (Python and C++), the real finalize latency and the seda model: a live run.
  • The C++ server's behaviour for a Done on a stream that is already at an endpoint, and whether it closes the socket after Done! (the adapter does not depend on either).
  • Max-queue backpressure of the Python server (32 messages): a replay of a full 8 s ring is 400 messages; the server reads continuously, so it only slows the replay.

Live (2026-10-14)

Owner's machine, WSL2 Ubuntu 24.04, sherpa-onnx 1.13.8 Python streaming_server.py (needs http_server.py and the python-api-examples/web assets next to it), Turkish model atasoglu/seda-v0.1 (int8 encoder/joiner, 76 MB, CPU, --num-threads 2), the TR FLEURS fixture through sherpa_onnx (SHERPA_ONNX_LANGUAGE=tr), 2 runs: WER 0.00 on both utterances, 9-11 partials per turn, first partial ~1.07-1.16 s into the speech, finalize answered in 1.2-20.6 ms (the server's final result after Done; Deepgram's Finalize is ~290-330 ms). The first-partial delay is the model's chunking; the turn's end is what the engine waits for.

Turkish phone audio (2026-10-14)

Same machine and server (Python streaming_server.py, ws://127.0.0.1:6006), model atasoglu/seda-v0.1 (int8, CPU, 2 threads), adapter defaults (finalize padding 300 ms). Eight Turkish FLEURS utterances (108 reference words): the two of fixtures/tr_two_utterances and the six of the untracked tr_long_0 / tr_long_1 set. Each utterance goes in real time in 20 ms frames followed by 0.3 s of silence (what the VAD leaves), then Finalize; one session per path.

  • 16 kHz: the fixture as recorded, session at 16 kHz.
  • 8 kHz mu-law: what a Twilio call delivers. The fixture is resampled to 8 kHz with brisvox-audio, G.711 mu-law encoded and decoded, and sent with the session configured for 8 kHz, so the adapter resamples it back up to the server's rate.

WER is word-level on text folded to lowercase letters and digits (Turkish letters folded to ASCII, punctuation dropped; İsa'nın is scored as two reference words and counts as an error when the model writes isanın).

utterancewordsWER 16 kHzfinalize msWER 8 kHz mu-lawfinalize ms
tr_two_utterances #0110.00024.50.18230.5
tr_two_utterances #1150.00021.80.00024.2
tr_long_0 #0110.00025.40.09120.5
tr_long_0 #1150.00019.00.00027.1
tr_long_0 #2130.15425.00.15423.7
tr_long_1 #0110.00015.40.00015.9
tr_long_1 #1140.14324.00.14324.9
tr_long_1 #2180.11125.70.44414.1
total / mean1080.05622.60.13922.6

Reading: phone quality costs the seda model about 8 points of WER on this set (5.6 % to 13.9 %). Seven of the eight utterances are equal or within one word; the damage is concentrated in the longest, densest sentence (tr_long_1 #2, a run of unusual words: küresel ısınma became kürete ısınma) and in proper names (Orta Amerika ülkelerindeki became Orta Amerikan ilçelerindeki). Finalize latency is the same for both paths, 14-31 ms (mean 22.6 ms), because the final result after Done is just the tail decode; the adapter's own 8 to 16 kHz resampling adds nothing measurable. One run, eight utterances: read the WER as an indication, not a benchmark.

Reproduce: BRISVOX_LIVE_TESTS=1 SHERPA_ONNX_TR_URL=ws://127.0.0.1:6006 cargo test -p brisvox-providers --features provider-sherpa-onnx --test sherpa_onnx live_sherpa_onnx_turkish -- --nocapture (the tr_long_* files are picked up from fixtures/large/ or the directory in BRISVOX_FIXTURES_LARGE; without them only the two tracked utterances run).

First-turn check and the padding before Done (2026-10-14)

The maintainers' fully local browser call (sherpa-onnx English zipformer chunk-16, Python server at ws://127.0.0.1:6007, WSL2) ended its first turn with an empty transcript (llm_rounds=0). The turn was reproduced through the production assembly, not the adapter alone: crates/brisvox-engine/tests/sherpa_first_turn.rs runs run_call with the real adapter, Silero VAD on the inference pool, the STT stage and the turn driver, a mock LLM and TTS, and a caller paced in real time (room tone, then an utterance cut from fixtures/en_two_utterances.wav or spoken by Kokoro, then 3 s of tone); the mock LLM shows what the turn became. Live only (BRISVOX_LIVE_TESTS=1 SHERPA_ONNX_URL=..., feature silero-tract).

What was ruled out (none of it is the cause of an empty first turn): the connection opened late (warm 0 ms, connect 1-12 ms; one 20 ms frame is dropped while the session connects, which only matters for speech starting at sample 0), Finalize overtaking audio (the stage defers it until the audio before it has gone out; audio_sent equals audio_in minus that frame), the heard no audio shortcut (it is keyed on audio written to the connection), the replay ring, rotation between turns (two utterances 3.5 s apart: both reached the LLM with their text; a loud noise burst before the words did not make the Silero VAD open a separate turn), and an endpoint of the server's own (rule2, 1.2 s) racing the Done (the segment bump is delivered as a final segment before the empty answer and the engine joins them; endpoint_closes=1 in the run that hit it).

What is real is the server's chunking, which the adapter can mitigate only in part:

  • A turn that reaches the server with little audio is answered with little text. Live, one connection per probe, audio then Done: 0.45 s of speech + 0.3 s of silence gave ""; the same with 0.9 s of trailing silence gave "I"; Kokoro's "Hello." (0.86 s) decoded as HELLO at every trailing length; "Hi there." (0.97 s) came back as HIE. A first utterance of well under a second right at the start of a call, with nothing else on the connection, is therefore the one case that can come back empty or partial. The adapter cannot add what the model never saw.
  • The last word of a turn is cut. The Python server pads only 0.3 s before input_finished(); the zipformer's right context needs about 0.6 s of trailing silence (saturates there). Fixture sentence cut at the VAD's silence: ... WITH THE IMP (0.3 s) against ... WITH THE IMPACT (0.6 s); through the engine ... IS ORTHODOX CHRIST / ... ORTHODOX CH in some runs, HIE for "Hi there".

Fix: SherpaOnnxConfig::finalize_padding ([stt.sherpa_onnx] finalize_padding_ms, 0..=5000, default 300). On Finalize, after the turn's audio and before Done, the adapter writes that much float32 silence on the connection about to be spent (never into the replay ring; nothing is written for a connection that heard no audio). Cost, measured through the engine (UserSpeechEnd to the final transcript, 6 turns each): 0 ms padding 96-151 ms (mean 118), 300 ms 90-161 ms (mean 122), 600 ms 127-199 ms (mean 162). The C++ server pads 0.8 s itself: set 0 there. Larger values also make the server's rule2 endpoint (1.2 s of trailing silence) fire while the answer is produced: handled, but pointless.

Tests: wire unit tests (padding then Done, none without audio), mock-server tests (a server that shows text only when it received enough audio: empty at 0 ms padding, text at 300 ms; the padding is float32 zeros of exactly the configured length between the audio and Done), the live adapter test live_sherpa_onnx_keeps_the_last_word_of_a_turn_with_the_default_padding (fixture sentence + 0.3 s of silence must end ... THE IMPACT), and the engine test above.

A turn that is empty because the caller made a noise (a pop when the browser opens the microphone) is not a transcription fault: the VAD reports speech, the server hears no words, llm_rounds=0 is the right outcome.

OpenAI Realtime transcription protocol and its dialects — STT (WebSocket)

Code: brisvox_providers::openai_realtime_stt (feature provider-openai-realtime-stt, server feature of the same name, provider = "openai_realtime_stt" with [stt.openai_realtime_stt] dialect = "openai" | "nim" | "speaches" | "vllm"), on the shared session core brisvox_providers::wsstt. Survey and alternatives: docs/providers/local-speech.md; the speech-to-speech use of the same API: docs/providers/openai-realtime.md.

Read 2026-10-14 through a summarising fetch tool (documentation pages: field lists, not wire captures) and, for Speaches and vLLM, from the server source itself (labelled "source"). Status: not yet verified against a real server. This file has no live results; nothing was run against any of the four servers (the stack that was run locally uses sherpa-onnx instead: docs/providers/sherpa-onnx.md). The mocks in crates/brisvox-providers/tests/openai_realtime_stt.rs implement what is written here; a live run (with the existing OpenAI key) must confirm it.

Sources:

  • OpenAI: https://developers.openai.com/api/docs/guides/realtime-transcription (session shape, models, events) and https://developers.openai.com/api/docs/guides/voice-websockets (URL and auth of a Realtime WebSocket)
  • NVIDIA Speech NIM: https://docs.nvidia.com/nim/speech/latest/reference/api-references/asr/realtime-asr.html
  • Speaches (source): https://raw.githubusercontent.com/speaches-ai/speaches/master/src/speaches/ routers/realtime_ws.py, realtime/session.py, realtime/session_event_router.py, realtime/input_audio_buffer_event_router.py, types/realtime.py
  • vLLM (source): https://raw.githubusercontent.com/vllm-project/vllm/main/vllm/entrypoints/speech_to_text/realtime/ connection.py, protocol.py; docs https://docs.vllm.ai/en/latest/serving/online_serving/speech_to_text/ and https://docs.vllm.ai/en/latest/examples/speech_to_text/realtime/

Dialect table

OpenAINVIDIA Speech NIMSpeachesvLLM
URLwss://api.openai.com/v1/realtime?model=<model> (the guide for transcription shows no connection line; the voice-websockets guide shows ?model=; ?intent=transcription as in the older API is the alternative, set it with path_and_query: UNVERIFIED which one the transcription models want)ws://<host>:9000/v1/realtime?intent=transcription; missing/invalid intent closes with 1008ws://<host>:8000/v1/realtime?intent=transcription&model=<stt model>[&language=<iso639-1>] (source: realtime_ws.py parameters model, intent, language, transcription_model)ws://<host>:8000/v1/realtime
authAuthorization: Bearer <key>; no beta headernone by default for a self-hosted container (REALTIME_AUTH_REQUIRE_TOKEN=false); a token travels in Sec-WebSocket-Protocol: realtime, realtime-token.<secret> (not supported by the adapter)optional API key checked before the upgrade (Bearer, X-API-Key or api_key query per the survey); failure = 1008/403none
audio ininput_audio_buffer.append {audio: base64 PCM16}, session format {"type":"audio/pcm","rate":24000}same event; session input_audio_params {sample_rate_hz: 16000, num_channels: 1}, input_audio_format: "pcm16"; max 15 MB per appendsame event; the base64 is read as 24 kHz and resampled to 16 kHz server side (source)same event, base64 PCM16 16 kHz mono
session messagesession.update {session: {type: "transcription", audio: {input: {format, transcription: {model, languages | language, prompt}, turn_detection: null}}}}; gpt-live-transcribe takes languages (not language); "omit audio.input.turn_detection or set it to null. The model doesn't support server_vad or semantic_vad"transcription_session.update {session: {modalities: ["text"], input_audio_format: "pcm16", input_audio_transcription: {language, model, prompt}, input_audio_params, recognition_config {enable_automatic_punctuation, ...}}} (all session.* optional except modalities, input_audio_format; defaults rate 16000, language en-US, model conformer, punctuation off)none sent: model and language come from the URL. session.update exists but is of no use here (below)session.update {model} (required before a commit: "Model not validated")
server VADnone (null)the page documents vad_states in results and zero-default endpointing_config; whether server endpointing runs and can be disabled: UNVERIFIEDon by default for transcription: server_vad, threshold 0.9, prefix_padding_ms 0, silence_duration_ms 550, create_response false (source: session.py). Cannot be switched off: PartialSession.turn_detection is TurnDetection | NotGiven and every TurnDetection field is required (source: types/realtime.py), so null is refusednone
interimsconversation.item.input_audio_transcription.delta {item_id, content_index, delta}: "returns transcript deltas as speech arrives" (gpt-live-transcribe); gpt-transcribe: deltas "before the final completion event"the same delta event "when response received from gRPC server"; whether a delta is an increment or the whole hypothesis: UNVERIFIED (the page's sample client assumes increments)none (source: only ...completed is published)transcription.delta {delta} (incremental, source: output_kind=DELTA)
final...input_audio_transcription.completed {item_id, content_index, transcript} after input_audio_buffer.commit...completed {transcript, words_info, vad_states, is_last_result, usage}; several per stream are possible (is_last_result)...completed {item_id, transcript, usage} after a commit or a VAD turntranscription.done {text, usage}
commitinput_audio_buffer.commit = "end of the audio turn" with client VAD; input_audio_buffer.committed may be returnedinput_audio_buffer.commit sends the buffer for inference; input_audio_buffer.committed {previous_item_id, item_id}; input_audio_buffer.done = no more audioinput_audio_buffer.commit; a buffer of less than 100 ms is invalid_request_error "buffer too small" (source: MIN_AUDIO_BUFFER_DURATION_MS = 100)input_audio_buffer.commit starts a generation that consumes the appended audio as it arrives; commit with final: true ends the audio and the generation answers with transcription.done; a new non-final commit starts the next (source: connection.py)
errors{"type":"error","error":{type,code,message,param}}same shape; conversation.item.input_audio_transcription.failedsame shape (invalid_request_error, server_error){"type":"error","error":"<message>","code":"<code>"} (string message)
limits60 minutes per sessionpage lists both 100 and 10,000 connections (inconsistent)30 minutes per connection (source: OPENAI_REALTIME_SESSION_DURATION_SECONDS)not stated
modelsgpt-live-transcribe, gpt-transcribe (the page names no others)Nemotron ASR Streaming, Parakeet (per NIM docs)any Speaches STT model (default English-only Systran/faster-distil-whisper-small.en; Turkish needs a multilingual one such as Systran/faster-whisper-small)ASR models with a realtime class (Voxtral Mini 4B Realtime, Qwen3-ASR); --enforce-eager or the model card's compilation config

What the adapter does

  • One protocol, a small table. Dialect fixes the URL, rate, session message(s), whether interims exist and the minimum commit. Event names are matched for all dialects at once (...transcription.delta/completed, transcription.delta/done, error, ...failed, input_audio_buffer.committed); everything else (session.created, rate_limits.updated, ...) is ignored. The mock servers, not the table, decide what a server sends.
  • Turn control is ours. The engine's VAD ends the turn; Finalize is input_audio_buffer.commit (vLLM: the final: true commit, with the audio gate shut until transcription.done and a new generation started by a plain commit; audio that arrives meanwhile is held in the ring and sent after it). Server-side turn ends (Speaches' VAD, NIM's endpointing) arrive as additional speech_final transcripts that are not from_finalize; the engine closes them with the next UtteranceFinal (crates/brisvox-engine/src/stt.rs).
  • Which transcript answers a commit. The item_id of input_audio_buffer.committed pairs the commit with its completed; a completed of another item is a server turn. Servers that send no committed (vLLM has no items): the first transcript after the commit answers it.
  • Nothing to flush. A Finalize with nothing appended since the last commit, or less than the minimum (min_commit: 100 ms OpenAI and Speaches, 0 NIM and vLLM), is answered empty at once and sends nothing; an error event while a commit is open (an empty buffer, a failed transcription) answers it empty too. The engine promotes the latest interim when an answer is empty.
  • Timeout. No answer within finalize_timeout (default 2 s, the engine gives up at 3 s): the latest hypothesis is delivered as the answer, the item is remembered and its late transcript is dropped (no second final). vLLM: the connection is replaced instead (a generation may still run).
  • Errors. invalid_api_key / authentication_error -> Rejected 401, model_not_found -> Rejected 404, insufficient_quota -> Rejected 429; any other error event is logged and the session goes on. Handshake status -> Rejected. Not JSON -> Protocol.
  • Liveness. These servers do not answer every chunk, so the session core proves liveness with WebSocket pings (any frame counts) instead of expecting messages: see wsstt.
  • Auth transport. A key is only sent over wss:// or to a loopback host; a keyless local server may be reached over plain ws:// anywhere (a GPU box on the LAN).

Configuration

docs/CONFIG.md ([stt.openai_realtime_stt]). Minimal local stacks:

# NVIDIA Speech NIM (Nemotron ASR Streaming, Turkish and English), 16 kHz
[stt]
provider = "openai_realtime_stt"
[stt.openai_realtime_stt]
dialect = "nim"
base_url = "ws://gpu-box.lan:9000"

# Speaches (Whisper; no interim text), 24 kHz
[stt]
provider = "openai_realtime_stt"
[stt.openai_realtime_stt]
dialect = "speaches"
model = "Systran/faster-whisper-small"

The session language (agent.language / stt.language) is mapped per dialect: OpenAI and Speaches get the primary subtag (tr), NIM a locale (tr -> tr-TR, en -> en-US, others as written), vLLM none (the model decides).

Tests

tests/openai_realtime_stt.rs: OpenAI (URL, bearer, session message with turn_detection: null, 16 -> 24 kHz resampling, deltas, commit/completed, empty finalizes, error while a commit is open, unanswered commit and its late transcript, reconnect with session message and replay, disconnect, garbage, invalid_api_key, refusals, live-but-silent server, dead server, hang-up, config validation), NIM (URL, nested session message, audio byte for byte), Speaches (URL query, no session message, VAD turn as endpoint, no interims), vLLM (model, start commit, final commit, held audio, restart, timeout replaces the connection), and the provider contract suite for the OpenAI, NIM and vLLM dialects (Speaches sends no interims, which the suite requires). Live: BRISVOX_LIVE_TESTS=1 LOCAL_REALTIME_STT_URL=ws://127.0.0.1:9000 LOCAL_REALTIME_STT_DIALECT=nim [LOCAL_REALTIME_STT_MODEL=..] [LOCAL_REALTIME_STT_LANGUAGE=tr] cargo test -p brisvox-providers --features provider-openai-realtime-stt --test openai_realtime_stt live_ -- --nocapture prints partial counts, first-partial time and finalize latency for the two-utterance fixture.

Open points (all for a live run)

  • OpenAI: the transcription URL (?model= vs ?intent=transcription), the committed event and the error codes on a too-short commit.
  • NIM: whether a delta is an increment (delta_replaces = true if not), what commit does exactly (one completed per commit?), whether server endpointing interferes, WSL2 is unsupported by the Nemotron container (docs/providers/local-speech.md).
  • Speaches: Blackwell compatibility of its CUDA 12.6 image, latency of a commit on CPU/GPU.
  • vLLM: the first commit before session.update's model check, behaviour at realtime_max_tokens (the adapter restarts a generation that ends by itself).

Local speech servers — STT + TTS (self-hosted survey)

Researched 2026-10-14. Every fact below came from a page fetched that day (URL in "Sources" and inline). Pages were read through a summarising fetch tool: quoted strings are copied from its output, JSON/message shapes are field lists, NOT verbatim wire captures. Source-code reads are labelled "(source)". Anything not confirmed is marked UNVERIFIED. This is a survey written from documentation. Afterwards three of the servers were run and measured (2026-10-14): sherpa-onnx's Python server (Turkish seda-v0.1 and the English zipformer: docs/providers/sherpa-onnx.md, "Live"), Kokoro-FastAPI and Chatterbox-TTS-Server (docs/providers/openai-speech.md, "Live"). Every other server in this survey was not run. Latency numbers below are the projects' own claims unless stated.

Recommended (read this, skip the survey).

NeedStart withVerified live (2026-10-14)
English speech recognition, no GPUsherpa-onnx streaming_server.py with the streaming zipformer sherpa-onnx-streaming-zipformer-en-2023-06-26, port 6007 (provider = "sherpa_onnx", docs/providers/sherpa-onnx.md)Yes, in a fully local browser call. Output is UPPERCASE without punctuation, and a very short first utterance can come back nearly empty (docs/providers/sherpa-onnx.md, "First-turn check"). The script has no bind flag and listens on all interfaces: firewall the port.
Turkish speech recognition, no GPUsherpa-onnx with atasoglu/seda-v0.1, port 6006Yes (WER 0.00 on the two fixture utterances, finalize in 1 to 21 ms); read speech only, 8 kHz phone audio unmeasured.
English voiceKokoro-FastAPI through openai_speech, preset = "kokoro", port 8880 (latest-cu128 image on an RTX 50 series card, the cpu image otherwise)Yes: first audio 50 to 164 ms on a GPU. The first request after a long idle is slower; the preset warms it in the background.
Turkish voiceNothing live-ready. Chatterbox-TTS-Server works (preset = "generic") but takes about 3 s to the first audioMeasured, too slow for a live turn; usable for offline speech.
A local language model (with tools)Ollama through openai_compatible: preset = "ollama", model = "gemma4:12b-it-qat", reasoning_effort = "none" (7.7 GB of GPU memory)Yes: all six tool scenarios 3/3 times, first sentence 267 ms on an RTX 5070; four smaller local models failed some or all tool scenarios (docs/TOOLS.md, "Local models through Ollama"). Without tools gemma4:e4b-it-qat (3.1 GB, 157 ms) is enough.
Everything else in this survey (Speaches, vLLM-Omni, Kyutai, NVIDIA NIM, Piper, Pocket TTS ...)Do not start hereNot run; paper facts only.

The whole local stack, step by step: book/src/quickstart/local.md; its agent files are examples/agents/local/local-en.toml and local-tr.toml.

  • Implemented: the sherpa-onnx client (docs/providers/sherpa-onnx.md) and the OpenAI Realtime transcription client with a dialect table (docs/providers/openai-realtime-stt.md). Two corrections from reading the server sources: Speaches' server VAD cannot be disabled (session.update does not accept turn_detection: null), and vLLM's realtime route starts a generation with a plain commit and ends it with commit {final: true} (connection.py).
  • Docs: see "Sources" at the end (fetched 2026-10-14).
  • Auth: local servers mostly none (Kyutai kyutai-api-key header default public_token; Speaches optional Bearer / X-API-Key / api_key query; NVIDIA NIM needs an NGC key to PULL the container).
  • Endpoint(s) / regions: self-hosted; ports per server below.
  • Protocol: no single standard. Two de-facto shapes exist (see "Protocol convergence").
  • Keepalive / timeouts: not documented for any server unless stated; Brisvox must apply its own.
  • Finalize / flush / cancel semantics: per server in the matrix below.
  • Timestamps / interim results: per server.
  • Concurrency / rate limits: per server.
  • Turkish support: see the Turkish summary table.
  • Gotchas: see "Gotchas".

Owner hardware (task text): RTX 5070 (NVIDIA page: 12 GB GDDR7, 6,144 CUDA cores), Windows 11 + WSL2 Ubuntu + Docker. Production may be CPU-only.

1. Protocol convergence (answer to "is there a standard?")

TTS: yes, OpenAI POST /v1/audio/speech. Body {model, input, voice, response_format, speed, ...}; with response_format: "pcm" the OpenAI docs define "raw 24kHz, 16-bit signed samples, little-endian ... without the header" and chunked transfer, so playback starts before generation ends (developers.openai.com text-to-speech guide). Servers that expose it: Kokoro-FastAPI (pcm, 24 kHz, stream), Speaches (pcm/wav/mp3/flac, stream_format audio|sse, optional sample_rate), vLLM-Omni serving Voxtral TTS ({BASE_URL}/audio/speech), Chatterbox-TTS-Server (/v1/audio/speech; whether it streams: UNVERIFIED). NOT compatible: Piper (POST /synthesize returns a whole WAV), Kyutai (msgpack WebSocket), Orpheus/others (library only). Limitation of the standard: the whole text is one request. Incremental text-in is only possible by sending one request per sentence/clause (or a WebSocket protocol like Kyutai's).

STT: OpenAI Realtime transcription WebSocket is the closest thing, in three dialects:

DialectURLAudio inEvents outFinalize
OpenAI (cloud, from developers.openai.com/api/docs/guides/realtime-transcription)see docs/providers/openai-realtime.mdinput_audio_buffer.append {audio: base64 PCM16}; guide example audio/pcm rate 24000conversation.item.input_audio_transcription.delta {item_id, content_index, delta}, ...completed {item_id, content_index, transcript}with turn_detection null/omitted the client sends input_audio_buffer.commit; "final transcript arrives in the completed event for that committed item"
NVIDIA Speech NIM (docs.nvidia.com/nim/speech/latest/reference/api-references/asr/realtime-asr.html)ws://<host>:9000/v1/realtime?intent=transcription (missing/invalid intent -> close 1008)base64 PCM16, default sample_rate_hz 16000, 1 channel; max 15 MB per appendconversation.item.input_audio_transcription.delta {item_id, content_index, delta}, ...completed {item_id, content_index, transcript, words_info, vad_states, is_last_result, usage}, ...failed, input_audio_buffer.committed {previous_item_id, item_id}, errorinput_audio_buffer.commit per utterance; input_audio_buffer.done = no more audio (process remaining, stop); input_audio_buffer.clear; config via transcription_session.update
Speaches (speaches.ai/usage/realtime-api, source input_audio_buffer_event_router.py, session.py)wss://host/v1/realtime?intent=transcription&model=<stt model>&language=<iso639-1> (auth Bearer / X-API-Key / api_key)base64 audio treated as 24 kHz and resampled to 16 kHz server-side (source); session default format pcm16input_audio_buffer.speech_started/stopped/committed, conversation.item.created, conversation.item.input_audio_transcription.completed {item_id, transcript, usage{type:"duration", seconds}}. No delta events (source: InputAudioBufferTranscriber publishes only completed)input_audio_buffer.commit (< 100 ms of audio -> invalid_request_error); server VAD default server_vad threshold 0.9, silence_duration_ms 550, prefix_padding_ms 0 (source); response.cancel, conversation.item.truncate unsupported
vLLM /v1/realtime (docs.vllm.ai serving/online_serving/speech_to_text; ASR models only)ws://host:8000/v1/realtimebase64 PCM16 16 kHz mono in input_audio_buffer.append {audio}session.created {id}, transcription.delta {delta}, transcription.done {text, usage?}, error {error} (different names from OpenAI)client sends session.update {model}, then input_audio_buffer.commit (ready signal), appends, and input_audio_buffer.commit {final:true} when all audio is sent; the docs flow also says "Repeat from step 5 for the next utterance" but how a mid-session turn flush works: UNVERIFIED

Consequence: one OpenAiRealtimeTranscription-style adapter with a small dialect config (URL/query, input sample rate, base64 PCM16, event-name table, whether deltas exist) covers OpenAI, NVIDIA NIM and Speaches, and with a second event-name table vLLM. Servers outside this family (own adapters needed): sherpa-onnx, Kyutai, WhisperLive, Vosk.

2. STT matrix

Legend: S = true streaming (decodes while audio arrives, emits partials). "Fin" = explicit finalize.

Server / modelS?Protocol and finalizeLatency claimTurkishLicense (code / weights)HardwareStatus
Speaches (faster-whisper; also Parakeet since v0.9.0-rc.1)No interim. REST /v1/audio/transcriptions (SSE of output as the file is transcribed, i.e. per file); Realtime WS gives completed onlysee dialect table; Fin = commitnone published (docs say real-time needs CUDA)Whisper multilingual (Turkish in the Whisper language set; speaches default STT model is English-only Systran/faster-distil-whisper-small.en)MIT / Whisper MITCPU or GPU; images ghcr.io/speaches-ai/speaches:latest-cuda, :latest-cpu; Dockerfile base nvidia/cuda:12.6.3-base-ubuntu24.04 (RTX 50 / Blackwell compatibility: UNVERIFIED)repo pushed 2026-10-13, 3.7k stars, 147 open issues, last release v0.9.0-rc.3 2025-12-27 (master far ahead)
whisper.cpp serverNo (HTTP POST /inference multipart WAV file, temperature, response_format...; POST /load)request/response only; streaming over HTTP not documented (UNVERIFIED). whisper-stream CLI example exists but was not documented on the fetched pagen/aWhisper multilingualcode license UNVERIFIED (not fetched)CPU/GPUlatest release v1.9.5 (2026-10-06)
WhisperLive (Collabora)Near-live (Whisper re-decoding with VAD), "nearly-live" per READMEWS: first message JSON options (uid, language, task, model, use_vad, audio_format float32/int16/uint8, initial_prompt, hotwords, word_timestamps, send_last_n_segments...); binary 16 kHz audio frames; b"END_OF_AUDIO" binary frame ends the stream (server finalizes then closes); refusal {"uid","status":"WAIT","message":minutes}; segments carry start,end,text,completed (segment shape from README, not server source); max clients default 4, max connection 600 s (source server.py); optional OpenAI-style REST with --enable_restnone statedWhisper multilingualMIT / Whisper MITfaster_whisper (CPU/GPU), TensorRT, OpenVINO; images ghcr.io/collabora/whisperlive-gpu:latest, -cpu:latest, -openvino4.3k stars, pushed 2026-10-07, no release info found
sherpa-onnx streaming WS server (Zipformer, Paraformer, NeMo transducers...)Yes (true streaming transducers)WS (python streaming_server.py, source): binary msgs = raw little-endian float32 samples at --sample-rate (default 16000; no resampling); text msg "Done" ends the utterance (server pads 0.3 s zeros, decodes, sends final message, then closes); server -> client JSON {"text": ..., "segment": n} after each decode step (no is_final, tokens, timestamps in the python example); endpointing built in: --use-endpoint (default 1), --rule1-min-trailing-silence 2.4 s, --rule2-min-trailing-silence 1.2 s, --rule3-min-utterance-length 20 s; on endpoint the stream is reset and segment increments; --max-active-connections 200 (extra get HTTP 503), --max-batch-size 3, --max-wait-ms 10. A C++ server (websocketpp+asio) also exists; its message format was not fetched (UNVERIFIED)community Turkish model: median first-word latency 0.70 s, final ~40 ms after audio end, RTF 0.027-0.034 on 1 CPU thread (model card, below)no official k2-fsa Turkish streaming model in the listing fetched; community models belowApache-2.0 / per modelCPU first-class, also GPUv1.13.8 (2026-09-10, latest seen), very active
... sherpa-onnx + atasoglu/seda-v0.1 (community, Turkish only)Yes (Zipformer2 transducer, 66.1M params, int8 ONNX, chunk 32 frames = 0.64 s)as above (card says it "can also be used directly with sherpa-onnx"; the exact server command was NOT on the card: UNVERIFIED that streaming_server.py loads it unchanged). Output is lowercase, no punctuation, numbers as words, no ITNsee aboveYes: streaming WER 11.52 FLEURS-TR test, 13.61 Common Voice 27.0 TR test, 15.23 ISSAI TSC test (beam 4 + LM); card compares Whisper small at 11.90 on FLEURS-TR under the same normalisationacoustic model Apache-2.0; optional LM files CC BY-SA 4.0 (Turkish Wikipedia text); training data CC0 (Common Voice) + MIT/CC BY 4.0 (ISSAI TSC)~170 MB RAM + ~1.4 MB per extra stream; encoder int8 70 MBtrained mostly on read speech; telephone / noisy / spontaneous speech UNMEASURED (card)
... sherpa-onnx + duxx/turkish-stt-zipformerYes (23.6M, ~27 MB int8)endpoint detection + hotwords per cardnot statedTurkish; dev-set WER 13.09 (CV17), 17.39 (daily-use), 20.16 (FLEURS valid)CC BY-NC 4.0 (non-commercial)CPUnot usable commercially
Kyutai STT (kyutai/stt-1b-en_fr, stt-2.6b-en) via moshi-serverYes (delayed streams)WS ws://host:8080/api/asr-streaming, header kyutai-api-key (default public_token; or ?auth_id=), MessagePack both ways (source stt_from_file_rust_server.py). Client: {"type":"Audio","pcm":[float32 ...]} mono 24 kHz, 1,920-sample (80 ms) frames; {"type":"Marker","id":0}. Server: Step (semantic VAD / progress), Word {text, start_time}, EndWord {stop_time}, Marker echoed once audio before it is transcribed. The script sends 1 s leading silence (needed by 2.6B), 5 s trailing silence, the Marker, then 35 more 1 s silences because "the model returns the marker late" -> finalize = Marker + enough silence to flush the 0.5 s delay (how little silence suffices: UNVERIFIED)0.5 s delay (1B en/fr, includes semantic VAD), 2.5 s (2.6B en); H100 400 real-time streams, L40S 64 streams at RTF 3xNo (English, French only)Python MIT, Rust Apache-2.0 / weights CC-BY 4.0GPU (benchmarks on L40S/H100; minimums not stated; fit in 12 GB: UNVERIFIED)repo pushed 2026-01-26 (slower since); moshi-server crate 0.6.4 (2025-10-01), MIT/Apache-2.0
Vosk server (Kaldi)YesWS (source asr_server.py, port 2700): text {"config": {"sample_rate", "words", "max_alternatives", "phrase_list", "model"}} (no reply; default rate 8000); binary audio (16-bit mono PCM per docs); reply per chunk: Result() if utterance finished else PartialResult() (Vosk JSON, schema not in the code); {"eof":1} -> FinalResult() then ends; {"reset":1} -> FinalResult() and keeps connection open (best finalize primitive of the group)n/avosk-model-small-tr-0.3 (35 MB, Apache-2.0, WER "TBD") - weak small modelApache-2.0 / Apache-2.0 per modelCPU; image alphacep/kaldi-en:latest port 27001.3k stars, 81 open issues; last commit date not seen
NVIDIA Nemotron ASR Streaming (3.5 multilingual) nvidia/nemotron-3.5-asr-streaming-0.6bYes (cache-aware FastConformer-RNNT)via Speech NIM nvcr.io/nim/nvidia/nemotron-asr-streaming:latest, NIM_TAGS_SELECTOR=name=nemotron-asr-streaming,type=multi (or type=en-US), ports 9000 (HTTP + realtime WS) and 50051 (gRPC); needs NGC_API_KEY; realtime WS = OpenAI-transcription dialect in section 1; endpointing: silence-based on by default; endpointing_config.* fields exist; a force_eou chunk flag exists in the gRPC example (NOT confirmed over WS). Also runnable without NIM via NeMo, HF Transformers >= 5.13.0, NeMo-Speech.cpp (GGUF) - none of these ships a WebSocket server (per the card)chunk sizes 80/160/320/560/1120 ms (att_context_size); NVIDIA "Voice Agent Blueprint" claim ASR 0.04 s single stream, 0.067 s at 64 streams (AI-generated FAQ page, verify)Yes, "transcription-ready" tier tr-TR; FLEURS WER 12.34 (80 ms), 12.33 (160), 12.05 (320), 11.34 (560), 11.17 (1120) with LangIDNeMo code Apache-2.0 (UNVERIFIED, not fetched) / weights OpenMDW-1.1 (HF card, NGC page; Together AI page says NVIDIA Open Model License: conflicting secondary source); NIM container: NVIDIA Software License Agreement + product terms; all "ready for commercial and non-commercial use"NIM: Compute Capability >= 8.0, "at least 16 GB VRAM"; per-profile GPU memory ~4 GB (batch 32), ~12-13 GB (batch 128), ~21-23 GB (batch 512); Linux driver >= 535; WSL2 not supported for Nemotron ASR Streaming (support matrix); table lists GeForce RTX 50xx but the Nemotron section names only "Blackwell and DGX Spark"; the maintainers' 12 GB RTX 5070 fits only the small-batch profile at best and WSL2 rules out the maintainers' Docker setup: UNVERIFIED in practiceHF card released 2026-06-04; NIM tag 1.4.0 (NGC, updated 2026-10-03)
... nvidia/nemotron-speech-streaming-en-0.6bYessame family; English only; chunk 80/160/560/1120 ms; WER (OpenASR avg, no PnC) 6.93 / 7.07 / 7.67 / 8.43 % at 1.12 / 0.56 / 0.16 / 0.08 s; built-in punctuation/capitalisation; 16 kHz monosee aboveNoNVIDIA Open Model License AgreementNeMo, Transformers, NeMo-Speech.cpp, NIM
nvidia/parakeet_realtime_eou_120m-v1Yes (EOU token)120M, ~80-160 ms, English, <EOU> token, no punctuation (search-result summary, primary card NOT fetched)NoNVIDIA Open Model License (secondary)UNVERIFIED
NVIDIA Parakeet-TDT-0.6B-v3 / Canary-1b-v2Parakeet: not natively streaming (NeMo chunked-inference script) ; Canary: not documentedNeMoNo (25 European languages, Turkey's language not among them)CC BY 4.0Parakeet card dated 2025-08-14
vLLM Realtime + Voxtral-Mini-4B-Realtime-2602Yesvllm serve mistralai/Voxtral-Mini-4B-Realtime-2602 --compilation_config '{"cudagraph_mode":"PIECEWISE"}' (card) / --enforce-eager (vLLM docs example); route /v1/realtime; vLLM dialect in section 1configurable delay 80 ms-2.4 s (card; intro says 240 ms), recommended 480 msNo (13 languages: ar, de, en, es, fr, hi, it, nl, pt, zh, ja, ko, ru)vLLM Apache-2.0 (UNVERIFIED) / weights Apache-2.0>= 16 GB GPU (card)Feb 2026
vLLM Realtime + Qwen3-ASR-0.6B / 1.7BStreaming via vLLM only, no batch, no timestamps (README); vLLM lists Qwen3ASRRealtimeGeneration (Qwen/Qwen3-ASR-0.6B) under "Realtime Transcription", with a note it "is not auto-detected from config.json" (exact serve flag/override: UNVERIFIED); the realtime module buffers audio in fixed segments, default segment_duration_s 5.0 s (vLLM API docs) -> likely much coarser than Nemotron/Kyutai; interim cadence UNVERIFIEDno latency figures (README)Yes (tr in the 30-language list); no Turkish WER published on the cardApache-2.0 / Apache-2.0GPU (qwenllm/qwen3-asr image; qwen-asr-serve)experimental candidate
Moonshine VoiceYes (streaming models Tiny/Small/Medium, English only)in-process library (Python, C++, mobile...); no server/WebSocket documented"very low latency", no numbersNo Turkish STT (en, es, zh, ja, ko, vi, uk, ar)MIT; non-English legacy non-streaming models under the non-commercial Moonshine Community LicenseCPU, edge
OpenAI whisper itselfNolibraryyes (multilingual)MIT

Whisper Turkish WER: the Whisper paper table could not be read (PDF text not extractable) and no published large-v3 Turkish WER was found. Only data points: seda card, Whisper small FLEURS-TR 11.90 WER (their normalisation); a third-party table quoting 8.4 % for large-v2 on FLEURS (not verified at the source).

3. TTS matrix

Server / modelStreaming in / outProtocolTime to first audio (claim)TurkishLicense (code / weights)HardwareStatus
Kokoro-FastAPI (Kokoro-82M v1.0, 54 voices)in: whole text per request; out: chunked audioPOST /v1/audio/speech {model (tts-1, tts-1-hd, kokoro, gpt-4o-mini-tts accepted), input, voice (combos like af_bella(2)+af_sky(1)), response_format mp3/opus/aac/flac/wav/pcm, stream, speed, lang_code, normalization_options}; pcm = 16-bit mono 24 kHz; Transfer-Encoding: chunked; extras /v1/audio/voices, /dev/captioned_speech (word timestamps), /dev/phonemize; validation errors are normal 400 before streaming starts; cancel = client disconnect (server checks is_disconnected before each chunk and stops generating)~300 ms GPU @ chunk size 400; ~3500 ms older i7 CPU @ 200; < 1 s M3 Pro CPU @ 200 (README, "first-token latency (streaming)")No (en-US/GB, es, fr, hi, it, ja, pt-BR, zh)wrapper Apache-2.0, weights Apache-2.0, StyleTTS2-derived code MITCPU image ghcr.io/remsky/kokoro-fastapi-cpu, GPU ghcr.io/remsky/kokoro-fastapi-gpu (cu126), RTX 50 / Blackwell: ghcr.io/remsky/kokoro-fastapi-gpu:latest-cu128; port 8880v0.9.0 (2026-09-10), pushed 2026-10-14, 5.5k stars, README: "development focused", release branch for production
Speaches TTS (Kokoro ONNX speaches-ai/Kokoro-82M-v1.0-ONNX, Piper)out: StreamingResponsePOST /v1/audio/speech `{model, input, voice, response_format pcm/mp3/wav/flac/opus/aac, speed, stream_format "audio""sse", sample_rate}; SSE mode = SpeechAudioDeltaEvent(base64) thenSpeechAudioDoneEvent; PCM response carries no rate/format header (client must know); docs say opus/aac unsupported (code lists them: conflicting) (source routers/speech.py`, docs)noneonly via Piper voices (below)MITCPU/GPU
Piper (OHF-Voice/piper1-gpl)out: whole WAVpython3 -m piper.http_server -m <voice>, port 5000: POST /synthesize {text, voice, speaker, speaker_id, length_scale, noise_scale, noise_w_scale} -> WAV file; GET /voices, GET /info; sample rate / chunked streaming not documentedn/a (fast ONNX VITS, no number found)Yes but one voice: tr_TR-dfki (rhasspy/piper-voices tree has only tr/tr_TR/dfki; fahrettin and fettah were removed "at request of contributors"); 22,050 Hz, medium, 1 speakerengine GPL-3.0 (use as a separate process, do not link); voice CC BY-NC-SA 4.0 = non-commercial (MODEL_CARD); other voices have their own licensesCPUrepo pushed 2026-10-11; README: Open Home Foundation "is looking for maintainers"
Chatterbox (Resemble AI) Multilingual V3 (500M), Turbo (350M, EN), Nano (110M, EN, CPU)README does not mention streaming for the librarylibrary; wrapper belowopen-source models: no figure (Nano "about 3x faster than realtime on 8 CPU cores"); "sub-200 ms" is the paid hosted service onlyYes (23 languages incl. Turkish; V3 list not itemised separately, V2 list includes Turkish)MIT / MIT; all output carries a Perth watermarkMultilingual: CUDA/CPU/MPS examples; VRAM not stated
... Chatterbox-TTS-Server (devnen) v2.0.0out: stream: true on /tts returns WAV as each chunk finishes ("chunk-level, not token-level"; single-chunk text gets no TTFB benefit)POST /tts (WAV/MP3/Opus, language param), POST /v1/audio/speech (OpenAI-compatible; streaming there UNVERIFIED), GET /v1/audio/voices, /docs; hot-swap Original/Multilingual/Turbono figureTurkish via multilingual model; exact language code not on page (check /docs)MITBlackwell/RTX 50 (incl. 5070): docker-compose-cu128.yml, CUDA 12.8, driver >= 570; CPU image existsno TTFA data; needs a live measurement
Kyutai TTS (kyutai/tts-1.6b-en_fr, ~1.8B total) via moshi-serverin: incremental text (one word per message); out: streamed audioWS ws://host:8080/api/tts_streaming?voice=<path in kyutai/tts-voices>&format=PcmMessagePack, header kyutai-api-key, MessagePack: client {"type":"Text","text":word} per word then {"type":"Eos"}; server {"type":"Audio","pcm":[float32...]} mono 24 kHz, 1,920-sample blocks; no explicit end message (receive loop ends when the socket closes); no cancel message documented (close the socket: UNVERIFIED semantics) (source tts_rust_server.py)audio trails text by 16 steps ~1.28 s (card); throughput ~75x batched; no TTFA figureNo (en, fr)Python MIT, Rust Apache-2.0 / weights CC-BY 4.0 (card); no watermark; voice cloning only from the provided embeddingsGPU (pretrained on H100; inference minimum not stated)Rust-server install "a bit tricky" (README)
Kyutai Pocket TTSstreaming outserve command: local HTTP server on 8000, endpoints not documented on README~200 ms first chunk on CPU, ~6x realtime on a MacBook Air M4 with 2 coresNo (en, fr, de, pt, it, es, nl; community adds cs, hi, ko, el)MIT code; each voice has its own licenseCPU, ~100M params
Voxtral-4B-TTS-2603 (Mistral)streamingvLLM-Omni vllm serve mistralai/Voxtral-4B-TTS-2603 --omni; POST {BASE}/audio/speech {input, model, response_format, voice}; 24 kHz; wav/pcm/flac/mp3/aac/opus70 ms at concurrency 1, 331 ms at 16, 552 ms at 32 (500-char input, single H200, card)No (9: en, fr, es, de, it, pt, nl, ar, hi)weights CC BY-NC 4.0 (non-commercial)>= 16 GB GPU
XTTS-v2inference_stream (docs claim < 200 ms latency; "usually slower overall but first chunk sooner")library; xtts-streaming-server demo (port 80 in container; README: "doesn't support concurrent streaming requests, it's a demo server, not meant for production"; needs COQUI_TOS_AGREED=1)< 200 ms (claim)Yes (17 languages incl. tr)maintained fork idiap/coqui-ai-TTS code MPL-2.0 (v0.27.5, 2026-01-26); weights Coqui Public Model License = non-commercial only ("allows only non-commercial use of a machine learning model and its outputs")GPU
F5-TTSbenchmark onlyTriton/TensorRT-LLM, Gradio, Docker253 ms avg at concurrency 2 on one L20 (README, client-server)not listed on READMEcode MIT; pretrained models CC-BY-NC (Emilia data)GPU
Fish Audio S2 Prostreaming engine (SGLang-based)no server API described on card~100 ms TTFA on an H200, RTF 0.195 (vendor)"tr" in the "other supported languages" list (card claims 80+)Fish Audio Research License: research/non-commercial free, commercial needs a license5B params
Orpheus-TTSstreaming (vLLM)library"~200 ms streaming latency ... reducible to ~100 ms with input streaming" (README)No (multilingual research release: zh, hi, ko, es+it, fr, de)repo Apache-2.0; weights license not stated on the pages read (Llama-3b backbone)GPU
Qwen3-TTS (12Hz 1.7B/0.6B)streamingqwen-tts package, DashScope API; vLLM-Omni offline only (online serving "will be supported later")"as low as 97 ms" (vendor)No (zh, en, ja, ko, de, fr, ru, pt, es, it)Apache-2.0GPU
OmniVoice (k2-fsa)none documentedmodel.generate() returns 24 kHz arraysRTF 0.025-0.09 on H100"600+ languages" claimed; Turkish not confirmed; voice design trained on zh/en onlyApache-2.0GPU
sherpa-onnx TTSnon-streaming API shownlibraryno Turkish model on the pages fetched (families listed: Matcha zh/en, Kokoro zh/en, KittenTTS en, VITS incl. MeloTTS zh+en, Piper en)Apache-2.0CPUthe full >100 models / 40 languages list was NOT enumerable
MeloTTSzh+en only in the sherpa-onnx listing; repo not fetchedUNVERIFIED
Meta MMS-TTS facebook/mms-tts-turVITS 36.3MtransformersyesCC-BY-NC 4.0 (non-commercial)
FreyaTTS (arXiv 2607.09530)not statedpaper onlyRTF 0.11 on RTX 4090Turkish-first, 183.2M, WER 8.0 % on their own benchmarkauthors state Apache-2.0 weights+code will be released; no repository link on the abstract page and none foundUNVERIFIED availability
Moonshine TTS (library)stream()/push_text()/flush()/end_input()/cancel_stream()in-process library using Kokoro, Piper, ZipVoice voices"well under a second" first audio streaming (docs example)tr-tr listed (20 languages)MIT code; TTS/G2P models and data follow their own readmes (not MIT by default); the Turkish voice's source/license is UNVERIFIED (likely the Piper dfki voice = non-commercial)

4. Turkish summary

NeedCommercial-safe, verified on paperNotes
STT streamingatasoglu/seda-v0.1 via sherpa-onnx (Apache-2.0 acoustic model; FLEURS-TR 11.52 WER), NVIDIA Nemotron 3.5 ASR Streaming (OpenMDW-1.1; FLEURS 11.2-12.3 WER)seda: CPU, lowercase unpunctuated output, read-speech training, phone audio unmeasured. Nemotron: needs NIM (Linux, >= 16 GB nominal, no WSL2) or custom serving. Qwen3-ASR (Apache-2.0) via vLLM realtime: Turkish listed, quality/latency unpublished
STT non-streaming (commit-based)Whisper via Speaches / WhisperLive (MIT)no interim; latency not published
TTSChatterbox Multilingual (MIT) via Chatterbox-TTS-Serverquality and TTFA on Turkish unmeasured
TTS non-commercial onlyPiper tr_TR-dfki (CC BY-NC-SA), XTTS-v2 (CPML), MMS-TTS-tur (CC-BY-NC)fine for maintainer-only dev, not for a product
No TurkishKokoro, Kyutai STT/TTS, Parakeet v3, Canary v2, Voxtral STT/TTS, Moonshine STT, Qwen3-TTS, Orpheus

5. Gotchas found

  1. Sample rates differ per server: OpenAI-realtime 24 kHz, NIM and vLLM 16 kHz, Speaches treats incoming base64 as 24 kHz regardless of what you intended (source), sherpa-onnx float32 at the model rate (usually 16 kHz) and does not resample, Kyutai 24 kHz float32, Vosk configured per connection (default 8000), WhisperLive 16 kHz. TTS: Kokoro/OpenAI pcm 24 kHz, Kyutai 24 kHz, Piper dfki 22,050 Hz. Brisvox needs per-adapter resamplers (see docs/providers/rubato.md).
  2. Client-driven finalize is not uniform: OpenAI/NIM/Speaches commit; Vosk {"reset":1}; sherpa-onnx "Done" ends the connection (use one connection per utterance, or rely on its endpoint rules); Kyutai needs a Marker plus trailing silence; WhisperLive END_OF_AUDIO ends the session; vLLM mid-session flush unclear.
  3. Speaches Realtime is not a streaming-transcript server: no deltas, its own server VAD (threshold 0.9 default) unless turn_detection is disabled (how to disable: UNVERIFIED), and it transcribes the committed buffer in one call (source). Its docs say real-time use needs CUDA.
  4. Kokoro-FastAPI is request-scoped: Brisvox must send one request per LLM sentence/clause and cancel by closing the HTTP connection. Voices, not text, decide language: no Turkish.
  5. Nemotron NIM: WSL2 unsupported, 16 GB nominal minimum, the container needs an NGC key to pull; the realtime page lists both 100 and 10,000 as max concurrent connections (page inconsistency).
  6. Licenses that block commercial use: Piper Turkish voice, XTTS-v2, F5-TTS weights, Fish S2, Voxtral TTS, MMS-TTS, duxx Turkish Zipformer, Moonshine non-English legacy STT. GPL-3.0: Piper engine (run as a separate process only).
  7. Model-card WERs are not telephony WERs: all Turkish figures are read speech (FLEURS, Common Voice); 8 kHz G.711 calls are untested by every source above.
  8. Maintenance: Kyutai repo quiet since 2026-01; Piper seeking maintainers; Speaches releases lag master by months (last tag 2025-12-27 vs master pushed 2026-10-13); Kokoro-FastAPI README labels master "development focused".

6. Recommendation (Brisvox judgement, built on the facts above)

Protocols to implement, in order

  1. OpenAI /v1/audio/speech streaming-PCM TTS client (24 kHz s16le, chunked; per-sentence requests; cancel = drop connection). One adapter covers Kokoro-FastAPI, Speaches, vLLM-Omni (Voxtral TTS if licensing allows), Chatterbox-TTS-Server.
  2. OpenAI Realtime transcription STT client with a dialect table (OpenAI 24 kHz, NVIDIA NIM 16 kHz with ?intent=transcription, Speaches 24 kHz completed-only, vLLM event names). Client VAD -> input_audio_buffer.commit.
  3. A native sherpa-onnx streaming WebSocket STT client (tiny protocol: float32 binary, "Done", JSON {text, segment}) for CPU-only / Turkish.
  4. Later: Kyutai msgpack STT + TTS clients (best incremental text-in TTS, English/French only).

STT, first two

  • #1 sherpa-onnx streaming server (Apache-2.0, true streaming, CPU-friendly, 200 connections, built-in endpointing, covers English zipformers and the Turkish seda model). Trade-offs: proprietary protocol, accuracy below large GPU models, Turkish model is community-made, read speech only, lowercase/no punctuation, python server example only (production hardening and the C++ server's protocol UNVERIFIED).
  • #2 OpenAI-realtime-dialect server: NVIDIA Speech NIM with Nemotron 3.5 for the GPU/production Turkish+English path (best published Turkish streaming numbers, 80-1120 ms chunks), and Speaches (Whisper) as the easy local/dev path on the maintainers' box. Trade-offs: NIM = NGC key, container terms, >= 16 GB VRAM nominal, no WSL2 (so not on the maintainers' Docker-in-WSL2 setup); Speaches = no interim transcripts, Blackwell compatibility of its CUDA 12.6 image UNVERIFIED.

TTS, first two

  • #1 Kokoro-FastAPI via /v1/audio/speech (English; Apache-2.0; documented cu128 image for the RTX 5070; ~300 ms GPU first-chunk claim; CPU workable on fast CPUs only).
  • #2 Chatterbox-TTS-Server (MIT) for Turkish, multilingual quality unknown, streaming is chunk-level, TTFA unmeasured, GPU needed. Use Piper tr_TR-dfki only as a non-commercial CPU fallback for the maintainers' dev. Kyutai TTS is the best text-incremental fit but English/French only.

Fully local stacks

  • English: STT sherpa-onnx streaming zipformer en (CPU) or Kyutai STT 1B en/fr on the GPU (0.5 s delay + semantic VAD; VRAM fit UNVERIFIED); TTS Kokoro-FastAPI (latest-cu128 on the 5070, CPU image for servers); CPU-only alternative TTS: Pocket TTS (MIT, ~200 ms claim, EN).
  • Turkish: STT sherpa-onnx + seda-v0.1 (CPU, Apache-2.0; median first word 0.70 s); GPU upgrade Nemotron 3.5 ASR via NIM on a Linux server (not WSL2); TTS Chatterbox Multilingual (MIT, GPU) with Piper dfki for non-commercial dev. No verified fully-local, commercial-safe, CPU-only Turkish TTS exists in this survey.

Follow-up measurements. (a) First-audio latency of Kokoro-FastAPI and Chatterbox (Turkish) on the RTX 5070: done, see docs/providers/openai-speech.md ("Live"). (b) seda-v0.1 on sherpa-onnx's own server: done for 16 kHz speech (docs/providers/sherpa-onnx.md, "Live"); 8 kHz phone audio is still unmeasured. (c) Speaches on Blackwell and (d) a Turkish MOS/WER listening test: not done.

Sources (all fetched 2026-10-14)

Speaches: github.com/speaches-ai/speaches; speaches.ai; speaches.ai/usage/realtime-api/; api.github.com/repos/speaches-ai/speaches (+/releases); raw.githubusercontent.com/speaches-ai/speaches/master/src/speaches/{realtime/input_audio_buffer_event_router.py,realtime/input_audio_buffer.py,realtime/session.py,routers/speech.py,Dockerfile}; github.com/speaches-ai/speaches/blob/master/docs/{installation,usage/speech-to-text,usage/text-to-speech}.md. whisper.cpp: github.com/ggml-org/whisper.cpp/blob/master/examples/server/README.md; api.github.com/repos/ggml-org/whisper.cpp/releases. WhisperLive: github.com/collabora/WhisperLive; raw.githubusercontent.com/collabora/WhisperLive/main/whisper_live/server.py; api.github.com/repos/collabora/WhisperLive. sherpa-onnx: github.com/k2-fsa/sherpa-onnx; api.github.com/repos/k2-fsa/sherpa-onnx/releases; raw.githubusercontent.com/k2-fsa/sherpa-onnx/master/python-api-examples/streaming_server.py; k2-fsa.github.io/sherpa/onnx/websocket/index.html; .../pretrained_models/online-transducer/index.html; .../tts/pretrained_models/{index,vits}.html. Turkish ASR: huggingface.co/atasoglu/seda-v0.1; huggingface.co/duxx/turkish-stt-zipformer; huggingface.co/datasets/issai/Turkish_Speech_Corpus. Kyutai: github.com/kyutai-labs/delayed-streams-modeling (+ raw README, scripts/stt_from_file_rust_server.py, scripts/tts_rust_server.py); huggingface.co/kyutai/{stt-1b-en_fr,tts-1.6b-en_fr}; api.github.com/repos/kyutai-labs/delayed-streams-modeling; crates.io/api/v1/crates/moshi-server; github.com/kyutai-labs/pocket-tts. Vosk: github.com/alphacep/vosk-server; raw.githubusercontent.com/alphacep/vosk-server/master/websocket/asr_server.py; alphacephei.com/vosk/server; alphacephei.com/vosk/models. NVIDIA: huggingface.co/nvidia/{parakeet-tdt-0.6b-v3,canary-1b-v2,nemotron-speech-streaming-en-0.6b,nemotron-3.5-asr-streaming-0.6b}; docs.nvidia.com/nim/speech/latest/reference/api-references/asr/realtime-asr.html; docs.nvidia.com/nim/speech/latest/asr/deploy-asr-models/nemotron-asr-streaming.html; docs.nvidia.com/nim/speech/latest/reference/support-matrix/asr.html; catalog.ngc.nvidia.com/orgs/nim/teams/nvidia/containers/nemotron-asr-streaming; perspectives.nvidia.com/nemotron-speech/task/faq/asr-models-streaming-transcription-real-time-agent-response/; www.together.ai/models/nemotron-3-asr-streaming-0-6b (secondary). vLLM / Mistral / Qwen: docs.vllm.ai/en/latest/serving/online_serving/speech_to_text/; docs.vllm.ai/en/latest/examples/speech_to_text/realtime/; docs.vllm.ai/en/latest/models/supported_models/; docs.vllm.ai/en/latest/api/vllm/model_executor/models/qwen3_asr_realtime/; vllm.ai/blog/streaming-realtime; huggingface.co/mistralai/{Voxtral-Mini-4B-Realtime-2602,voxtral-4b-tts-2603}; huggingface.co/Qwen/{Qwen3-ASR-1.7B,Qwen3-TTS-12Hz-1.7B-CustomVoice}; github.com/QwenLM/Qwen3-ASR. Moonshine: github.com/moonshine-ai/moonshine; raw LICENSE; moonshine-voice.readthedocs.io/en/latest/using/text-to-speech/; core/moonshine-tts/data/README.md (raw). OpenAI: developers.openai.com/api/docs/guides/realtime-transcription; developers.openai.com/api/docs/guides/text-to-speech; github.com/openai/whisper. TTS: github.com/remsky/Kokoro-FastAPI (+ raw README, api/src/routers/openai_compatible.py, API releases); huggingface.co/hexgrad/Kokoro-82M (+ VOICES.md); github.com/OHF-Voice/piper1-gpl (+ raw docs/API_HTTP.md, docs/VOICES.md, API repo metadata); huggingface.co/rhasspy/piper-voices (tr/tr_TR tree via API; dfki MODEL_CARD); github.com/resemble-ai/chatterbox; huggingface.co/ResembleAI/chatterbox; github.com/devnen/Chatterbox-TTS-Server; github.com/idiap/coqui-ai-TTS (+ docs/source/models/xtts.md, releases API); huggingface.co/coqui/XTTS-v2; tts-hub.github.io/cpml; github.com/coqui-ai/xtts-streaming-server; github.com/SWivid/F5-TTS; huggingface.co/fishaudio (and /s2-pro); github.com/canopyai/Orpheus-TTS; huggingface.co/collections/canopylabs/orpheus-multilingual-research-release-67f5894cd16794db163786ba; github.com/k2-fsa/OmniVoice; huggingface.co/facebook/mms-tts-tur; arxiv.org/abs/2607.09530; nvidia.com RTX 5070 family page.

OpenAI Realtime — speech-to-speech (S2S)

  • Docs (all fetched 2026-10-13; the old platform.openai.com/docs/... URLs redirect with a 301 to developers.openai.com/api/...):
    • Overview and migration: https://developers.openai.com/api/docs/guides/realtime
    • WebSocket transport: https://developers.openai.com/api/docs/guides/voice-websockets?api=realtime
    • Conversations (function calling, interruption, formats, limits): https://developers.openai.com/api/docs/guides/realtime-conversations
    • Voice activity detection: https://developers.openai.com/api/docs/guides/realtime-vad
    • Tools: https://developers.openai.com/api/docs/guides/realtime-mcp
    • Client events: https://developers.openai.com/api/reference/resources/realtime/client-events
    • Server events: https://developers.openai.com/api/reference/resources/realtime/server-events
    • The platform.openai.com API reference pages answer 403 to an unauthenticated fetch; the developers.openai.com reference pages above are the readable copy.
  • Auth: Authorization: Bearer <OPENAI_API_KEY> on the WebSocket upgrade request, from a server (never a browser). Browsers use an ephemeral key (POST /v1/realtime/client_secrets) as a WebSocket subprotocol openai-insecure-api-key.<key>; Brisvox is a server, so it uses the header. Optional OpenAI-Safety-Identifier header (a stable, hashed end-user id) on the connection request. The GA interface must NOT send OpenAI-Beta: realtime=v1 (that header selected the removed beta interface).
  • Endpoint(s) / regions: wss://api.openai.com/v1/realtime?model=<model>. Region/data-residency hosts: UNVERIFIED (not on the pages read). WebRTC (/v1/realtime/calls) and SIP exist; Brisvox uses WebSocket only, so the transport stays our own.
  • Models: gpt-realtime-2.1 is the model of every current doc example; gpt-realtime-2 and gpt-realtime are named (image input section); gpt-realtime-2025-08-28 appears in a session.created example. gpt-realtime-whisper / gpt-live-transcribe are transcription-only models (turn detection must be omitted or null). Exact pricing/latency: UNVERIFIED (not read). The model is chosen in the URL query and cannot change in session.update.
  • Voices: alloy ash ballad coral echo sage shimmer verse marin cedar (marin/cedar recommended). voice cannot change after the model has produced audio in the session.
  • Max session duration: 60 minutes ("The maximum duration of a Realtime session is 60 minutes", conversations guide, fetched 2026-10-13). What happens at the limit (error code, close) is NOT documented: UNVERIFIED. Handled by the engine: session_limit reports 60 minutes, the stage opens a replacement session seeded from its local mirror two minutes before and switches over while the bot is idle; if the session dies anyway the normal reconnect also seeds.
  • History seeding: conversation.item.create (fetched 2026-10-13, client-events reference): message items with role system / user (content input_text) / assistant (content output_text or output_audio; "the type depends on the session's output_modalities"; the event "cannot populate assistant audio messages"), function_call (name, arguments JSON string, optional call_id) and function_call_output (call_id, output); status has no effect; the item is appended unless previous_item_id is given. The adapter sends one event per history item after session.updated, before any audio. UNVERIFIED: whether an assistant message with output_text is accepted in a session whose output_modalities is ["audio"] (the guide has no example); seed_assistant_as_system switches such turns to system messages ("The assistant said earlier: ...") if the server rejects them. Rejections arrive as error events (delivered as warnings); the session still works.
  • Protocol: JSON text messages both ways, every event has type and an optional client-chosen event_id. Audio is base64 inside JSON (input_audio_buffer.append, up to 15 MB per chunk; no server acknowledgement). Server sends session.created right after the upgrade.

Client events (we send)

eventfields (verified)
session.updatesession: {type:"realtime", instructions, tools:[{type:"function",name,description,parameters}], tool_choice:"auto", output_modalities, audio:{input:{format, turn_detection, transcription, noise_reduction}, output:{format, voice, speed}}}. Only included fields change. session.type is required on the GA interface.
input_audio_buffer.appendaudio (base64)
input_audio_buffer.commitnone. Creates the user message item from the buffer; with server VAD it commits automatically; fails on an empty buffer.
input_audio_buffer.clearnone; server answers input_audio_buffer.cleared
conversation.item.createitem, optional previous_item_id ("root" = front). Message: {type:"message",role:"user",content:[{type:"input_text",text}]}. Tool result: {type:"function_call_output",call_id,output} (output is a string).
conversation.item.truncateitem_id, content_index (use 0), audio_end_ms. Truncates the assistant audio to what the user heard and deletes the server-side transcript of the item.
conversation.item.deleteitem_id
response.createoptional response overrides (instructions, tools, conversation:"none" for out of band, output_modalities, input, metadata)
response.canceloptional response_id (omitted: the in-progress response of the default conversation)
output_audio_buffer.clearWebRTC/SIP only. Not used.

Server events (we handle)

eventfields (verified)
session.created / session.updatedsession (id, model, audio.input.format, audio.output.{format,voice}, tools, ...)
errorerror:{type, code, message, param, event_id} (event_id is the client event that failed)
input_audio_buffer.speech_startedaudio_start_ms, item_id
input_audio_buffer.speech_stoppedaudio_end_ms, item_id
input_audio_buffer.committeditem_id, previous_item_id
response.createdresponse:{id, status:"in_progress", output:[], ...}
response.output_audio.deltaresponse_id, item_id, output_index, content_index, delta (base64 PCM)
response.output_audio.doneresponse_id, item_id, output_index, content_index
response.output_audio_transcript.delta / .donesame ids; delta / transcript
response.function_call_arguments.doneresponse_id, item_id, output_index, call_id, name, arguments (JSON string). The conversations guide does not mention this event (it reads the call from response.done); the reference lists it. The adapter accepts BOTH and deduplicates by call_id.
response.output_item.doneresponse_id, output_index, item (a function_call item has call_id, name, arguments)
response.doneresponse:{id, status: completed|cancelled|failed|incomplete, status_details:{type,reason,error}, output:[items], usage}
conversation.item.truncateditem_id, content_index, audio_end_ms
conversation.item.added / .doneitem
rate_limits.updatedrate_limits:[{name,limit,remaining,reset_seconds}]
conversation.item.input_audio_transcription.completedUNVERIFIED (not in the pages read); the adapter reads item_id and transcript if present and ignores it otherwise.

Audio formats

  • Session audio format is an object {type:"audio/pcm", rate:24000}; the session.created example shows audio/pcm at 24000 for input and output. PCM16 little-endian mono, no container header, even byte count per chunk.
  • G.711: the docs show "audio/pcmu" (output example); audio/pcma and the 8 kHz rate for both G.711 variants are documented for the separate GPT-Live API (wss://api.openai.com/v1/live/sessions, first message session.start); for the Realtime API this is UNVERIFIED. The adapter requests audio/pcm at 24 kHz only; the engine resamples. G.711 passthrough is a follow-up.
  • The format can be set per session (audio.input.format, audio.output.format) or per response.
  • Input transcription: UNVERIFIED. The conversations guide only says "input transcription (if enabled)". The adapter sends audio.input.transcription: {model, language} (the layout is from memory of the session object, not from the pages read) only when the session asks for transcription AND OpenAiRealtimeConfig::transcription_model is set; it is off by default. The completion event conversation.item.input_audio_transcription.completed is UNVERIFIED too.

Turn detection (session.audio.input.turn_detection)

  • server_vad (default for S2S models): threshold (0..1, default 0.5), prefix_padding_ms (300), silence_duration_ms (200 in the session.created example, 500 in the VAD guide example), create_response (bool), interrupt_response (bool). idle_timeout_ms: UNVERIFIED (not on the guide page).
  • semantic_vad: eagerness low|medium|high|auto (auto = medium), same two booleans.
  • null (manual): the client commits (input_audio_buffer.commit) and asks for a response (response.create). Required for the transcription-only models.

Interruption and truncation

  • With VAD enabled the server detects user speech, cancels the in-progress response (response.done with status:"cancelled"; the guide also names a response.cancelled event that the server-events reference does not list: the adapter treats it as a synonym of a cancelled response.done).
  • WebRTC/SIP: the server truncates unplayed audio itself. WebSocket: the client must stop playback, note how much audio was played and send conversation.item.truncate with audio_end_ms = played milliseconds. The server cannot know how much the caller heard, because it generates faster than real time and the audio sits in our and the carrier's buffers. The transcript of the truncated item is deleted server-side; the truncated transcript is NOT given back ("not precisely aligned").
  • response.cancel on a response that already finished answers an error event (code UNVERIFIED, probably response_cancel_not_active); the adapter treats any error that carries the event_id of one of our response.cancel/conversation.item.truncate events as benign.

Function calling

  1. Tools in session.update (or per response.create): {type:"function", name, description, parameters:<JSON Schema>}, tool_choice:"auto".
  2. The model's call arrives as a function_call item (type, name, arguments JSON string, call_id) in response.output_item.done / response.done.response.output[], and as response.function_call_arguments.done (+ .delta stream).
  3. We run the tool, then send conversation.item.create with {type:"function_call_output", call_id, output:"<string>"}, then response.create so the model speaks the result. The model does not continue by itself after a function call.

Keepalive / timeouts

  • No application-level keepalive is documented. The adapter relies on the audio stream (caller audio flows continuously) and WebSocket ping replies. UNVERIFIED: server idle close behaviour.
  • Rate limits arrive as rate_limits.updated. Concurrency limits: UNVERIFIED.

Turkish support

UNVERIFIED for the speech-to-speech models in the pages read (the models are multilingual; no language list was found). Input transcription (separate Whisper-class model) language hint: UNVERIFIED. A live run must test Turkish before it is claimed.

Gotchas found during implementation

  • Audio deltas arrive faster than real time, so a barge-in at wall-clock t happens when the server has already sent seconds of audio: the heard duration must come from the transport's playout position (PlayoutBook), never from the count of deltas received.
  • audio_end_ms larger than the audio the server holds for the item is an error; the engine clamps to the audio it received for the item.
  • Do not send response.create for a tool result of a superseded response (the user is already speaking again); only add the output item.
  • Every function_call should get a function_call_output, also interrupted ones, so the server-side history stays well formed.

Gemini Live — speech-to-speech (S2S)

  • Docs (all fetched 2026-10-13):
    • WebSocket reference (client/server messages): https://ai.google.dev/api/live
    • Live API guide (models, limits, VAD, interruptions, tools, transcription, formats, languages): https://ai.google.dev/gemini-api/docs/live-guide
    • Session management (lifetime, GoAway, compression, resumption): https://ai.google.dev/gemini-api/docs/live-session
    • Tool use: https://ai.google.dev/gemini-api/docs/live-tools
    • Capabilities (voice, transcription): https://ai.google.dev/gemini-api/docs/live-api/capabilities
    • Raw WebSocket quick start (endpoint, key, setup, audio, tool response JSON): https://ai.google.dev/gemini-api/docs/live-api/get-started-websocket
    • FunctionDeclaration / FunctionCall / FunctionResponse / Blob are defined on the GenerateContent reference, which was NOT read (see UNVERIFIED below).
  • Auth: API key as the key query parameter of the WebSocket URL ("Authentication is handled by including your API key as a query parameter"). The pages do not mention a header option; x-goog-api-key as a header on the WebSocket upgrade is UNVERIFIED (the adapter offers it as opt-in key_in_header). Browsers/clients use ephemeral tokens (BidiGenerateContentConstrained, access_token); Brisvox is a server and uses the key. The key is URL material: the adapter never logs the URL and error texts never contain it; keys are restricted to RFC 3986 unreserved characters [A-Za-z0-9._-] (Google's newer keys start with AQ.; live 2026-10-13).
  • Endpoint(s) / regions: wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=<KEY>. Regions/data residency: UNVERIFIED (not on the pages read). Vertex AI has a separate Live endpoint: out of scope.
  • Models (live-guide): gemini-3.8-live (default for low-latency voice agents; thinkingLevel must be omitted), gemini-3.8-live-extended-thinking (supports thinkingLevel), gemini-3.1-flash-live-preview (legacy, "migrate to 3.8"). The model field is models/<name>. The adapter default is gemini-3.8-live.
  • Protocol: JSON messages; each client message has exactly one of setup, clientContent, realtimeInput, toolResponse. The first message is setup and the configuration cannot be changed afterwards; wait for setupComplete before anything else.
    • setup: model, generationConfig {responseModalities ["AUDIO"], speechConfig {voiceConfig {prebuiltVoiceConfig {voiceName}}}}, systemInstruction {parts [{text}]}, tools [{functionDeclarations [{name, description, parameters, behavior}]}], realtimeInputConfig {automaticActivityDetection {disabled, startOfSpeechSensitivity (START_SENSITIVITY_HIGH|LOW), endOfSpeechSensitivity (END_SENSITIVITY_HIGH|LOW), prefixPaddingMs, silenceDurationMs}, activityHandling (START_OF_ACTIVITY_INTERRUPTS | NO_INTERRUPTION), turnCoverage}, inputAudioTranscription {}, outputAudioTranscription {}, sessionResumption {handle}, contextWindowCompression {slidingWindow {targetTokens}, triggerTokens}, historyConfig {initialHistoryInClientContent}, proactivity. Unsupported in generationConfig: responseMimeType, responseSchema, stopSequence and others (see the reference).
    • realtimeInput: audio {data (base64), mimeType}, video, text, activityStart {}, activityEnd {} (only valid with automatic detection disabled), audioStreamEnd (bool, with automatic detection on).
    • clientContent: turns [Content {role user|model, parts [{text}]}], turnComplete (bool; true starts generation from the accumulated prompt).
    • toolResponse: functionResponses [{id, name, response {object}}]. Quick start: {"toolResponse":{"functionResponses":[{"name":"...","id":"...","response":{}}]}}. The tool guide's SDK samples put {"result": "ok"} in response and (non-blocking functions only) scheduling inside it (INTERRUPT, WHEN_IDLE, SILENT).
    • Server messages (each may also carry usageMetadata and voiceActivity): setupComplete; serverContent {modelTurn {parts [{inlineData {mimeType, data}}]}, generationComplete, turnComplete, interrupted, inputTranscription {text}, outputTranscription {text}, waitingForInput, ...}; toolCall {functionCalls [{id, name, ...}]}; toolCallCancellation {ids}; goAway {timeLeft}; sessionResumptionUpdate {newHandle, resumable}; voiceActivity {type ACTIVITY_START|ACTIVITY_END, audioOffset}.
    • Frames: the service is known to send its JSON in WebSocket BINARY frames; UNVERIFIED on the pages read. The adapter accepts text and binary frames.
  • Audio: input raw 16-bit little-endian PCM, mime audio/pcm;rate=16000; "the API resamples other rates if you set the matching rate in the MIME type". Output 24 kHz PCM. The adapter labels the engine's audio with its real rate (the engine's provider rate is 24 kHz, so audio/pcm;rate=24000) and requires 24 kHz output.
  • Keepalive / timeouts: connection lifetime "around 10 minutes"; the server sends goAway (timeLeft, a protobuf Duration such as "50s") before the connection is terminated as ABORTED. Without compression audio-only sessions are limited to 15 minutes (audio+video: 2). Idle/ping behaviour of the server: UNVERIFIED. The adapter pings after keepalive_interval of inbound silence and fails the session when nothing arrives within io_timeout.
  • Finalize / flush / cancel semantics:
    • Turn detection: automatic activity detection by default (silenceDurationMs default about 800 ms, 500 to 800 recommended). With it disabled the client sends activityStart / activityEnd (silence threshold at least 500 ms recommended). Hybrid: client VAD plus audioStreamEnd.
    • Interruption: serverContent.interrupted = true; "only content already sent to the client stays in session history"; pending function calls are discarded and the server reports the cancelled ids (toolCallCancellation). generationComplete is not sent for an interrupted turn. There is no client message that cancels a response and none that truncates the history to what the caller heard. Whether a client activityStart interrupts: UNVERIFIED but implied by activityHandling: START_OF_ACTIVITY_INTERRUPTS.
    • Tools: "the Live API doesn't support automatic tool response handling": the client answers every call. gemini-3.8-live is asynchronous by default (behavior NON_BLOCKING or BLOCKING); 3.8-live-extended-thinking non-blocking only; the legacy 3.1 model is sequential ("the model will not start responding until you've sent the tool response").
  • Timestamps / interim results / tool-call deltas: transcripts arrive as chunks in inputTranscription / outputTranscription; interimInputTranscription also exists. Tool calls arrive whole in one toolCall message (no deltas).
  • Concurrency / rate limits: the pages link to a separate rate-limit page that was NOT read: UNVERIFIED. Context window: 128k tokens for native audio models, 32k for others. Session resumption tokens are valid "2 hr after the last session's termination"; resumption and compression "allow unlimited extension of session duration".
  • Turkish support: Turkish (tr) is in the list of 99 supported languages; native audio models pick the language automatically and do NOT accept an explicit language code ("restrict via system instructions"). The adapter therefore ignores S2sConfig::language. Quality: UNVERIFIED .
  • Gotchas found during implementation (design decisions, see docs/S2S.md):
    • No response or item ids exist; the adapter mints gr<n> and gr<n>-audio. A response ends at turnComplete, interrupted, or when a toolCall arrives (the model then waits, the answer begins a new response). UNVERIFIED whether the server also sends a turnComplete after a toolCall; a turnComplete with no open response is ignored.
    • Truncate has no wire form: nothing is sent. The server's history ends where the server stopped generating, not where the caller stopped listening (generation runs ahead of playback), so after a barge-in Gemini's own history can contain words the caller did not hear. The engine's local mirror is exact and is what seeds the next session (rotation every ~10 minutes or any reconnect), which bounds the divergence.
    • CancelResponse has no wire form either: the adapter ends the response locally (ResponseDone(Cancelled)) and drops the rest of it until the turn ends; the caller's next activityStart interrupts the server.
    • After activityEnd and after a toolResponse the server answers by itself, so the adapter absorbs the engine's following CreateResponse. A CreateResponse after only a note is sent as {"clientContent":{"turns":[],"turnComplete":true}} (UNVERIFIED that this starts generation).
    • Tool answers are batched: one toolResponse carries the answers of all calls of one toolCall message (sent when the last one is answered or at CreateResponse). Results of calls the server cancelled or interrupted are not sent.
    • Notes (Note) go as a clientContent user turn "[note] ..." with turnComplete: false. Whether clientContent is accepted in the middle of a conversation by 3.8 is UNVERIFIED (the guide describes it for seeding the context).
    • History seeding: after setupComplete, one clientContent with the turns (user/model), turnComplete: false, and historyConfig.initialHistoryInClientContent: true in the setup. Tool calls and results of the history are written as TEXT turns ([tool call] name(args), [tool result] name: output) because the structured shape of past calls is UNVERIFIED. The historyConfig semantics themselves are only listed in the reference, not explained.
    • Tool declarations use parameters (the guide's hint). Whether the schema must be the OpenAPI subset or may be full JSON Schema (parametersJsonSchema) is UNVERIFIED; additionalProperties and similar may be rejected. A live run must test with the engine's real schemas.
    • Session limits are handled by the engine, not the adapter: session_limit reports connection_lifetime (10 minutes), the engine opens a replacement session seeded from its mirror a margin before it (2 minutes) or at goAway, and switches over while the bot is idle. The adapter does not use Gemini's own session resumption (handles); that would keep the audio-native context but is a second recovery path for the same problem.
    • voiceActivity messages (ACTIVITY_START/END) become SpeechStarted/SpeechStopped. Whether the server sends them by default is UNVERIFIED; when it does not, interrupted yields a SpeechStarted and the start of the next response yields the SpeechStopped.

Live verification (2026-10-13)

Real key, gemini-3.8-live, FLEURS speech at real time (16 kHz in, 24 kHz out), provider VAD (crates/brisvox-providers/tests/gemini_live/live.rs, BRISVOX_LIVE_TESTS=1). Confirmed:

  • Auth: the key query parameter works, and so does x-goog-api-key on the upgrade (key_in_header = true). Keys may contain '.' (the adapter rejected AQ. keys before this).
  • voiceActivity is sent by default: order SpeechStarted, SpeechStopped, UserTranscript (input transcription), ResponseStarted, Audio/AssistantTranscript deltas, AudioDone, final AssistantTranscript, ResponseDone(Completed).
  • Tools: the declaration the adapter sends is accepted; toolCall arrives BEFORE the turn's turnComplete (ToolCall then ResponseDone(Completed)); after toolResponse + the adapter's CreateResponse a new response starts with audio (which of the two triggers it is not isolated).
  • Mid-session clientContent (S2sControl::Note) is accepted without warning and is used by the model (it repeated a name given only in the note).
  • Barge-in: caller speech during the answer -> interrupted -> ResponseDone(Cancelled) 216 ms after the barge-in audio started.
  • Latency (this machine, Istanbul): session setup 0.7-1.05 s; end of caller speech -> first audio 1.2-1.9 s (includes the provider VAD's end-of-speech wait); tool result -> first answer audio 0.76 s.
  • Turkish: input transcript exact ("%80'lik" normalised), spoken answer in natural Turkish. Still UNVERIFIED: regions/data residency; server idle/ping behaviour; the 10-minute connection lifetime and goAway (not reached in a short test).
  • 2026-10-14 (live tool-calling run): tool declarations must use parametersJsonSchema for full JSON Schema; parameters (OpenAPI subset) rejected a schema with additionalProperties/minimum, and the server refused the whole setup (every connect failed). Fixed in gemini_live/wire.rs. Parallel calls (3 in one turn) and a 3-round chain then passed live.

WebRTC stack for a server-side Rust voice agent — TRANSPORT

  • Researched and fetched: 2026-10-08. Every number below was read from the cited page on that date. Anything not read from a page is marked UNVERIFIED.
  • Purpose: choose the library for the WebRTC transport (WHIP-style HTTP signaling, browser demo, Opus encode/decode, one peer per call) behind the sans-IO CallEnds boundary of crates/brisvox-transport (docs/ARCHITECTURE.md "Transport").
  • Auth: n/a for the libraries. WHIP uses HTTP Bearer tokens (RFC 9725 section 4.7).
  • Endpoint(s) / regions: n/a (we host the signaling endpoint ourselves).
  • Protocol (messages in/out, with field names): see section 5 (WHIP) and section 4 (Opus RTP).
  • Keepalive / timeouts: ICE consent/keepalive is inside the library; see per-library notes.
  • Finalize / flush / cancel semantics: WHIP DELETE on the session URL ends a session; see section 5.
  • Timestamps / interim results / tool-call deltas: n/a.
  • Concurrency / rate limits: n/a (CPU/socket bound; see performance notes).
  • Turkish support: n/a (transport only; audio is language-neutral).
  • Gotchas found during implementation (2026-10-08; details in section 10):
    • Event::MediaAdded is not emitted by the first poll_output after accept_offer; find the audio section in the offer text (a=mid) and ask rtc.media(mid).
    • ICE-lite declares a silent browser gone only after 15 s without a STUN binding request (RECENT_BINDING_REQUEST in the is crate): hang-up by DELETE or DTLS close is immediate, a vanished browser takes about 15 s plus disconnect_grace.
    • RtcConfig::build panics on failure (expect); the DTLS certificate takes 20-36 ms to generate (rcgen): one certificate per server, setup on spawn_blocking.
    • With rust-crypto, dimpl still builds aws-lc-rs (through rcgen) for certificate generation; it compiled here without NASM.
    • opus 0.4.0 / opusic-sys 0.7.5 build libopus with cmake; it works on this machine because the cmake crate finds the Visual Studio copy, but the build fails when the target directory path is long (MSBuild 260-character limit inside .../out/build/CMakeFiles/...): keep CARGO_TARGET_DIR short on Windows.
    • Closing a socket with unread request data resets the connection: the signaling server sends FIN and drains briefly after every response, so a 413 reaches the client.

Sources (all fetched 2026-10-08)

  • crates.io API: webrtc, webrtc/0.21.0, rtc, str0m, str0m/0.24.1, dimpl, livekit, libwebrtc, webrtc-sys-build, datachannel, gstreamer-webrtc, rustrtc, opus, opus/0.4.0, opusic-sys, audiopus, audiopus_sys (+ /dependencies), rusty-opus, opus-rs, ruopus (https://crates.io/api/v1/crates/<name>).
  • GitHub API repo metadata: webrtc-rs/webrtc, webrtc-rs/rtc, algesten/str0m, livekit/rust-sdks, lerouxrgd/datachannel-rs; commits lists of str0m and webrtc-rs/webrtc; issue webrtc-rs/webrtc#836.
  • READMEs: https://raw.githubusercontent.com/webrtc-rs/webrtc/master/README.md, .../webrtc-rs/rtc/master/README.md, .../webrtc-rs/webrtc/master/CHANGELOG.md, .../algesten/str0m/main/README.md, .../algesten/str0m/main/CHANGELOG.md, .../algesten/str0m/main/examples/http-post.rs, .../livekit/rust-sdks/main/README.md, .../livekit/rust-sdks/main/webrtc-sys/build/src/lib.rs, .../paullouisageneau/libdatachannel/master/README.md and BUILDING.md, .../lerouxrgd/datachannel-rs/master/README.md, .../restsend/rustrtc/main/README.md, .../DoumanAsh/opusic-sys/master/README.md, .../SpaceManiac/opus-rs/master/README.md, https://github.com/remade-with-rust/rusty-opus.
  • docs.rs: webrtc 0.21.0 (crate, PeerConnectionBuilder, PeerConnectionEventHandler, media_stream), rtc 0.21.0 (crate, SettingEngine, SettingEngineBuilder), rtc-interceptor, str0m 0.24.1 (crate, RtcConfig, MediaData, Writer, SdpOffer, SdpAnswer), libwebrtc, gst-plugin-webrtc, opus 0.4.0 (Decoder, Encoder).
  • Specs: https://www.rfc-editor.org/rfc/rfc9725.html (WHIP), https://www.rfc-editor.org/rfc/rfc7587.html (Opus RTP), https://datatracker.ietf.org/doc/draft-ietf-wish-whep/ (WHEP), https://opus-codec.org/downloads/, https://opus-codec.org/license/, https://opus-codec.org/docs/opus_api-1.5/group__opus__decoder.html, https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia, https://gstreamer.freedesktop.org/documentation/webrtc/index.html, https://gstreamer.freedesktop.org/documentation/rswebrtc/index.html, https://aws.github.io/aws-lc-rs/requirements/index.html.

1. Candidates at a glance

OptionVersion (crates.io)Last releaseLicenseRepo activity (GitHub API)Downloads (total / recent)
str0m0.24.12026-10-03MIT OR Apache-2.0 (crates.io; GitHub repo metadata shows "MIT")pushed 2026-10-04, 634 stars, 36 open issues2,428,919 / 1,002,780
webrtc (webrtc-rs, async layer)0.21.02026-09-19MIT/Apache-2.0 (crates.io; GitHub shows Apache-2.0)pushed 2026-10-03, 5,158 stars, 7 open issues7,664,986 / 2,690,025 (history includes the old 0.x line; 0.21.0 itself 18,000)
rtc (webrtc-rs sans-IO core)0.21.02026-09-19MIT/Apache-2.0pushed 2026-10-04, 164 stars, 10 open issues948,062 / 921,281
livekit + libwebrtc (Google libwebrtc via FFI)livekit 0.9.4, libwebrtc 0.3.512026-10-07Apache-2.0pushed 2026-10-07, 495 stars, 157 open issueslibwebrtc 3,254,718 / 1,439,204
datachannel (libdatachannel wrapper)0.16.12026-07-27MPL-2.0pushed 2026-07-27, 151 stars178,803 / 19,034
gstreamer-webrtc + gst-plugin-webrtc (webrtcbin)gstreamer-webrtc 0.25.4; gst-plugin-webrtc 0.15.42026-09-21; 2026-09-22MIT OR Apache-2.0 (bindings); MPL-2.0 (gst-plugin-webrtc)n/agstreamer-webrtc 1,156,344 / 208,114
rustrtc (restsend)0.4.22026-10-07MITcrate created 2025-11-2715,498 / 6,052

Pion via FFI is excluded by the project's requirements.


2. Per-library facts

2.1 str0m 0.24.1 (https://github.com/algesten/str0m)

  • Architecture: pure sans-IO. "A Sans I/O WebRTC implementation in Rust"; Rtc has no internal threads or async tasks and never calls Instant::now() itself (time is passed in). Drive loop: after any mutation (handle_input, Writer::write, Channel::write, SDP apply, add candidate) drain poll_output until Output::Timeout. Outputs are Output::Transmit, Output::Event, Output::Timeout(Instant). Fits the repo's injected-Clock rule and deterministic simulation. Not a W3C RTCPeerConnection API (deliberate). Tokio is not involved: we own the UDP socket and loop.
  • Panic policy: "should never panic on any user input"; panics mean bugs. README suggests catch_unwind as an extra layer (relevant for the "one session's panic must not affect others" invariant).
  • ICE: full ICE agent; application gathers candidates (Candidate::host(addr, "udp") + rtc.add_local_candidate), add_remote_candidate. ICE-lite: RtcConfig::set_ice_lite(bool) ("Ice lite is a mode for WebRTC servers with public IP address"; default false per the ice_lite() getter; an Rtc in ice lite mode "will not make STUN binding requests, but only answer to requests from the remote peer"). ICE-lite is not mentioned in the README/docs.rs feature table (it is documented only on RtcConfig). TURN: README/docs.rs feature table says TURN "No"; docs.rs: "We consider TURN similar to enumerating local network interfaces – it's a way of obtaining sockets" (application's job); NIC enumeration also "No". IPv4/IPv6/UDP/TCP claimed in FAQ. Changelog 0.24.0: Rtc::accepts recognises validated remote addresses in ICE-lite mode before nomination.
  • DTLS-SRTP: DTLS via the dimpl crate (sans-IO DTLS 1.2/1.3, MIT OR Apache-2.0, 0.7.5 released 2026-10-03, 1,452,331 downloads). Crypto backends (feature flags): aws-lc-rs (default), rust-crypto, openssl (DTLS 1.2 only), openssl-dimpl, apple-crypto, wincrypto / wincrypto-dimpl (Windows CNG). Priority when several are on: aws-lc-rs, rust-crypto, openssl-dimpl, openssl, apple-crypto, wincrypto. Per-instance: Rtc::builder().set_crypto_provider(...); process-wide: install_process_default() (panics if called twice); RtcConfig::set_crypto_provider(Arc<CryptoProvider>) overrides the process default. Changelog 0.24.0 removed SChannel: Windows DTLS now uses only dimpl with CNG.
  • Opus: RtcConfig::enable_opus(enabled, use_red) (default enabled, no RED). The library payloads/depayloads Opus frames but does NOT encode or decode (README: capture/encode/decode/render are left to the application). Frame API: receive Event::MediaData, send Writer::write(pt, wallclock: Instant, rtp_time: MediaTime, data). Audio uses a 48 kHz media clock (README: "48_000Hz for audio"). Per docs.rs Writer::write: media written before IceConnectionState is Connected is dropped. MediaData fields: mid, pt, rid, params, time (MediaTime numerator=RTP ts, denominator=clock rate), network_time (Instant), seq_range, contiguous, data (Arc<[u8]>, one depayloaded frame), ext_vals, codec_extra, last_sender_info, audio_start_of_talk_spurt. contiguous=false means loss ("usually irrelevant for audio"); for Opus PLC we would detect loss via seq_range/contiguous and call the decoder with an empty packet. Writer options: audio_level(i8, voice_activity), start_of_talkspurt(bool) (marker bit; for DTX), telephone_event. 0.24.0 added RFC 2198 RED for all audio codecs (breaking), opt-in telephone-event (DTMF) SDP negotiation, audio reordering default 1 s (video 2 s).
  • Jitter buffer: feature table: "Adaptive Jitter Buffer: No", "Fixed Depacketize Buffer: Yes". Reordering knobs: set_reordering_size_audio (default 15 packets per the getter docs; ignored in RTP mode), set_reordering_timeout_audio (default 1 s; None disables; frame mode only). There is no adaptive playout; playout/wallclock estimation is the application's job. Consequence: if inbound network jitter matters for STT, we need our own small de-jitter/PLC stage (the first version can start with the reorder buffer only; UNVERIFIED how well the 1 s default behaves under jitter, to be measured).
  • RTCP/feedback: SR/RR, Transport-Wide CC, bandwidth estimation, NACK, simulcast: "Yes". Stats events via set_stats_interval (default None = off).
  • Data channels: Yes (SCTP).
  • WHIP/WHEP: not built in and not named in the README (the word WHIP appears only inside "BitWHIP"). The http-post example (browser POSTs a JSON SdpOffer deserialized with serde_json; the answer is returned in the HTTP response; host candidates are added before accept_offer so there is no trickle; one thread and one UDP socket per connection) is a direct starting point. Example uses JSON, not application/sdp; we adapt using SdpOffer::from_sdp_string(&str) -> Result<SdpOffer, SdpError> and SdpAnswer::to_sdp_string(&self) -> String (both verified on docs.rs 0.24.1). Example bug to avoid: it calls process::exit(1) on a connection error. Example note: Firefox rejects 127.0.0.1 candidates, so use a non-loopback host address.
  • Production users: README: "Str0m was originally developed by Martin Algesten of Lookback" and "We use str0m for a specific use case: str0m as a server SFU (as opposed to peer-2-peer)" (the README does not explicitly say Lookback runs it in production as an SFU). BitWHIP (CLI WebRTC agent in Rust) is cited as a real-world example. No other users named. README says peer-to-peer paths have received less testing than SFU use; our use (browser <-> server peer) resembles the SFU ingest path.
  • Performance: README says "very good", with optimization ongoing. No numbers published on the page (UNVERIFIED; measure). Sans-IO plus one socket per process (or shared UDP port demux) is the cheap design. Changelog 0.24.1: pacer burst limited to ~63 kB.
  • Maintenance: 8 releases between 2026-05-04 and 2026-10-03 (0.19.0 ... 0.24.1); commits on 2026-10-04 (NACK timer fix "Stop NACK timer wakeups for paused receive streams (#1064)", RTCP receiver report loss-fraction wraparound fix (#1065); both listed under "Unreleased" in the changelog, i.e. not in 0.24.1). Breaking changes land in minor versions (0.21 dimpl 0.7.0 upgrade, 0.24 RED), so pin exact versions.
  • Build: MSRV 1.85.0 (crates.io; our toolchain is 1.99). README lists x86_64/aarch64 Windows MSVC, Linux GNU, macOS as compiled and tested. Default aws-lc-rs needs a C/C++ compiler (CMake never required for non-FIPS per aws-lc-rs requirements page; NASM/Windows specifics UNVERIFIED). wincrypto avoids C toolchains on Windows; rust-crypto is pure Rust. crate_size 6.08 MB.

2.2 webrtc 0.21.0 (webrtc-rs) and rtc 0.21.0

  • History/status: v0.21.0 (2026-09-19) is "the recommended choice today" and still pre-1.0 (minor versions may break). Issue #836 ("The path to webrtc 1.0") closed as completed 2026-08-22 with all rows marked done, including "C3 Jitter buffering". Releases: 0.20.4 (2026-08-30), 0.20.5 (2026-09-04), 0.21.0-alpha.2 (2026-08-16) ... 0.21.0 (2026-09-19): fast churn, rewrite is about 7 weeks old. A web search snippet (not a primary source) said v0.17.x was the last feature release of the old Tokio-coupled design (UNVERIFIED; not in the CHANGELOG).
  • Architecture: webrtc is "a thin async layer over rtc", the sans-IO core ("Sans-I/O protocol core with complete WebRTC stack (95%+ W3C API compliance)", wording from the webrtc README; the rtc README itself gives no percentage). webrtc has PeerConnection (async handle), PeerConnectionDriver (background loop owning sockets, timeouts, event dispatch) and a Runtime trait. Default runtime-tokio; also runtime-smol, runtime-mock (virtual clock). Core rtc exposes sansio::Protocol (handle_read, poll_write, poll_read, poll_event, poll_timeout/handle_timeout, handle_write, close); no ambient clock: methods take Instant. 0.21: "The core no longer reads a clock. Time comes from Runtime::now()". Using rtc directly would give the same determinism as str0m with a W3C-shaped API; using webrtc puts a driver task per peer connection inside tokio (interaction with our "no blocking on worker threads" rule is UNVERIFIED).
  • API: PeerConnectionBuilder::new().with_configuration(..).with_media_engine(..).with_setting_engine(..).with_interceptor_registry(..) .with_handler(..).with_runtime(..).with_udp_addrs(vec![..]).build().await. Handler trait PeerConnectionEventHandler has on_track(Arc<dyn TrackRemote>), on_ice_candidate, on_connection_state_change, on_ice_connection_state_change, on_ice_gathering_state_change, on_data_channel, on_negotiation_needed, ... (all provided no-op async methods). Receive: track.poll().await yields TrackRemoteEvent::OnRtpPacket(packet). Send: TrackLocalStaticSample (docs.rs media_stream index: for raw media samples such as VP8, H.264 or Opus frames, the library packetizes and sequences them; the struct-level doc text on docs.rs is a copy of the RTP variant's, so no verbatim quote) or TrackLocalStaticRTP (pre-packetized RTP). Opus registration in the reflect example (examples/reflect/reflect.rs): MIME_TYPE_OPUS, clock rate 48000, 2 channels, payload type 120; register_default_interceptors(registry, &mut media_engine). The reflect example waits for ICE gathering to complete (no trickle) and exchanges one offer/answer.
  • ICE/TURN: STUN/TURN with relay candidates, mDNS, trickle ICE, ICE restart, ICE over TCP (rtc README: "NAT traversal with STUN/TURN, plus mDNS candidates"; TURN RFC 8656 in spec compliance list). TURN client released allocations on close (commit 2026-09-27). ICE-lite: rtc::SettingEngineBuilder::with_lite(bool) exists ("only responds to connectivity checks but does not initiate them"); whether the webrtc crate forwards it via with_setting_engine is implied by the builder method but UNVERIFIED. ICE timeouts default disconnected 5 s / failed 25 s / keepalive 2 s (with_ice_timeouts). with_nat_1to1_ips(ips, candidate_type) advertises public IPs for 1:1 NAT (Host replaces the private IP and disables mDNS; Srflx adds a server-reflexive candidate).
  • DTLS-SRTP/crypto: crypto lives in rtc-crypto; default ring (no C toolchain), optional aws-lc-rs (crypto-aws-lc-rs feature); provider selectable per connection (SettingEngineBuilder::with_crypto_provider). Cipher suites and SRTP profiles configurable.
  • Interceptors (rtc-interceptor 0.21.0): JitterBufferInterceptor ("Holds each stream's packets for a fixed span of time, then releases them in order"; not adaptive), NackGeneratorInterceptor, NackResponderInterceptor (+RTX), TwccSender/ReceiverInterceptor, SenderReportInterceptor, ReceiverReportInterceptor (also congestion control, FlexFEC, pacer, PLI, RFC 8888). Whether register_default_interceptors enables the jitter buffer by default is UNVERIFIED.
  • Data channels: yes (SCTP/DCEP, flow control back-pressure).
  • WHIP/WHEP: none among the 39 examples (no whip/whep/audio-named example directory). We implement it ourselves.
  • Production users: not stated in README; sponsors are Recall.ai (Gold), Stream and ChannelTalk (Silver), AdrianEddy (Bronze); sponsorship is not evidence of production use.
  • Performance: README 0.21 reports per-packet crypto improvements (DTLS GCM record encrypt 1.015 us -> 262 ns; SRTP MAC keyed once per context, about 40% faster per RTCP packet). No end-to-end concurrency numbers (UNVERIFIED). A competing project's self-reported benchmark claims 3.3x throughput vs webrtc-rs (see rustrtc); treat as marketing.
  • Maintenance: very active (commits 2026-09-26..2026-10-03 by multiple contributors), 7 open issues on webrtc, 10 on rtc.
  • Build: MSRV not stated; rtc requires Rust edition 2024 (we have it). ring default needs no C toolchain.

2.3 LiveKit Rust SDK / libwebrtc (https://github.com/livekit/rust-sdks)

  • libwebrtc crate (Apache-2.0) = "Livekit safe bindings to libwebrtc" (Google's C++ WebRTC): modules include peer_connection, peer_connection_factory, audio_source, audio_stream, data_channel, rtp_*. livekit crate = client SDK to connect to LiveKit rooms (needs a LiveKit server URL and token; README examples are server-based). Whether libwebrtc can be used standalone as a plain peer without a LiveKit server is not addressed on the pages read; the module list suggests yes (UNVERIFIED).
  • Binary acquisition (webrtc-sys build code): downloads prebuilt zips from GitHub Releases, https://github.com/livekit/rust-sdks/releases/download/{WEBRTC_TAG}/webrtc-{triple}.zip, WEBRTC_TAG = "webrtc-89d790b", triple e.g. win-x64-release; LK_CUSTOM_WEBRTC overrides. A network fetch at build time; supply-chain and offline-CI concern.
  • README: Linux needs clang 21+ (libwebrtc artifact's bundled libc++); macOS needs -ObjC; rustflags from the repo .cargo/config.toml must be added to your cargo config; Tokio required. No Windows build section on the README.
  • Pros (inherent to libwebrtc, UNVERIFIED on these pages): full audio stack (adaptive jitter buffer/NetEq, AEC) and mature interop. Cons: large C++ FFI blob, own threads (not sans-IO, not deterministic with the injected Clock), 157 open issues, build coupling.
  • Conflicts with the project goal (hundreds of calls per container, deterministic sims, Rust-native); keep as a fallback reference only.

2.4 libdatachannel via datachannel crate

  • libdatachannel: C++ with C API, MPL-2.0 (since 0.18), ICE per RFC 8445 with STUN (RFC 8489)/TURN (RFC 8656), SCTP data channels, optional media transport (SRTP via libsrtp, RTX, "Only SDP BUNDLE mode is supported"), WebSocket. Deps: OpenSSL/GnuTLS/MbedTLS, libjuice (or libnice), usrsctp, libsrtp, plog. README does not mention an Opus packetizer, ICE-lite, jitter buffer or WHIP.
  • Rust wrapper datachannel 0.16.1 (MPL-2.0, 2026-07-27, 151 stars, 1 open issue): README shows only data-channel usage; a media Cargo feature exists ("Enables media support through libdatachannel") but the media API is undocumented there. Needs CMake + OpenSSL (vendored feature builds OpenSSL statically). Verdict: media/audio path UNVERIFIED and likely thin; C++ build chain; MPL-2.0 (weak copyleft, outside the preferred MIT/Apache/BSD set). Reject.

2.5 GStreamer webrtcbin (gstreamer-rs + gst-plugin-webrtc)

  • webrtcbin implements much of the W3C PeerConnection API (offers/answers, data channels, stun-server, turn-server/add-turn-server, ice-transport-policy, latency property = jitter buffer, default 200 ms). Codecs are chosen by caps (Opus via other GStreamer elements). ICE-lite not mentioned on the page. License of the Bad Plug-ins webrtc plugin is not stated on the page read (UNVERIFIED).
  • gst-plugin-webrtc 0.15.4 (2026-09-22, MPL-2.0): the docs.rs crate page lists only webrtcsink/webrtcsrc; the GStreamer rswebrtc plugin page (12 elements) additionally lists whipclientsink, whipserversrc, whepclientsrc, whepserversink, plus LiveKit/Janus/AWS KVS/Unreal signaller elements (no element literally named whepsrc). So a WHIP server exists in this ecosystem as a GStreamer element. The rswebrtc plugin license is not stated on that page.
  • Cons: requires a system GStreamer install (Windows packaging pain, UNVERIFIED), GLib main loop and threads, not sans-IO, heavy for hundreds of calls per container. Reject for the engine; acceptable only for experiments.

2.6 rustrtc 0.4.2 (restsend; https://github.com/restsend/rustrtc)

  • MIT, crate created 2025-11-27, 15,498 downloads, 0.3.146 (2026-10-05) -> 0.4.0 (2026-10-06) -> 0.4.2 (2026-10-07): nine releases 0.3.141..0.4.2 between 2026-09-29 and 2026-10-07. README: transport modes WebRtc, Srtp, Rtp (T.38 is a separate t38 feature, not a transport_mode variant), no_std path (some parts still std-only); ICE with STUN, TURN (UDP and TCP), ICE Lite, ICE-TCP, mDNS; DTLS-SRTP; packetizers include Opus; jitter buffer in the media pipeline; NACK/RTX, PLI/FIR, TWCC, REMB; GCC; SCTP data channels. No WHIP coverage (WHEP appears only as "single-port UDP mux for SFU/WHEP deployments"). Benchmarks are self-reported on one i7-9700T machine (3.3x throughput and ~52% less memory than webrtc-rs; 0.45 ms setup latency vs 25.6 ms). No production users named; single-vendor; very young. Feature-rich on paper but least proven. Watch list, not a pick.

3. Scored comparison

Scale 0-5 (5 best). Weights reflect Brisvox invariants (sans-IO/determinism, Windows+Linux build, correctness over features). Scores are my judgement from the facts above, not measured; performance has NOT been benchmarked (UNVERIFIED for all).

Criterion (weight)str0mwebrtc 0.21 (async)rtc 0.21 (direct)libwebrtc (LiveKit)rustrtclibdatachannelGStreamer
Maturity / maintenance (20)4334134
Sans-IO / determinism / injected Clock (20)5350310
Opus path (payloading, loss, DTX/RED) (15)4435314
ICE incl. ICE-lite and TURN client (15)3445534
Build on Windows + Linux, no network at build (10)4552421
Performance / scalability headroom (10)4343332
License fit (MIT/Apache/BSD) (5)5555523
WHIP / signaling effort (5)3332224
Weighted total (max 500)405360395315305210265

Weighted totals are sum(weight x score); e.g. str0m = 204 + 205 + 154 + 153 + 104 + 104 + 55 + 53 = 405. (Fact-check 2026-10-08: the totals in the original draft were arithmetically wrong for 6 of 7 columns and were recomputed; the ranking of the top three is unchanged.) Key facts behind the biggest gaps: str0m has no TURN client and no adaptive jitter buffer; webrtc-rs 0.21 has TURN and a jitter buffer interceptor but is only about three weeks old in its rewritten form; direct rtc use has the best architecture but a thinner Opus/WHIP story (no audio example) and 164 stars.

4. Recommendation

Primary: str0m 0.24.1 (exact-version pin, MIT OR Apache-2.0). Reasons: (1) sans-IO with caller-supplied Instant, no threads, matching the repo's Clock, bounded-channel and cancellation invariants and giving deterministic simulation; (2) ICE-lite fits a server with a reachable address; (3) frame-level Opus payloading, reordering knobs, RED, DTX talkspurt marker; (4) active releases and a project developed by Lookback for a server-SFU use case (production use itself not stated); (5) crypto backend choice including rust-crypto (pure Rust) or wincrypto so Windows builds do not need a C toolchain. Accepted gaps and mitigations: no TURN client (acceptable for a server on a public/routable address; for NATed deployments rely on with_nat_1to1_ips-style advertising, i.e. str0m Candidate::host/srflx with the public IP, or revisit); no adaptive jitter buffer (build a small sequence-aware de-jitter + PLC stage in front of the decoder if measurements show need); WHIP is ours to write (~one POST handler). Reconsider if: TURN-relayed server-side is a hard requirement (then evaluate rtc directly, with webrtc only if tokio-driver integration passes the invariants), or str0m's pre-1.0 breaking minors cause churn.

Fallback: rtc 0.21.0 (webrtc-rs sans-IO core); second fallback webrtc 0.21.0 once it has several months of stability. Rejected: libdatachannel (MPL-2.0, C++ chain, media API unclear), GStreamer webrtcbin (system dependency, threads), libwebrtc via LiveKit (FFI blob, build-time download, no determinism), rustrtc (too young; revisit later).

Recommendation: "WebRTC stack = str0m (sans-IO, ICE-lite, Opus frames), Opus codec = libopus via opus crate; criteria and scoring in docs/providers/webrtc.md."


5. WHIP (RFC 9725) request/response shape (source: https://www.rfc-editor.org/rfc/rfc9725.html)

  • Status: RFC 9725, "WebRTC-HTTP Ingestion Protocol (WHIP)", March 2025, Standards Track; updates RFC 8840 and RFC 8842; authors S. Garcia Murillo (Millicast), A. Gouaillard (CoSMo Software). (I did not compare with draft-ietf-wish-whip; not covered by the page.)
  • Session setup (4.2): client POST to the endpoint, Content-Type: application/sdp, body = SDP offer. Success = 201 Created, body = SDP answer (Content-Type: application/sdp), Location header = session URL. Wrong content type / malformed SDP = 4xx. Client SHOULD offer sendonly (MAY sendrecv), MUST NOT offer inactive or recvonly; the endpoint MUST answer recvonly. (For a bidirectional voice agent we need to send audio back: plain WHIP is ingest only. Options: (a) sendrecv offer, which the RFC permits for the client but whose answer direction the RFC says must be recvonly, so the agent's reply audio would need a WHEP-style second session or a documented Brisvox extension; (b) our own endpoint that answers sendrecv, i.e. "WHIP-style" as the project brief says. UNVERIFIED which browsers' RTCPeerConnection accept a sendrecv answer to a sendrecv offer: that is standard JSEP, so yes in practice, but not read from a page now. Decision: implement WHIP-shaped HTTP (POST/201/Location/DELETE/OPTIONS) with a documented sendrecv deviation.)
  • OPTIONS: required for CORS; 200 response SHOULD include Accept-Post: application/sdp.
  • DELETE on the session URL terminates the session.
  • ICE (4.3): Trickle ICE and ICE restart RECOMMENDED, via PATCH with application/trickle-ice-sdpfrag; ETags required (428 if missing, 412 on mismatch); support for only one of the two returns 422. The endpoint gathers all candidates before answering (4.3.2), so non-trickle is sufficient for a server. Adding candidates without restart returns 204 No Content; restart returns 200 OK with a new ETag.
  • Constraints (4.4): BUNDLE with max-bundle and rtcp-mux-only; single MediaStream (one msid); no partially successful answers (reject the whole POST); DTLS setup: client SHOULD offer actpass; client MUST implement full ICE, endpoint SHOULD support full ICE but MAY support only ICE lite (ICE lite only when the endpoint is reachable at an address accessible to each authorized client, 4.4.5).
  • Redirects (4.5): 307 preferred; 301/302 MUST NOT be used for POST; 503 + Retry-After under load (both optional, MAY).
  • STUN/TURN (4.6): endpoint may return Link: <url>; rel="ice-server"; username=..; credential=.. in the 201 response.
  • Auth (4.7): HTTP auth must be supported; Bearer tokens (RFC 6750) MUST be supported by all WHIP entities (endpoints and sessions MAY require them, 4.7.1); token format out of scope.
  • Extensions (4.9): Link headers in the 201 response with rel prefix urn:ietf:params:whip:ext.
  • Codecs: none mandated. RFC examples use Opus PT 111 and VP8 PT 96. Status codes named in the RFC: 200, 201, 204, 301/302 (prohibited for POST), 307, 400 (4.4.x), 412, 422, 428, 503; 405, 401 and 403 are not mentioned.
  • WHEP (egress) is NOT an RFC: draft-ietf-wish-whep-04 (document date 2026-06-22; datatracker last updated 2026-10-06; expires 2026-12-24; WG states "Waiting for WG Chair Go-Ahead" and "Revised I-D Needed - Issue raised by WG", IESG "I-D Exists"). Same POST/201/Location/DELETE model; 406 with counter-offer allowed.

6. Opus on the wire (RFC 7587, https://www.rfc-editor.org/rfc/rfc7587.html)

  • RTP clock rate is always 48000 Hz regardless of encode sample rate; SDP a=rtpmap:<pt> opus/48000/2 (channels field always 2).
  • fmtp (defaults): maxplaybackrate(48000), maxaveragebitrate (any positive integer; values outside 6000-510000 SHOULD be ignored; default depends on mode and maxplaybackrate), stereo(0), sprop-stereo(0), useinbandfec(0), usedtx(0), cbr(0), ptime(20), maxptime(120), sprop-maxcapturerate(48000). Mono voice: ask stereo=0, useinbandfec=1 (UNVERIFIED that browsers/str0m emit/accept these without extra config; check in the demo).
  • One Opus packet per RTP payload (may contain several frames up to 120 ms). Timestamp increments at 48 kHz: 10 ms = 480, 20 ms = 960, 40 ms = 1920, 60 ms = 2880.
  • DTX: receivers detect it by sequence-number gaps; timestamps jump by multiples of 120; comfort noise via RFC 3389 is discouraged.
  • Duplicates possible: pass only one copy to the decoder.

7. Opus codec options for Rust

CrateVersion / dateLicenseNatureBuild requirementsNotes
opus0.4.0, 2026-08-23 (0.3.1 on 2026-01-03; 2,662,657 downloads, 1,415,281 recent)MIT/Apache-2.0Safe bindings to libopus (repo SpaceManiac/opus-rs, 120 stars); depends on opusic-sys ^0.7.3 (crates.io dependencies)README: by default needs cmake and a C compilerDecoder::new(sample_rate: u32, Channels), decode(&[u8], &mut [i16], fec: bool), decode_float(.., &mut [f32], fec); PLC = pass empty slice &[]; Encoder::new(sample_rate, Channels, Application), encode, encode_float, encode_vec, set_bitrate(Bitrate), set_vbr(bool). set_inband_fec/set_packet_loss_perc exist by name (signatures not shown on the page). The crate takes a plain u32 rate; libopus API docs say the decoder rate must be one of 8000, 12000, 16000, 24000 or 48000 Hz (from opus-codec.org docs; the crate's own validation not read).
opusic-sys0.7.5, 2026-08-06 (1,562,959 downloads)BSD-3-Clauselibopus FFIbundled feature (default) builds bundled libopus 1.6.1, needs cmake; optional ninja; without bundled finds libopus on PATH / OPUS_LIB_DIR, OPUS_LIB_STATIC=true for static; build-bindgen needs LIBCLANG_PATHNo Windows-specific notes on the page.
audiopus / audiopus_sys0.3.0-rc.0 / 0.2.2, both 2021-04-22ISClibopus bindingsaudiopus_sys build-deps: cmake, pkg-config, log (+optional bindgen 0.58)Stale (no release since 2021). Do not use.
rusty-opus1.0.0, 2026-10-05 (created 2026-07-29; 51,981 downloads)BSD-3-ClausePure Rust encoder+decoder, SILK/CELT/Hybrid, DTX, FEC, PLC, SIMDnone (zero deps), MSRV 1.85Claims bit-exact on the 12 official RFC 6716/8251 decoder vectors; self-reported speed 1.50x libopus for CELT speech, ~4% slower for SILK 16 kHz VoIP; 5 stars, 157 commits; README lists "v1.0.0 gates 14/15". Very new: treat as experimental.
opus-rs0.1.37, 2026-10-07 (created 2026-02-28; 186,746 downloads)BSD-3-ClausePure Rust port of libopus 1.6 (restsend, repo restsend/opus-rs, single owner)noneREADME headline says "Production-ready" but its release notes show recent correctness fixes (SIGILL on AVX-without-FMA, FEC desync, fuzz-found panics in 0.1.35/0.1.36) and one known CELT gap at 5-6 byte packets; maturity beyond that UNVERIFIED. Hybrid is documented as supported.
ruopus0.1.2, 2026-07-01 (2 releases; 7,391 downloads)MITPure Rust decoder + encodernoneToo new; single crates.io owner.

libopus itself: latest release 1.6.1 (2026-01-14). Reference implementation is three-clause BSD; royalty-free patent grants from Xiph.Org/Broadcom, a separate (still royalty-free) Microsoft license, and IPR disclosures by Qualcomm, Huawei, France Telecom and Ericsson with external counsel advising no license is needed (https://opus-codec.org/license/).

Recommendation: opus 0.4.0 (libopus 1.6.1 via opusic-sys bundled) for correctness and the reference codec; requires cmake + C compiler on the build machine (Windows: MSVC build tools, UNVERIFIED that cmake generator picks it automatically; check in CI). Keep a trait (OpusEncode/OpusDecode) so rusty-opus/opus-rs can replace it once conformance and PLC/FEC are verified by our own tests. Encoding/decoding is CPU work: per CONTRIBUTING.md anything above ~50 us belongs on the brisvox-inference pool, not on tokio workers (a 20 ms Opus frame decode cost is not measured here; UNVERIFIED).

8. Minimal integration plan

Architecture (fits the transport boundary):

  1. crates/brisvox-transport/src/webrtc/ (new module or crate brisvox-transport-webrtc): a WebRtcTransport implementing Transport::accept. The adapter per call keeps CallEnds: pushes WireEvent::Audio (decoded caller PCM), Hangup; consumes WireOut::Audio (engine PCM), Clear (barge-in: flush encoder queue and stop sending), Hangup. The core already handles pacing (OutputPacer), marks and epochs; the adapter just emits frames when told. The sample rate goes through CallInfo::metadata[CallInfo::SAMPLE_RATE_KEY] (CallRates { input, output }).
  2. Signaling endpoint (axum/hyper, whatever brisvox-server already uses): POST /whip (or /webrtc/offer) with Content-Type: application/sdp and optional Authorization: Bearer. Steps: validate size and content type; create an Rtc (str0m) with ICE-lite on when the server has a public/routable address; add local host (and configured public) candidates; sdp_api().accept_offer(offer) (offer parsed with SdpOffer::from_sdp_string, answer returned via SdpAnswer::to_sdp_string); return 201 Created, Content-Type: application/sdp, Location: /whip/<session-id>, SDP answer body. DELETE /whip/<session-id> cancels the session token and returns 200/204; OPTIONS returns CORS headers and Accept-Post: application/sdp. PATCH can be 405/422 at first (non-trickle).
  3. One peer per call: one Rtc + one task per call, child of the call's CancellationToken. Sockets: a single shared UDP socket (or small pool) with a demux map (remote SocketAddr or ICE ufrag -> call). handle_input/poll_output loop per call, timeouts via the injected Clock (never Instant::now() in logic, per CONTRIBUTING.md). catch_unwind around the per-call loop for isolation. Bounded channels between the socket reader and each call, with a documented drop-oldest overflow policy for inbound datagrams.
  4. Media: negotiate Opus (48 kHz clock, 2 channels in SDP; send mono content). Inbound: Event::MediaData.data is one Opus frame -> decoder (opus::Decoder, 48 kHz or 16 kHz output; if 48 kHz, use the existing rubato pipeline to reach the engine's 16 kHz input; PLC when seq_range/contiguous shows a gap). Outbound: engine PCM (24 kHz TTS output resampled to the call's output rate, preferably 48 kHz) -> 20 ms frames (960 samples at 48 kHz) -> opus::Encoder (voice/VoIP application, bitrate ~24-32 kbit/s, in-band FEC on: UNVERIFIED exact setters) -> Writer::write(pt, wallclock, MediaTime(960*n, 48 kHz), data) once ICE is Connected. Drop writes while not connected. Barge-in: on WireOut::Clear discard queued PCM/frames immediately; RTP timestamps keep advancing by elapsed time so the stream stays valid.
  5. Hang-up: on IceConnectionState::Disconnected or DELETE, emit WireEvent::Hangup; the transport supervisor maps it to stop reasons.
  6. Tests without a browser: str0m-to-str0m loopback in-process (both Rtcs driven by a virtual clock, which sans-IO makes possible); Opus encode->decode round trip; signaling handler tests with tower::ServiceExt; protocol fixtures for the WHIP shapes in section 5. A live browser test is BRISVOX_LIVE_TESTS=1 / a pending follow-up.
  7. Browser demo page (served by the same server at /demo/webrtc as static HTML; microphone needs a secure context: HTTPS, localhost or file:///; otherwise navigator.mediaDevices is undefined, source MDN getUserMedia page):
    const stream = await navigator.mediaDevices.getUserMedia({
      audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 }, video: false });
    const pc = new RTCPeerConnection({ iceServers: [] /* add stun/turn if needed */ });
    stream.getAudioTracks().forEach(t => pc.addTrack(t, stream));
    pc.ontrack = e => { audioEl.srcObject = e.streams[0]; audioEl.play(); };
    await pc.setLocalDescription(await pc.createOffer());
    await new Promise(r => pc.iceGatheringState === 'complete' ? r() : pc.addEventListener('icegatheringstatechange',
      () => pc.iceGatheringState === 'complete' && r()));          // non-trickle
    const res = await fetch('/whip', { method: 'POST', headers: { 'Content-Type': 'application/sdp' }, body: pc.localDescription.sdp });
    const sessionUrl = res.headers.get('Location');                // 201 Created
    await pc.setRemoteDescription({ type: 'answer', sdp: await res.text() });
    // hang up: await fetch(sessionUrl, { method: 'DELETE' }); pc.close();
    
    (The JS is my sketch of the standard browser API usage, not copied from a fetched page; check in the demo. Only the secure-context rule is from MDN's getUserMedia page; the MDN page does NOT list the constraint names echoCancellation/noiseSuppression/autoGainControl/channelCount (they belong to MediaTrackConstraints; UNVERIFIED here). Browser host candidates may be mDNS .local names; the server learns the real address from the STUN binding request, not from the SDP: UNVERIFIED behaviour in str0m, test it.)
  8. Rates: caller side 48 kHz Opus <-> engine 16 kHz input (libopus decoder API accepts 16000 Hz per the libopus docs, so the decoder can produce 16 kHz directly; the opus crate passes u32 through, validation UNVERIFIED; otherwise 48 -> 16 kHz with rubato, see docs/providers/rubato.md) and TTS 24 kHz -> 48 kHz output. The adapter writes CallRates { input: 16000-or-48000, output: 48000 } into CallInfo metadata. Keep all resampling in the existing pipeline (StreamResampler), not in the adapter.
  9. Config/limits: max concurrent peers, per-IP session cap, SDP size limit, Bearer token check, ICE timeouts; every network/IO await has a timeout.

9. UNVERIFIED list (carried into the implementation)

  • Whether the webrtc 0.21 crate forwards ICE-lite (with_lite) through PeerConnectionBuilder::with_setting_engine, and whether register_default_interceptors enables the jitter buffer.
  • str0m end-to-end performance (CPU per call, calls per core) and behaviour of the default 1 s audio reordering timeout under jitter; none benchmarked.
  • (Resolved 2026-10-08: str0m SDP string API is SdpOffer::from_sdp_string / SdpAnswer::to_sdp_string.)
  • str0m behaviour with browser mDNS host candidates and with ICE-lite when the browser is behind NAT (server learns peer-reflexive address).
  • Windows build recipe for aws-lc-rs (NASM / prebuilt objects) and for opusic-sys bundled (cmake generator, MSVC); wincrypto and rust-crypto as C-free alternatives not yet built by us.
  • opus crate: set_inband_fec / set_packet_loss_perc signatures, Application enum variants, and whether the crate itself restricts sample rates (docs page did not show them).
  • Whether libwebrtc can run as a standalone peer without a LiveKit server; the GStreamer webrtc plugin licenses; datachannel crate media API and license beyond MPL-2.0 metadata.
  • Real-world production users of str0m beyond what the README states (Lookback is its originating company, BitWHIP is an example), and of webrtc 0.21.
  • Browser acceptance of sendrecv answers to a WHIP-style POST (standard JSEP, but not read from a page now).
  • MDN/MediaTrackConstraints support for the demo's audio constraint names (not on the getUserMedia page).
  • webrtc-rs "v0.17.x last feature release of the old design" (search snippet only).
  • crates.io license/GitHub license metadata disagree for str0m (crates.io "MIT OR Apache-2.0"; GitHub API "MIT") and webrtc (crates.io "MIT/Apache-2.0"; GitHub "Apache-2.0"); read the LICENSE files in the repo before adding the dependency.

Fact-checked 2026-10-08: 120 claims checked, 17 corrected. Corrections:

  1. Section 3 weighted totals were arithmetically wrong (originals 410/365/395/270/310/205/235); recomputed 405/360/395/315/305/210/265 (str0m example formula also 405, not 410).
  2. rustrtc release window: "0.3.146 -> 0.4.2 within five days (2026-10-03..10-07)" was wrong; 0.3.146 is 2026-10-05, 0.4.0 2026-10-06, 0.4.2 2026-10-07.
  3. Lookback: README only says str0m was originally developed by Lookback and that the project uses it "as a server SFU"; it does not explicitly say Lookback runs it in production. Reworded in 2.1 and section 4.
  4. str0m NACK-timer and RTCP receiver-report fixes (commits 2026-10-04) are under "Unreleased" in the changelog, not in 0.24.1.
  5. str0m ICE-lite: wording is "will not make STUN binding requests, but only answer"; ICE-lite is not in the README feature table (documented only on RtcConfig).
  6. str0m TURN quote replaced with the verbatim docs.rs sentence.
  7. gst-plugin-webrtc: whep elements do exist (whepclientsrc, whepserversink); the docs.rs page lists only webrtcsink/webrtcsrc, the GStreamer rswebrtc page lists 12 elements incl. whip/whep ones.
  8. rustrtc: T.38 is a separate feature, not a transport_mode variant; WHEP is mentioned (UDP mux), WHIP is not.
  9. WHEP draft: WG state also "Revised I-D Needed - Issue raised by WG".
  10. MDN: secure context also includes file:///; the constraint names are NOT on that MDN page (previous text said they were).
  11. libopus decoder sample rates (8000/12000/16000/24000/48000) now verified from opus-codec.org API docs (previously "from memory").
  12. opus-rs: "Older releases lacked Hybrid" is not supported by the README (Hybrid documented as supported); replaced with README maturity facts.
  13. webrtc TrackLocalStaticSample: quoted phrase not found verbatim (docs.rs struct doc is a copy of the RTP variant's); replaced with the media_stream index paraphrase.
  14. RFC 9725: ICE lite is only permitted when the endpoint is reachable at an address accessible to each authorized client (4.4.5); 503/Retry-After are optional; Bearer: endpoints/sessions MAY require it.
  15. RFC 9725 status-code list expanded (200, 201, 204, 301/302/307 named too; 401/403 not mentioned); previous "Error codes used" list was incomplete but not wrong for 4xx/5xx.
  16. str0m SdpOffer::from_sdp_string / SdpAnswer::to_sdp_string verified to exist (previously UNVERIFIED); DTMF in 0.24.0 is opt-in negotiation; RFC 7587 maxaveragebitrate range is "SHOULD ignore outside" rather than a default.
  17. libdatachannel STUN is RFC 8489 (TURN RFC 8656); livekit README says to add the config.toml rustflags to your cargo config; Microsoft patent license and IPR-disclosure companies (Qualcomm, Huawei, France Telecom, Ericsson) specified. Remaining UNVERIFIED: see section 9 (webrtc ICE-lite forwarding and default jitter buffer, str0m performance and jitter behaviour, mDNS handling, Windows build recipes, opus crate setter signatures, libwebrtc standalone use, plugin licenses, production users, browser sendrecv acceptance, MediaTrackConstraints names, webrtc 0.17.x snippet, license-metadata mismatches).

10. Implementation notes (2026-10-08)

Code: crates/brisvox-transport/src/webrtc/ (cargo feature webrtc, off by default), tests in crates/brisvox-transport/tests/webrtc.rs and the unit tests of the module. Design: docs/ARCHITECTURE.md "WebRTC transport"; the library decision is recorded in section 4.

10.1 Facts read from the sources (not from memory)

  • str0m 0.24.1 source (~/.cargo/registry/src/.../str0m-0.24.1): the crate ships only LICENSE-MIT.txt (MIT, Copyright 2022 Martin Algesten) while Cargo.toml says MIT OR Apache-2.0. Treat it as MIT, which deny.toml allows (cargo deny check: advisories, bans, licenses, sources ok).
  • Default features are aws-lc-rs and examples (the examples pull rouille): use default-features = false, features = ["rust-crypto"]. str0m::crypto::from_feature_flags() picks the backend and panics if none is enabled; Rtc::builder().set_crypto_provider(Arc<..>) and .set_dtls_cert(..) avoid the process-wide default and a certificate per call.
  • API used: Rtc::builder().clear_codecs().enable_opus(true, false).set_ice_lite(..) .set_reordering_size_audio(..).set_reordering_timeout_audio(..).build(Instant), rtc.add_local_candidate(Candidate::host(addr, "udp")?), rtc.sdp_api().accept_offer(SdpOffer), rtc.accepts(&Input), handle_input(Input::Receive|Timeout), poll_output(), rtc.writer(mid).start_of_talkspurt(bool).write(pt, wallclock, MediaTime, payload), Event::Connected, Event::MediaData, Event::Closed, Event::IceConnectionStateChange, rtc.close(). RtcConfig::build calls expect on its own errors; everything else returns Result.
  • Event::Connected fires when ICE and DTLS are up; Event::MediaAdded comes from poll_output only after that (it is not available right after accept_offer), so the audio mid is read from the offer text.
  • MediaData timestamps are 64-bit MediaTime (numerator = RTP ticks at 48 kHz for Opus); contiguous is false after a sequence gap.
  • Candidate lines whose address is not an IP literal (browser mDNS names) are removed from the offer before parsing: the server does not need the browser's candidates (it learns the address from the STUN request). Not tested against a real browser (UNVERIFIED), tested with a crafted offer line.
  • ICE-lite: after nomination the remote is considered gone 15 s after its last binding request (is-0.11.1/src/pair.rs, RECENT_BINDING_REQUEST). Chrome sends consent checks about every 2.5 s (UNVERIFIED here, from memory), so a live browser is never close to that.
  • Windows: UdpSocket::recv_from can fail with ConnectionReset (WSAECONNRESET) after a send to a closed port; the UDP reader ignores that error kind.

10.2 Opus: libopus works here, pure Rust was not needed

Order of preference: (1) opus 0.4.0 if it builds without installing cmake; it does. The cmake crate (0.1.58) located Visual Studio 2022's bundled cmake 3.31.6 although cmake is not on PATH, and opusic-sys 0.7.5 built libopus 1.6.1 (BSD-3-Clause) with MSVC. Caveat: MSBuild fails when the build path exceeds 260 characters (it did in a deep scratch directory; the worktree's own target/ is fine). On Linux the build needs cmake and a C compiler, which is why the feature is off by default. The pure-Rust crates (rusty-opus, opus-rs, ruopus) were not evaluated: they stay candidates behind OpusFactory (section 7).

Measured here (release build, 20 ms mono frame at 48 kHz, VoIP, Signal::Voice, 24 kbit/s, tone input):

settingencode per framedecode per frame
complexity 9, fullband376 us44 us
complexity 5, superwideband (the default)185 us44 us
complexity 3, superwideband148 us41 us
complexity 5, wideband135 us26 us
complexity 2, wideband93 us25 us

All of it is above the 50 us budget for tokio workers (CONTRIBUTING.md), so the call driver runs codec jobs on the inference pool when WebRtcOptions::codec_pool is set (WebRtcOptions::with_pool; tested: the pool completes the jobs, none dropped); without a pool they run on the call's task, i.e. on a tokio worker (tests, simulator). Decision (from the review): the inline default stays, because the simulator and the tests need it, but WebRtcServer::serve (and so bind and bind_with) logs a warning when there is no pool and max_calls is above INLINE_CODEC_MAX_CALLS (8): 100 talking calls cost roughly 100 x 50 frames/s x 0.2 ms = 1 core-second per second on the workers. The per-call packet rate limit (section 10.6) bounds what one client can add to that. A speech-like input encodes slower than the tone; real figures per call need a load test (UNVERIFIED).

Round trip (webrtc::opus tests, libopus 1.6.1, 24 kbit/s, in-band FEC on): 1 kHz tone SNR 25.2 dB; the en_two_utterances fixture (linearly up-sampled to 48 kHz) SNR 8.8 dB (waveform SNR of a perceptual codec on speech; not a quality score); codec delay 309 samples = 6.4 ms (the tone's reported lag of 21 samples is 309 modulo the 48-sample period). PLC returns a non-silent frame after a lost packet.

10.3 Signaling shape implemented

POST <whip_path> (Content-Type: application/sdp, optional Authorization: Bearer) answers 201 Created, Content-Type: application/sdp, Location: <whip_path>/<32 hex digits>; DELETE <whip_path>/<id> answers 200 (404 unknown); OPTIONS answers 200 with Allow and Accept-Post: application/sdp (plus CORS headers when cors_origin is set); PATCH answers 405 (non-trickle only). Refusals: 400 (SDP does not parse, no usable audio, anything but exactly one live audio media section, no rtcp-mux), 401 + WWW-Authenticate: Bearer, 413 (body above max_sdp_bytes), 415 (not application/sdp; RFC 9725 names only "4xx"), 429 (per-address limit), 503 + Retry-After: 1 (call limit), 408 (request too slow). Deviation from RFC 9725: the answer to a sendrecv offer is sendrecv (RFC: recvonly), so the agent can speak; a sendonly offer is answered recvonly and the bot cannot speak. The Location is relative. No ETag/Link headers (no trickle, no ICE servers in the answer). RFC 9725 4.4 "no partially successful answers" is kept: an offer with anything besides one live audio section (audio+video, an m=application data channel, a second audio section) is refused whole with 400, and rtcp-mux is required (an offer without a=rtcp-mux gets 400). BUNDLE is not checked because one media section makes it moot; max-bundle / rtcp-mux-only offer attributes are accepted but not required. Single MediaStream (one msid) follows from the single section.

10.4 Marks, clear and the RTP timeline

  • Browsers do not report playout: a mark is acknowledged mark_delay (default 80 ms) after the audio ahead of it (including the flushed resampler tail and the padded last 20 ms frame) was handed to RTP. An estimate; Lead is not reported. A data-channel echo from the demo page would make it exact (not done).
  • Clear drops what the adapter holds (resampler, partial frame, encoder history, pending marks). Packets already sent are played by the browser; measured: nothing arrives at the browser 250 ms after the clear (loopback, 2 s of audio queued).
  • Outgoing RTP timestamps are contiguous while audio keeps coming and jump to the wall-clock position with the marker bit after a pause above 60 ms. Tested end to end: the browser peer logs the RTP time and talkspurt bit of every packet (the_outgoing_rtp_time_line_is_contiguous_jumps_after_a_pause_and_never_goes_back: steps of 960 ticks inside an utterance, a jump of about the pause (500 ms = 24 000 ticks) with the marker bit after it, a marker and no backward step after a clear) and plan_packet is unit tested.
  • Measured on loopback (debug build, inline codec, Windows, 10 rounds of 5 frames after 300 ms of idle, taken at the decoded packet's arrival in the browser peer): engine Audio command to first decoded packet at the browser 1.8-3.8 ms, median 2.6 ms, one round 33 ms (timer tick). The 44-46 ms recorded earlier was an upper bound from a polling test (10 ms poll sleeps rounded up to tokio's 15.6 ms Windows timer tick, 30 frames queued first), not the adapter's latency. The test first_bot_audio_reaches_the_browser_fast_at_the_median asserts a median below 50 ms (about 20 times the measurement, room for a loaded runner, still far below the 100 ms reordering wait or an extra pacer stage); a hang guard of 400 ms remains in the audio test. Not a browser measurement.

10.5 Remaining UNVERIFIED after the first implementation

  • Behaviour with real Chrome, Firefox and Safari (the demo page was written against the standard API and exercised only by a str0m peer): sendrecv answer acceptance, mDNS candidates, getUserMedia constraint names, ICE consent timing.
  • Whether the 100 ms / 5 packet audio reordering limits suit real jitter (defaults were 1 s / 15 packets).
  • CPU per call and calls per core under load (no load test yet).
  • A vanished browser (no DTLS close, no DELETE) is noticed after 15.5 s (ICE-lite consent window plus the 500 ms disconnect_grace of the test config) and the engine sees PeerDisconnected. The test a_vanished_browser_frees_its_slot_and_ends_the_engines_call runs in every normal test run: it pauses tokio's clock once the call is up, so the server's timers (all on the injected clock, handed to str0m as Instants) run in simulated time, and it asserts the 5-30 s window, the stop reason, the freed slot and the freed per-address count. A unit test covers the disconnect_grace and connect deadlines without waiting. str0m 0.24.1 has no setting for the ICE consent timeout.
  • Verified on loopback with a str0m peer: an offer whose only candidate is an mDNS .local name, and one with no candidate at all, connect (the server learns the peer-reflexive address from the first STUN request), pass DTLS and carry audio both ways. Real browsers remain unverified.
  • Routing: a STUN request's ufrag outranks a known source address (same-5-tuple reconnect). An off-path attacker who spoofs a victim's exact ip:port can send it to the attacker's own call until the victim's next connectivity check re-binds it; the attacker's call never gets the victim's media (no ICE password).
  • TURN-relayed browsers cannot reach the server unless it has a routable address (candidate_ip).

10.6 Review fixes (verified findings)

  • Inbound media rate limit. A client with valid SRTP keys could send tens of thousands of packets a second, each one a codec job and an audio frame for the engine, and starve its own timers. Now WebRtcConfig:: max_audio_packets_per_sec (default 200, at least 50) is a token bucket per call (burst of one second, driven by the injected clock) applied to each MediaData before the decode job; the excess is dropped and counted in WebRtcStats::audio_packets_rate_limited. The default is four times the usual 50 packets/s and twice the 100/s of 10 ms packets, so network bursts (the reorder buffer releasing a few packets at once) never reach it. Timers: every loop round checks the due deadline before it waits, so datagrams cannot starve the mark acknowledgements, the connect deadline or the disconnect grace; additionally the loop yields to the runtime after 32 datagrams in a row. Tested with a browser peer writing 20 packets per 20 ms tick (1000/s): the engine gets at most burst plus rate frames, audio_packets_rate_limited counts the rest, and a mark sent during the flood is still acknowledged.
  • Packet loss concealment is tested on the wire: the browser peer's network drops every 8th RTP datagram. The engine's caller audio keeps its length (one 20 ms frame per packet sent, concealed ones included), frames_concealed equals the number of lost packets that were followed by another one, and a sender that jumps its RTP clock over contiguous sequence numbers (DTX) is not concealed. lost_frames is a pure function with unit tests.
  • Codec pool failure paths are tested: a panic inside a pool worker (poisoning the call's codec lock) drops that frame, the call survives with call_panics == 0, later decode and encode jobs work, and a neighbouring call on the same pool is unaffected; a pool with one busy worker and a queue of one makes jobs time out or be refused: the frames are dropped (codec_dropped, pool timed_out/rejected), the call stays up and recovers when the worker is free.
  • The demo page hangs up (DELETE, once) on every exit: stop, a rejected answer, a failed connection, pagehide.

11. Reachability: ICE-TCP and TURN credentials (fetched 2026-10-13)

Why: the browser initiates, so a server with a public UDP port works from any NAT type, but fails when the client network blocks UDP, when the host has no UDP ingress (a one-port PaaS such as Railway), and gives no relay to symmetric-NAT clients. The server is ICE-lite; this section covers what the browser gets (relay servers), and the server's own TURN client is section 12.

11.1 Facts read now (sources fetched 2026-10-13)

  • RFC 9725 section 4.6 (https://www.rfc-editor.org/rfc/rfc9725.html): the endpoint MAY return STUN/TURN servers in the 201 Created as Link headers, one URI per header, e.g. Link: <turn:turn.example.net?transport=udp>; rel="ice-server"; username="user"; credential="myPassword". The target is an RFC 7064/7065 URI (stun:, turn:, turns:); username is the TURN user, credential a long-term password (RFC 8489 section 9.2). credential-type is not defined by the RFC, so the server does not send it. Returning the servers in OPTIONS is "NOT RECOMMENDED"; a CORS preflight SHOULD NOT carry them. Implemented: one Link per URL on the 201; Access-Control-Expose-Headers lists Link so a page on another origin can read them.
  • coturn REST credentials (https://raw.githubusercontent.com/coturn/coturn/master/README.turnserver): --use-auth-secret + --static-auth-secret=<secret>; username <timestamp>:<userid> (separator -C, default :), password base64(hmac-sha1(secret, username)). Only HMAC-SHA1 is documented for this scheme. --tls-listening-port defaults to 5349; --cert / --pkey are PEM files. Implemented with ring::hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY; the timestamp is the unix expiry (wall clock of the injected Clock), the user id is the call's session id (so one credential maps to one call). Reference vector in the unit test the_coturn_password_matches_a_reference_vector.
  • Cloudflare Realtime TURN (https://developers.cloudflare.com/realtime/turn/generate-credentials/): POST https://rtc.live.cloudflare.com/v1/turn/keys/$TURN_KEY_ID/credentials/generate-ice-servers, Authorization: Bearer $TURN_KEY_API_TOKEN, Content-Type: application/json, body {"ttl": <seconds>}; 201, body {"iceServers":[{"urls":["stun:stun.cloudflare.com:3478"]},{"urls":[turn:...:3478?transport=udp, turn:...:443?transport=udp, turn:...:3478?transport=tcp, turn:...:80?transport=tcp, turns:...:5349?transport=tcp, turns:...:443?transport=tcp],"username":...,"credential":...}]}. The page warns that port 53 is blocked by browsers and can time out ICE gathering: the server drops any URL on port 53. No TTL limits are documented. The key (id + API token) is created in the Cloudflare dashboard (Realtime > TURN) and never leaves the server.
  • Twilio Network Traversal Service (https://www.twilio.com/docs/stun-turn/api): POST https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Tokens.json, HTTP basic auth (account SID + auth token, or an API key SID + secret), form parameter Ttl (seconds; default and maximum 86400); response JSON has ice_servers (entries with url/urls, TURN ones with username/credential), plus username, password, ttl. TURN URLs: turn:global.turn.twilio.com on 3478 (udp, tcp) and 443 (tcp); no turns:. The parser accepts urls as a string or an array.
  • str0m ICE-TCP (read in is-0.11.1 and str0m-proto-0.7.0 sources, no network): a passive TCP host candidate is Candidate::builder().tcp().host(addr).tcptype(TcpType::Passive).build(); Protocol::Tcp is documented "See RFC 4571 for framing" (2-byte big-endian length before every packet; str0m itself does no framing, the caller does). ICE-lite accepts host candidates of any protocol (add_local_candidate rejects only non-host ones). A STUN request is accepted only when Receive.destination equals a local candidate address of the same protocol; the sender becomes a peer-reflexive remote candidate. Type preference: host/UDP 126, host/other 90, so UDP is preferred whenever both work (checked in the answer SDP by test).
  • Fly.io (https://docs.fly.io/networking/udp-and-tcp): the UDP side must bind the fly-global-services address (replies must come from it), a [[services]] block with protocol = "udp" per UDP port, external and internal port equal, a dedicated IPv4 address (no shared IPv4 or IPv6 for UDP), assume about 1300 bytes of UDP payload (WireGuard + proxy overhead). Not covered by the page: anycast, regions.
  • Railway (https://docs.railway.com/reference/tcp-proxy, /reference/public-networking, web search 2026-10-13): public networking is HTTP/HTTPS plus a TCP proxy (domain:port, a Railway-chosen port); the docs do not mention UDP, private networking carries UDP only inside a project, and Railway staff/community posts (station.railway.com/feedback/allow-outbound-udp-traffic-0f74101c, /feedback/adding-inbound-udp-fad19847) say inbound UDP is not supported. TCP proxy idle timeouts are not documented. Treat Railway as HTTP + TCP only.

11.2 What was built

  • GET <whip_path>/ice (same bearer check as POST; Cache-Control: no-store; CORS like POST): {"iceServers":[...]} in the browser's RTCIceServer shape, [] when nothing is configured or the provider is down. Rate limit per client: a burst of 5, then one per 2 s. coturn usernames are <expiry>:<label>, the label being the first 16 hex digits of SHA-256 of the session token's sid (a random id when the request had no session token); Cloudflare/Twilio credentials are shared by every caller for the cache period.
  • webrtc::turn_creds: coturn computed locally per request; Cloudflare/Twilio fetched with a 5 s deadline, cached 60 s (one entry, at most half the credential lifetime), a failure remembered for 10 s so an outage costs one slow request per backoff and never fails a call. Answers are validated before they reach a header or JSON (schemes, no space/quote/angle bracket/backslash/control, 8 servers, 16 URLs, port 53 dropped).
  • webrtc::tcp (BRISVOX_WEBRTC_TCP_ADDR): see ARCHITECTURE.md "WebRTC transport" for routing and bounds.
  • Pages (demo.html, examples/web-assistant/web/index.html) fetch <whip>/ice before createOffer and pass the list to RTCPeerConnection; an empty list or any failure (404 from an older server) keeps the old behaviour.

11.3 UNVERIFIED (to check with real browsers and a live TURN relay)

  • A real Chrome/Firefox/Safari connecting to the passive TCP candidate of an ICE-lite answer: only a str0m client (TCP-active candidate) was tested. Chrome's handling of ICE-TCP candidates is from memory, not read now.
  • A real relay path (browser -> Cloudflare/Twilio/coturn -> the server's UDP candidate): the credentials are tested against mocks of the REST APIs and the coturn formula against a reference vector, not a live TURN server.
  • Whether a TURN service reaches the server: a TURN relay allocates UDP, so a relayed browser still needs the server's UDP port (or a server-side TURN client). Browsers cannot relay to a TCP candidate.
  • Cloudflare's generate-ice-servers response shape for iceServers as an object (the single-credential endpoint does that, per memory): the parser accepts an array or an object.
  • RFC 6544 (ICE-TCP) and RFC 4571 were not re-fetched; behaviour is taken from the str0m sources.

12. Server-side TURN client (fetched 2026-10-13)

Why: a host with HTTP(S) and no inbound UDP (a one-port PaaS such as Railway) cannot be reached by the ICE-lite server address, and a browser TURN relay only forwards to the server's UDP candidate. The server therefore holds its own TURN allocation on a managed service, reached over TCP/TLS, and advertises the relayed address (section 12.3).

12.1 Facts read now (sources fetched 2026-10-13)

  • RFC 8656 (https://www.rfc-editor.org/rfc/rfc8656.txt, sections cited by number): the client talks to the server over UDP, TCP or TLS-over-TCP and asks for a UDP allocation with REQUESTED-TRANSPORT; the default allocation lifetime is 600 s (10 minutes), a server SHOULD cap it at 3600 s, and a Refresh with LIFETIME 0 deletes the allocation (section 7); a permission is installed by CreatePermission for an IP address (the port is ignored), lasts 300 s unless refreshed (section 9), and a UDP datagram from a peer without one is silently discarded by the server (section 5); a channel binding lasts 10 minutes unless refreshed (section 12) and ChannelBind also installs a permission; data goes as Send/Data indications or ChannelData (4-byte header: channel number 0x4000 to 0x7FFF and the length of the data). Section 12.5: over TCP and TLS-over-TCP the ChannelData message MUST be padded to a multiple of four bytes; the padding is not in the length field. Over UDP padding is optional.
  • RFC 7064 / 7065 (TURN URIs, from memory, not re-fetched): turn:host[:port][?transport=udp|tcp], default port 3478; turns: is TLS over TCP, default port 5349. The parser in relay.rs follows this and uses only URLs that mean TCP (transport=tcp, or turns: without a transport).
  • Cloudflare Realtime TURN FAQ (https://developers.cloudflare.com/realtime/turn/faq/): billed per GB of data the TURN server sends to a TURN client ($0.05/GB, 1000 GB free); client-to-server traffic is free; TURN over TLS is supported; RFC 6062 (TCP allocations) is not implemented and REQUESTED-TRANSPORT is ignored (so the allocation is UDP, which is what the server asks for); no maximum allocation duration but the client must keep refreshing with a valid credential, credentials may expire up to 48 h ahead; billing stops when the credential expires. The page does not say whether non-browser clients may use the credentials (UNVERIFIED, see 12.4).
  • turn-client-proto 0.7.3 / turn-server-proto 0.7.3 / stun-proto 2.0.3 (crates.io, published 2026-10-06 / 2026-10-03, license MIT OR Apache-2.0, repository https://github.com/ystreet/turn-proto; sources read in ~/.cargo/registry): sans-IO RFC 5766/6062/6156/8656 clients and servers, time passed in as stun_proto::Instant (sans-io-time: nanoseconds on a caller-chosen time line, so the injected Clock maps straight onto it). TLS is not in the crate (separate turn-client-rustls); the TCP client takes plaintext bytes. Deviations found by running it against a TLS-less server and reading the source:
    1. it writes ChannelData unpadded and parses 4 + length bytes on receipt; RFC 8656 12.5 requires padding on a stream. relay.rs frames the stream itself: it hands the crate each frame without its padding and pads the ChannelData the crate writes. (The in-process test server pads like a real one.)
    2. with anonymous_username on Auto it switches from USERNAME to the RFC 8489 USERHASH when the server advertises PASSWORD-ALGORITHMS; the server side cannot then see the REST user name. relay.rs sets it to Disabled; coturn and the managed services use USERNAME.
    3. its Debug output (TurnCredentials, client, config) contains the TURN password: never formatted. Its tracing output (targets turn_client_proto, stun_proto, turn_types; #[instrument] spans too) was checked by a log-capture test (the_turn_client_crates_logs_hold_no_password_and_the_filter_removes_them, everything at trace, a whole call): the password and the shared secret never appear, but at trace every received STUN message is logged in full (NONCE, REALM, the response's MESSAGE-INTEGRITY HMAC), and at info the crate logs on every poll. (An earlier note here said the logs "expose the TURN password at info"; that was wrong, they do not. The credential-adjacent trace output and the noise are why they are filtered.) The server filters those targets below warn whatever RUST_LOG says (brisvox_core::telemetry::turn_client_log_allowed, a per-layer filter in telemetry::build_subscriber; BRISVOX_LOG_TURN_CLIENT=1 lifts it, for debugging only). A subscriber an embedder installs itself must apply the same filter (filter_fn(brisvox_transport::webrtc::turn_client_log_allowed), combined with the layer's own filter by FilterExt::and).

12.2 Why the design is shaped this way

  • str0m ICE-lite refuses non-host local candidates (is-0.11.1 agent.rs add_local_candidate), so a relayed address is added as a host candidate. The browser needs nothing special: it sends STUN to an IP:port.

  • A TURN server drops data from an IP without a permission, and the browser (controlling agent) sends the first connectivity check; so CreatePermission for every IP in the offer happens before the answer is sent, and the 201 waits for it (bounded, 3 s default).

  • One allocation per call, one connection per allocation: allocations are per 5-tuple (RFC 8656), a shared one needs a demultiplexer, and one connection would put every call behind one head-of-line stall.

  • Refresh timing is the client crate's, driven by the injected clock (ProtoInstant = clock time since the session start): allocation before its lifetime ends, permissions and channels before their 5 / 10 minutes.

  • Permissions are created for IPs the client supplies in its offer. Bounds (relay.rs module docs): at most max_permissions (8), only the allocation's address family, never unspecified, multicast, broadcast, loopback or link-local addresses (loopback and link-local only with RelayConfig::allow_local_peers, for tests). The cost of a hostile offer is up to 8 CreatePermission requests on the operator's TURN quota per call (the per-IP offer rate limit applies first). A permission only lets the TURN server pass packets from that IP to the relayed address; str0m sends only to peers whose ICE check it verified, so a permission for an address the server never talks to is inert.

  • Trust assumption. The server opens an outbound TCP/TLS connection to the host in the chosen TURN URL. Those URLs come only from operator configuration (coturn) or from the TURN provider's API answer over TLS (Cloudflare, Twilio). A hostile or compromised provider response could point the server's connection at an internal host or port; operators who do not trust that path should restrict the server's egress. Nothing from the connection is returned to a caller.

  • DNS for the TURN host uses tokio's blocking pool: at most 8 lookups at once and a 60 s cache per host.

  • A setup cut off by the setup timeout closes its connection without Refresh 0; the server frees the allocation on the closed connection (coturn does) or when its lifetime runs out (600 s by default, section 12.1). A setup that fails with an error releases its allocation from a task of its own.

  • Queues: 20 packets per direction by default (400 ms of audio; was 128, i.e. 2.56 s of stale audio); a backlog goes out in one write of up to 16 packets. relay_queue_dwell_max_us, relay_queue_slow (> 40 ms), relay_write_max_us and relay_slow_writes (> 20 ms) show a slow TURN connection.

12.3 What was built

webrtc/relay.rs (see its module docs), WebRtcConfig::turn_relay: Option<RelayConfig>, env BRISVOX_WEBRTC_TURN_RELAY=on|only|off and BRISVOX_WEBRTC_TURN_RELAY_TIMEOUT, counters relay_* in WebRtcStats, tests/webrtc_relay.rs (13 tests: a str0m browser that can reach only the relayed address completes ICE and DTLS and carries audio both ways with datagrams_in == 0; the permission exists before the first packet and nothing is dropped for lack of one; refresh of allocation, permissions and channels across 35 virtual minutes on a stepped clock with the call carrying on; release with Refresh 0 on hang-up and shutdown; silent TURN server and wrong secret fall back to the normal candidates, or 503 in relay-only mode, within the setup timeout; permission for the wrong IP means no connection; garbage, oversize and bad-length frames end that allocation only; TLS with a test certificate, and an untrusted certificate refused; no allocation for an offer without an IP candidate or a malformed one).

12.4 UNVERIFIED

  • A real TURN service (Cloudflare, Twilio, coturn) accepting the server's allocation with the same REST credential the browser gets, over turns:...:443?transport=tcp; per-user allocation limits; whether more than one allocation per credential is allowed (the credential is cached and shared by calls and the browser).
  • TLS server-name and ALPN expectations of a 443 multiplexer (no ALPN is sent; RFC 7443 defines stun.turn).
  • A real browser (Chrome, Firefox, Safari) connecting to a relayed host candidate whose base is not local: only a str0m controlling client was tested.
  • Latency and loss behaviour of a media path through TURN-over-TCP (head-of-line blocking) on a real network.
  • Whether a TURN service pads or accepts unpadded ChannelData from the client: padded is what RFC 8656 requires and what the tests' server expects; an unpadded-tolerant server would also accept it.

RTVI (Real-Time Voice Inference) and the Pipecat WebSocket wire format — protocol

Researched 2026-10-08. Every fact below was fetched on that date from the sources listed; anything not confirmed is marked UNVERIFIED. Nothing here is from memory. Fact-checked 2026-10-08 (see the last section).

  • Docs / sources (all fetched 2026-10-08):
    • RTVI standard (docs): https://docs.pipecat.ai/client/rtvi-standard (also .md variant)
    • Pipecat server RTVI docs: https://docs.pipecat.ai/server/frameworks/rtvi/rtvi-processor
    • JS WebSocket transport docs: https://docs.pipecat.ai/client/js/transports/websocket
    • Pipecat source, tag v1.12.0 (published 2026-09-26, tag object c8d5d28c..., commit 1559a684b1ee9771b36454b72418d7364b518e7f (commit hash not re-checked in the fact-check)). main HEAD on 2026-10-08 was a3b149f834d1791249b8b258960b111318d119b4 (commit date 2026-10-08T03:09:05Z). Files used:
      • https://github.com/pipecat-ai/pipecat/blob/v1.12.0/src/pipecat/processors/frameworks/rtvi/models.py
      • .../rtvi/processor.py, .../rtvi/observer.py (same directory)
      • https://github.com/pipecat-ai/pipecat/blob/v1.12.0/src/pipecat/frames/frames.proto
      • https://github.com/pipecat-ai/pipecat/blob/v1.12.0/src/pipecat/serializers/protobuf.py
      • https://github.com/pipecat-ai/pipecat/blob/v1.12.0/src/pipecat/serializers/rtvi_client.py
      • https://github.com/pipecat-ai/pipecat/blob/v1.12.0/src/pipecat/transports/websocket/fastapi.py
    • JS client, tag client-js-v1.13.1 (npm 2026-09-04, commit 936217a73c17db46f64154e4879c8218cc7bd55d): https://github.com/pipecat-ai/pipecat-client-web/tree/client-js-v1.13.1/client-js (rtvi/messages.ts, client/client.ts, client/transport.ts)
    • JS WebSocket transport, tag websocket-transport-v1.7.2 (npm 2026-09-15, commit b1cd6483a555f7d56144f0f5bdfb64d565394442): https://github.com/pipecat-ai/pipecat-client-web-transports/tree/websocket-transport-v1.7.2/transports/websocket-transport (src/webSocketTransport.ts, src/serializers/protobufSerializer.ts, proto/frames.proto)
    • Reference server example: https://github.com/pipecat-ai/pipecat-examples/tree/main/websocket (bot.py, main HEAD 93c0bd5f31e18d5d182a49f4a5eeead60b640db6, commit date 2026-09-23)
    • npm registry: https://registry.npmjs.org/@pipecat-ai/client-js and .../@pipecat-ai/websocket-transport; crates.io API for prost.
  • Licenses: Pipecat (Python), the JS client and the WebSocket transport are BSD-2-Clause (SPDX headers in the files; npm registry says BSD-2-Clause for both client-js and websocket-transport). If the .proto text is copied into this repo, keep the Daily copyright and license header. A Rust re-implementation of the same wire layout via prost structs needs no copied text (design option, not legal advice).
  • Auth: none in the protocol. The JS WebSocket transport accepts an optional token connect param and appends it as a query parameter: ?token=<urlencoded> (or &token= if the URL already has ?). Authentication is the server operator's business (UNVERIFIED how Pipecat's own FastAPI example consumes it; the example bot.py contains no token handling).
  • Endpoint(s) / regions: any WebSocket URL the client is given: connect({ wsUrl }) (ws_url is a deprecated alias) or startBotAndConnect({ endpoint }), where the HTTP endpoint returns connection params ({ wsUrl }; shape UNVERIFIED in this pass). The example README says the client calls POST /start on http://localhost:7860 before opening the WebSocket; the WebSocket path is not given there (UNVERIFIED; an earlier draft said /ws and /api/start, not confirmed). No path convention is required by the client.

Versions (current on 2026-10-08)

ComponentVersionDateNotes
RTVI protocol constant"2.1.0"v1.12.0PROTOCOL_VERSION in models.py, RTVI_PROTOCOL_VERSION in client-js rtvi/messages.ts. Both identical.
Pipecat (Python)v1.12.02026-09-26GitHub latest release. LEGACY_SUPPORTED_MAJOR = 1: 1.x clients are accepted.
@pipecat-ai/client-js1.13.12026-09-04npm latest. BSD-2-Clause. Deps: uuid, bowser, events, clone-deep, typed-emitter, @types/events.
@pipecat-ai/websocket-transport1.7.22026-09-15peer dep @pipecat-ai/client-js ~1.13.0. Deps include @daily-co/daily-js ^0.90.0, @protobuf-ts/runtime ^2.11.0, @protobuf-ts/plugin ^2.11.0, x-law ^0.3.1.

The docs page title is "The RTVI Standard" and its body says "This page documents version 1.0 of the RTVI standard, released in June 2025"; it also says five messages (user-mute-started, user-mute-stopped, llm-function-call-started, llm-function-call-in-progress, llm-function-call-stopped) were "Introduced in RTVI version 1.2.0 (Pipecat 0.0.102, client-js 1.6.0)". These numbers are the page's own release labels and do NOT match the wire version 2.1.0 that both implementations carry (the server docs page also states "The server protocol version is 2.1.0"). Treat the source constants as authoritative; the doc page is internally inconsistent. Per the docs page: bot-transcription deprecated in Pipecat 0.0.95 / client-js 1.5.0; llm-function-call deprecated in Pipecat 0.0.102 / client-js 1.6.0. Protocol version history: UNVERIFIED beyond what the code says (client warns if bot major < 2; the multi-key DTMF form needs bot protocol 2.1.0+, older bots expect a legacy single button message per key; DTMFInputData in models.py has only buttons).

Transports the Pipecat client SDKs use

  • WebSocket: @pipecat-ai/websocket-transport (this doc's focus). Docs call it "best suited for server-server applications and prototyping client/server apps"; a WebRTC-based transport is recommended for client/server production apps.
  • Daily (WebRTC): daily-transport (npm latest 1.6.9, 2026-09-15). RTVI messages travel over the Daily data channel app messages (UNVERIFIED details; not fetched).
  • SmallWebRTC: small-webrtc-transport (npm latest 1.10.8, 2026-09-15). RTVI over a WebRTC data channel (UNVERIFIED).
  • LiveKit: livekit-transport (npm latest 1.0.0, 2026-09-04). Other transports named in the earlier draft (openai-realtime-webrtc, gemini-live-websocket, moq): UNVERIFIED, not re-checked.
  • The RTVI JSON envelope is identical on all transports; only the carrier differs. On WebSocket the carrier is a binary protobuf MessageFrame (below), NOT a bare JSON text frame.

Envelope (identical on every transport)

{ "label": "rtvi-ai", "type": "<string>", "id": "<string>", "data": { } }
  • label: always "rtvi-ai". The JS WebSocket transport silently drops any message whose label differs (if (parsed.message.label === "rtvi-ai")). Pipecat's processor drops (warning log Ignoring not RTVI message, no reply) incoming messages with another label.
  • type: kebab-case string (tables below).
  • id: the JS client generates uuidv4().slice(0, 8) (8 hex chars) for each message. The Pipecat base Message model that validates ALL incoming client messages declares id: str with no default, so a client message without id fails validation (error "Invalid RTVI transport message"). Replies (bot-ready, server-response, error-response) reuse the request id. Server-initiated events (user-started-speaking, bot-llm-text, error, server-message, ...) are separate pydantic models in models.py with NO id field, so Pipecat v1.12.0 emits no id on them. The JS client never reads id on server events.
  • data: for payload-less server events (e.g. user-started-speaking) the Pipecat model has no data field and push_transport_message uses exclude_none=True, so data is omitted. Incoming disconnect-bot from the JS client sends data: {}; Pipecat does not read it.

Protocol (messages in/out, with field names)

Source of truth for the server side is models.py (v1.12.0); client side is client-js/rtvi/messages.ts (v1.13.1). "Seen by JS client" = a switch case exists in client.ts handleMessage; unknown types are logged at debug level (Unrecognized message type) and ignored.

Client to server

typedataPipecat v1.12.0 server behavior
client-ready{ "version": "2.1.0", "about": { "library": "<pkg name>", "library_version": "<ver>", "platform"?, "platform_version"?, "platform_details"? } }Parses version (must split into exactly 3 integers). Same major as server (2) or legacy major 1: accepted. Missing/invalid/other major: sends error-response ("Client version unknown." / "Invalid client version format (...)." / "RTVI version ... is not compatible with server protocol 2.1.0.", each with " Compatibility issues may occur." appended) but the connection continues. Then pushes InputTransportStartAudioStreamingFrame, remembers the request id, fires on_client_ready.
disconnect-bot{} (JS sends empty object; docs: data undefined)Pushes EndWorkerFrame downstream. Payload never read. No reply.
client-message{ "t": "<name>", "d": <any, optional> }Pushed as RTVIClientMessageFrame (with msg_id), fires on_client_message. App answers with send_server_response.
send-text{ "content": "<text>", "options"?: { "run_immediately"?: true, "audio_response"?: true } } (both options default true)If run_immediately: interrupts the bot and flushes the pipeline. If audio_response=false: TTS skipped for this turn (LLMConfigureOutputFrame(skip_tts)), restored afterwards. Then appends {"role":"user","content":...} via LLMMessagesAppendFrame(run_llm=run_immediately).
append-to-contextdeprecated; JS appendToContext() logs a deprecation warning on every callNO case in the processor.py v1.12.0 dispatch, so it gets an error-response Unsupported type append-to-context. The JS enum still has append-to-context and append-to-context-result.
llm-function-call-result{ "function_name", "tool_call_id", "arguments": {..}, "result": {..} or "<string>" }Result goes to the LLM as a FunctionCallResultFrame.
dtmf{ "buttons": ["1","2","#"] } (min 1; keys 0-9 * #)One InputDTMFFrame per key, in order. Multi-key buttons form needs protocol 2.1.0+ (JS comment). Not on the docs standard page.
ui-snapshot / ui-event / ui-cancel-job-groupUI worker protocol (see models.py)Optional; ignore for voice-only.
raw-audio, raw-audio-batch{ "base64Audio": "<b64>", "sampleRate", "numChannels" }; batch uses "base64AudioBatch": [<b64>, ...] (else falls back to base64Audio)Handled by the processor (one InputAudioRawFrame per decoded chunk; missing sampleRate/numChannels is caught and logged). There is no pydantic model for these in models.py. The Python RTVIClientSerializer emits raw-audio (id from a counter, base64 ASCII). Not used by the JS client, which sends audio as protobuf frames.

Server to client

typedataSeen by JS client (callback)
bot-ready{ "version": "2.1.0", "about"?: { "library": "pipecat-ai", "library_version": "<pipecat ver>" } }yes (onBotReady; resolves the pending connect() promise; warns if major < 2; missing version treated as 0.0.0)
error{ "error": "<string>", "fatal": <bool> }yes (onError; fatal: true makes the client disconnect)
error-response{ "error": "<string>" }, id = request idyes (rejects the pending request, onMessageError)
server-response{ "t": "<client-message t>", "d": <any> }, id = request idyes (resolves the pending request)
server-messageany JSON valueyes (onServerMessage)
user-started-speaking, user-stopped-speakingnoneyes
vad-user-started-speaking, vad-user-stopped-speakingnone (default off in the observer)no case in handleMessage (debug log)
bot-started-speaking, bot-stopped-speakingnoneyes
bot-interruptednone (fires on every InterruptionFrame, gated by observer bot_speaking_enabled)NO case in client-js 1.13.1 (debug log only)
user-mute-started, user-mute-stoppednoneyes
user-transcription{ "text", "user_id", "timestamp", "final": <bool> } (interim = final:false)yes (onUserTranscript)
user-llm-text{ "text" }yes
bot-output{ "text", "aggregated_by": "sentence"/"word"/"code"/"url"/..., "segment_id"?, "spoken"? (v1 clients), "will_be_spoken"?, "spoken_status"?: "new"/"in-progress"/"completed", "spoken_progress"?: { "accumulated_text", "remaining_text" } (2.0+ clients) }yes (onBotOutput)
bot-transcription{ "text" } DEPRECATED (use bot-output); Pipecat still emits it when a sentence endsyes (onBotTranscript)
bot-llm-started, bot-llm-stoppednoneyes
bot-llm-text{ "text" } (streamed token/chunk)yes
bot-llm-marker{ "text", "kind"?, "raw", "markers": [] } (off by default, eval only)no
bot-tts-started, bot-tts-stoppednoneyes
bot-tts-text{ "text" }yes
metrics{ "processing"?: [{"processor","value","model"?}], "ttfb"?: [...], "characters"?: [...] } per the docs page; the observer also builds keys ttfa, ttfat, tokens, stt_usageyes
llm-function-call-started{ "function_name"? }yes
llm-function-call-in-progress{ "tool_call_id", "function_name"?, "arguments"? }yes (JS invokes a registered handler by function_name)
llm-function-call-stopped{ "tool_call_id", "cancelled": <bool>, "function_name"?, "result"? }yes
llm-function-call{ "function_name", "tool_call_id", "args" } DEPRECATED since Pipecat 0.0.102yes
bot-llm-search-response{ "search_result"?, "rendered_content"?, "origins"?: [{ "site_uri"?, "site_title"?, "results": [{ "text", "confidence": [0.0] }] }] } (per the docs page; no class in models.py v1.12.0, so whether the v1.12.0 server emits it is UNVERIFIED)yes
bot-tts-audio{ "audio": "<b64>", "sample_rate", "num_channels" }model exists; v1.12.0 observer does NOT emit it (its TTS audio only feeds bot-audio-level)
user-audio-level, bot-audio-level{ "value": <float> } (off by default; throttled by audio_level_period_secs, default 0.15)no
system-log{ "text" } (off by default)no
ui-command, ui-job-groupUI worker protocolyes (not needed)

Function-call fields (function_name, arguments, result) are only populated at the observer's function_call_report_level NAME/ARGUMENTS/FULL; default is {"*": NONE}. Observer defaults (v1.12.0): bot_output, bot_llm, bot_tts, bot_speaking, user_llm, user_speaking, user_mute, user_transcription, metrics enabled; bot_llm_marker, vad_user_speaking, user_audio_level, bot_audio_level, system_logs disabled.

Not present in the v2 protocol sources: the old v0/1.0 config / action / describe-config / describe-actions message family. The server doc page still says the bot-ready response contains the protocol version, "Current service configuration" and available actions, which does not match the BotReadyData model (version, about only) nor the standard page. Treat actions/config as removed. (UNVERIFIED for 1.x legacy clients; they are accepted with only the bot-output legacy behavior mentioned in the docs.)

JSON examples (envelope per models.py; values illustrative)

{"label":"rtvi-ai","type":"client-ready","id":"a1b2c3d4","data":{"version":"2.1.0","about":{"library":"@pipecat-ai/client-js","library_version":"1.13.1"}}}
{"label":"rtvi-ai","type":"bot-ready","id":"a1b2c3d4","data":{"version":"2.1.0","about":{"library":"pipecat-ai","library_version":"1.12.0"}}}
{"label":"rtvi-ai","type":"user-started-speaking"}
{"label":"rtvi-ai","type":"user-transcription","data":{"text":"merhaba","user_id":"","timestamp":"2026-10-08T10:00:00.000+00:00","final":true}}
{"label":"rtvi-ai","type":"bot-llm-started"}
{"label":"rtvi-ai","type":"bot-llm-text","data":{"text":"Merhaba"}}
{"label":"rtvi-ai","type":"bot-llm-stopped"}
{"label":"rtvi-ai","type":"bot-tts-started"}
{"label":"rtvi-ai","type":"bot-tts-text","data":{"text":"Merhaba, nasilsiniz?"}}
{"label":"rtvi-ai","type":"bot-started-speaking"}
{"label":"rtvi-ai","type":"bot-output","data":{"text":"Merhaba, nasilsiniz?","aggregated_by":"sentence","spoken_status":"completed","will_be_spoken":true}}
{"label":"rtvi-ai","type":"bot-stopped-speaking"}
{"label":"rtvi-ai","type":"error","data":{"error":"stt connection lost","fatal":false}}
{"label":"rtvi-ai","type":"client-message","id":"e5f6a7b8","data":{"t":"get-weather","d":{"city":"Ankara"}}}
{"label":"rtvi-ai","type":"server-response","id":"e5f6a7b8","data":{"t":"get-weather","d":{"temp":21}}}
{"label":"rtvi-ai","type":"error-response","id":"e5f6a7b8","data":{"error":"Unsupported type foo"}}
{"label":"rtvi-ai","type":"send-text","id":"c9d0e1f2","data":{"content":"hello","options":{"run_immediately":true,"audio_response":true}}}
{"label":"rtvi-ai","type":"server-message","data":{"any":"json"}}
{"label":"rtvi-ai","type":"metrics","data":{"ttfb":[{"processor":"CartesiaTTSService#0","value":0.21,"model":"sonic"}]}}

The client-ready JSON is built from RTVIMessage.clientReady() (version, about.library, about.library_version, constructed by the JS code; the id is random). The bot-ready JSON mirrors BotReadyData and the processor's default about. All other examples are constructed from the model field names above, not captured from a live run (UNVERIFIED as byte-exact). The timestamp is typed as a plain string in both models.py and the docs page; the format is unspecified (UNVERIFIED; ISO-8601 in practice).

Handshake and ordering

  1. Client opens the WebSocket (ReconnectingWebSocket; ws.on("open") only logs).
  2. PipecatClient.connect(): init devices (mic) if needed, transport.connect() (WebSocket + media manager, transport state connected), then transport.sendReadyMessage(). The WebSocket transport's sendReadyMessage() does this.state = "ready" and sends client-ready immediately (the transport state becomes ready at that moment, NOT when bot-ready arrives).
  3. Mic audio captured before state ready is queued (unbounded queue) and flushed afterwards. Audio therefore starts flowing right after client-ready, before bot-ready may have arrived. The server must tolerate audio frames before it replies.
  4. Server replies bot-ready with the same id as client-ready. In Pipecat the app must call await rtvi.set_bot_ready() (typically in on_client_ready); the processor does not send it by itself (source processor.py; the docs: "The server must mark the bot as ready before it can process client messages"). Its version is the server protocol version, except that a 1.x client gets its own declared version echoed back.
  5. The JS connect() promise resolves on the first bot-ready and has NO timeout in client.ts (it waits indefinitely). The JS client does not check the id of bot-ready (confirmed in client.ts).
  6. Ordering observed in the Pipecat observer (v1.12.0): aggregated bot text is queued until BotStartedSpeakingFrame; then bot-started-speaking is sent first, then the queued bot-output messages; while the bot is speaking, aggregated text is sent immediately. bot-llm-text is sent per LLMTextFrame; the deprecated bot-transcription buffer is built from LLMTextFrame text separately (relative send order between the two: UNVERIFIED). Broadcast frames are handled downstream only.
  7. Shutdown: the JS disconnect() awaits transport.disconnect() (state disconnecting then disconnected; closes the WebSocket; no RTVI message sent). disconnect-bot is an explicit "end the bot" request with no reply. A fatal: true error makes the JS client call disconnect() itself. In the WebSocket transport, close, error, connection-timeout and reconnect-failed socket events each surface as a fatal error to the client.
  8. Client-request correlation: sendClientRequest() (client-message) waits for server-response or error-response with the same id; client.ts applies no default timeout (an optional timeout is passed through); any default lives in MessageDispatcher (not fetched, UNVERIFIED).

Pipecat WebSocket serializer (protobuf) — what a JS WebSocket client needs

The JS WebSocket transport's default serializer is ProtobufFrameSerializer; the only other shipped one is TwilioSerializer (and websocketSerializer.ts, base). The Python server pairs it with FastAPIWebsocketTransport + ProtobufFrameSerializer(), add_wav_header=False (confirmed in pipecat-examples/websocket/bot.py; add_wav_header defaults to False anyway).

Schema: Pipecat server, tag v1.12.0, src/pipecat/frames/frames.proto (BSD-2-Clause)

The file also has a leading comment block explaining how to regenerate frames_pb2.py with grpcio-tools 1.67.x; it is omitted below. Message definitions are verbatim.

//
// Copyright (c) 2024–2026, Daily
//
// SPDX-License-Identifier: BSD 2-Clause License
//

syntax = "proto3";

package pipecat;

message TextFrame {
  uint64 id = 1;
  string name = 2;
  string text = 3;
}

message AudioRawFrame {
  uint64 id = 1;
  string name = 2;
  bytes audio = 3;
  uint32 sample_rate = 4;
  uint32 num_channels = 5;
  optional uint64 pts = 6;
}

message TranscriptionFrame {
  uint64 id = 1;
  string name = 2;
  string text = 3;
  string user_id = 4;
  string timestamp = 5;
}

message MessageFrame {
    string data = 1;
}

message InterruptionFrame {
  uint64 id = 1;
  string name = 2;
}

message Frame {
  oneof frame {
    TextFrame text = 1;
    AudioRawFrame audio = 2;
    TranscriptionFrame transcription = 3;
    MessageFrame message = 4;
    InterruptionFrame interruption = 5;
  }
}

Schema: JS client, tag websocket-transport-v1.7.2, proto/frames.proto (differences only)

Identical messages 1 to 4 and the same Frame field numbers 1 to 4, but there is NO InterruptionFrame and no interruption = 5 in the client's oneof (header says 2024–2025; the client file also carries explanatory comments). Consequence: when Pipecat's ProtobufFrameSerializer sends an interruption frame (SERIALIZABLE_TYPES includes InterruptionFrame), the JS client cannot decode that variant. What the client then does is UNVERIFIED (the serializer throws "Unknown frame kind" for any oneofKind other than audio/message; that ws.on("message") catches and logs it is NOT confirmed; whether protobuf-ts keeps or rejects the unknown field 5 is not confirmed). A Brisvox server should NOT send field 5.

Wire rules (from protobuf.py v1.12.0 and the TS serializer)

  • Each WebSocket message is ONE binary frame holding one serialized Frame. The JS deserialize requires data instanceof Blob, otherwise throws "Unknown data type". So WebSocket TEXT frames from the server are rejected by the default JS serializer: send RTVI messages ONLY as protobuf MessageFrame inside binary frames.
  • RTVI message from either side: Frame{ message: MessageFrame{ data: "<JSON string of the RTVI envelope>" } }. JS sends data: JSON.stringify(msg). Python wraps outgoing transport messages (OutputTransportMessageFrame / OutputTransportMessageUrgentFrame) with MessageFrame(data=json.dumps(frame.message)) and parses incoming with json.loads (a parse failure drops the frame).
  • Audio, client to server: Frame{ audio: AudioRawFrame{ id: 0, name: "audio", audio: <PCM16 LE bytes>, sample_rate: <recorderSampleRate, default 16000>, num_channels: 1 } }. serializeAudio(data, sampleRate, numChannels) is called with channels hardcoded to 1. proto3 omits zero/empty defaults on the wire (id 0 is not encoded).
  • Audio, server to client: Frame{ audio: AudioRawFrame{ audio: <PCM16 LE mono bytes>, sample_rate, num_channels } }. The JS client IGNORES sample_rate and num_channels on receipt (the transport passes the parsed audio to bufferBotAudio; it plays at its configured playerSampleRate, default 24000, configurable only in the WebSocketTransport constructor). The server must therefore produce audio at the rate the client was configured with (24 kHz for the defaults). add_wav_header=False: no WAV header, raw PCM16. (That the bytes are read as Int16Array: confirmed in the serializer.)
  • The JS client discards decoded frames that are neither audio nor message. TextFrame and TranscriptionFrame are in the schema but the TS deserialize does not return them.
  • Python deserialize of client frames: only text, audio, transcription, message, interruption are accepted; audio becomes InputAudioRawFrame; message becomes InputTransportMessageFrame; the id/name/pts fields are copied when truthy. Python serialize sends OutputAudioRawFrame as audio and copies a field only if truthy.
  • Max message size: the JS WebSocket transport sets _maxMessageSize = 1024 * 1024 ("python websockets default to 1MB"), but the file never reads the value again, so its effect (if any) is UNVERIFIED. An earlier draft stated a 64 KiB default in the base Transport class; not confirmed in this pass (UNVERIFIED). Keep each outgoing frame well below 1 MiB (a 20 to 40 ms audio frame is far below).
  • Keepalive: the JS transport sets ws.keepAliveInterval = 0 (no client-initiated pings; comment: "disabling the keep alive, there is no API for it inside Pipecat"). Browser WebSocket stacks answer server pings automatically. On drop the transport tries to reconnect twice (docs), then disconnects (retry count/backoff live in ReconnectingWebSocket, not fetched).
  • Connection params accepted by connect(): only wsUrl/ws_url and token (snake_case keys are converted to camelCase; each must be a string); any other key throws Unrecognized connection parameter: <key>. (the docs say an RTVIError).

Minimum a server must implement for @pipecat-ai/client-js + websocket transport to converse

Verified from the fetched sources (items marked UNVERIFIED are not confirmed by running a client):

  1. Accept a WebSocket upgrade at the URL given in wsUrl (any path; optional ?token=). No subprotocol requirement was found (UNVERIFIED that none is requested).
  2. Speak ONLY binary frames, each a protobuf pipecat.Frame using fields 2 (audio) and 4 (message). Decode audio (PCM16 mono at the client's recorder rate, 16 kHz by default, sample_rate in the frame is authoritative) and message (JSON string, envelope WITH an id).
  3. On client-ready, reply with bot-ready echoing the request id (data.version = "2.1.0" (a major >= 2 avoids the client's warning), optional data.about). Without bot-ready, connect() never resolves (no timeout) and all @transportReady-gated calls (sendText, sendClientMessage, ...) stay unusable.
  4. Stream bot speech as audio frames: PCM16 LE mono at the client's playerSampleRate (default 24 kHz). Pace at roughly real time: the transport has no way to clear audio already handed to the browser's WavStreamPlayer (see interruption below), so over-buffering makes barge-in sound late.
  5. That is enough to talk. All RTVI events in the server table are optional for the client to function (each maps to an optional callback). Recommended minimum for a usable UI: user-started-speaking, user-stopped-speaking, user-transcription, bot-started-speaking, bot-stopped-speaking, bot-llm-text or bot-output, error.
  6. Answer client-message with server-response (same id, data: {t, d}) or error-response, otherwise sendClientRequest() never resolves unless a timeout is set (default UNVERIFIED).
  7. disconnect-bot and the WebSocket close both mean "end the call". No reply is defined.
  8. Barge-in: bot-interrupted is not handled by client-js 1.13.1, and the websocket transport source has no interrupt/clear logic (confirmed in webSocketTransport.ts; it does not reference WavStreamPlayer; bot audio goes to mediaManager.bufferBotAudio). DailyMediaManager.userStartedSpeaking() calling WavStreamPlayer.interrupt() was not re-fetched in the fact-check (UNVERIFIED who triggers it and whether interrupt() drops buffered audio). Do not rely on a client-side flush; keep server-side pacing tight.

Brisvox implications (design notes, not source facts)

  • The existing generic protocol (docs/WEBSOCKET_PROTOCOL.md) carries bare PCM in binary frames and JSON in text frames. RTVI over WebSocket needs a different framing (protobuf Frame in every binary frame, no text frames), so it must be a separate adapter/mode behind the transport trait, not an extension of the generic framing.
  • Rust implementation option: hand-written prost message structs (#[derive(prost::Message)]) matching the field numbers above need no protoc. prost 0.14.4 (updated 2026-06-07, Apache-2.0, 624,317,919 total downloads, repo tokio-rs/prost); prost-build 0.14.4 (Apache-2.0, 2026-06-07). The protobuf crate (BSD-3-Clause per crates.io API for the newest version) is an alternative; its newest listed version was 4.36.2-release, max stable 3.7.2 (updated 2026-09-17; license of 3.7.2 not checked). Final pick: a design decision. No Rust RTVI crate exists: crates.io search for "rtvi" returned only an unrelated crate (portview).
  • Rate mismatch: the JS client plays at 24 kHz by default and ignores the frame's sample_rate. Brisvox's generic server currently drops audio at a rate other than the call's (known limitation in WEBSOCKET_PROTOCOL.md), so the RTVI mode should treat the client's playerSampleRate (24 kHz default) as the call rate for output and 16 kHz for input unless the example configures otherwise.
  • Test target: the Python RTVIClientSerializer (JSON text, not protobuf; used by Pipecat's own eval/websocket client transport) is a different carrier from the JS path. A conformance test should use the protobuf path (JS client) as the primary.

Brisvox engine bridge (design notes, not source facts)

brisvox_engine::rtvi::RtviBridge drives a call's RTVI messages from the engine. What it sends and when (checked end to end by crates/brisvox-engine/tests/rtvi_events.rs, a scripted client speaking the protobuf wire above over a real socket):

  • Events are observed on the inputs of five pipeline stages by the stages' own tasks (brisvox_engine::events; no extra stage or hop) and published to a bounded drop-oldest ring (tokio broadcast), so a slow client loses the oldest events and never stalls the pipeline. One bridge task maps them to RTVI messages and sends them through the call's RtviSender, in order, on one queue. The bridge switches the adapter's own auto_events off for the call (RtviSender::set_auto_events) while it sends the bot speaking events itself.
  • user-started-speaking / user-stopped-speaking: the user's turn as the turn driver decides it (as Pipecat 1.x's turn start/stop strategies do), not the raw VAD; the VAD variants (vad-user-*, off by default in Pipecat's observer) are not sent. The turn driver publishes them itself, so every start gets its stop, also for a turn without words (noise, an empty final), a held end that a new turn supersedes, and the hang-up. A typed turn raises neither (Pipecat's send-text neither).
  • user-transcription: every STT transcript (interim final: false, final segments final: true), user_id empty, timestamp ISO-8601 UTC from the injected clock.
  • user-llm-text: the whole text of the turn the LLM answers (sent before user-stopped-speaking; the driver publishes both as it emits the transcript and the turn end).
  • bot-llm-started / bot-llm-text (each streamed delta) / bot-llm-stopped: per LLM round.
  • bot-tts-started (before the first chunk of a response), bot-tts-text (each chunk sent to the TTS, verbatim), bot-tts-stopped (the TTS produced the last audio, or a barge-in).
  • bot-output per TTS chunk (aggregated_by: "sentence", will_be_spoken: true), held until bot-started-speaking as Pipecat's observer does (step 6 of the handshake section); held ones are dropped on a barge-in (never spoken).
  • bot-started-speaking: the first audio of the response handed to the transport; bot-stopped-speaking: the transport reports the response's end mark played (for RTVI the adapter's playout estimate).
  • bot-interrupted, then bot-stopped-speaking, bot-tts-stopped, bot-llm-stopped for what was running; events of the ended epoch that arrive later are dropped.
  • metrics once per turn, right after bot-started-speaking: ttfb of llm (time to first token) and tts (time to first byte), processing of stt (VAD stop to final), turn (end-of-turn delay) and response (server-side voice-to-voice), in seconds, model omitted.
  • send-text: a user turn of its own (an interruption first while the bot thinks or speaks), the text appended to the context as a user message and answered, with no user speaking events; while the user speaks it joins the open turn; run_immediately: false holds it for the next turn (joined to that turn's user message) instead of appending a separate message at once without running the LLM (Pipecat's behavior; a documented deviation); audio_response: false is not supported (answered with audio, counted).
  • client-message: answered by the application's handler (server-response echoing t with the handler's d), or error-response on its error, after reply_timeout (default 5 s) or without a handler. Up to 8 replies run at once in their own tasks, beside the inbox, so a slow handler never delays a later send-text; a request beyond 8 in flight gets an error-response at once.
  • Not sent: llm-function-call-*, bot-transcription (deprecated), bot-llm-search-response, bot-tts-audio, audio levels, system-log, user-mute-*.

Keepalive / timeouts

  • JS client: no client pings (keepAliveInterval = 0); no timeout waiting for bot-ready.
  • Server (Pipecat FastAPIWebsocketParams, v1.12.0 transports/websocket/fastapi.py): session_timeout (default None), ws_close_timeout (default 0.5 s), allowed_origins (default from env PIPECAT_ALLOWED_ORIGINS, comma-separated; empty list = allow all; when set, a missing or disallowed Origin header is rejected; parsing code not fetched).
  • Brisvox should still enforce its own start_timeout equivalent (e.g. time to receive client-ready), since the client never will on its own if the server is silent.

Finalize / flush / cancel semantics

  • No explicit flush. Interruption: Pipecat emits bot-interrupted (any interruption) and sends the protobuf interruption frame (field 5, unknown to the JS client schema). send-text with run_immediately=true interrupts and flushes the pipeline before appending.
  • disconnect-bot: ends the pipeline worker; no response.

Timestamps / interim results / tool-call deltas

  • user-transcription.timestamp is a string; final=false for interim results (InterimTranscriptionFrame).
  • bot-output word-level progress: spoken_progress{accumulated_text, remaining_text} ("Word-level TTS progress within a spoken segment"); no per-word timestamp fields appear in models.py.
  • Tool calls: llm-function-call-started/in-progress/stopped (not deltas of arguments); client results return via llm-function-call-result.

Concurrency / rate limits

None defined by the protocol. _maxMessageSize 1 MiB is set in the JS WebSocket transport (effect UNVERIFIED, see above).

Turkish support

The protocol is language neutral (UTF-8 JSON inside a protobuf string). Turkish is the providers' concern, not RTVI's.

Gotchas found during research

  • Only binary frames: a text frame from the server makes the default JS serializer throw.
  • sample_rate on incoming bot audio is ignored by the JS client; 24 kHz assumed by default.
  • Transport state becomes ready when the client SENDS client-ready, not when bot-ready arrives; user audio flows before bot-ready.
  • JS client proto lacks InterruptionFrame (field 5) that the server proto has.
  • Docs page body ("version 1.0 ... June 2025", "RTVI 1.2.0") contradicts the wire version 2.1.0; follow the source constants.
  • bot-transcription, llm-function-call, append-to-context are deprecated; bot-output and llm-function-call-in-progress replace them.
  • Server doc claims config/actions in bot-ready; the model has only version and about.
  • Client messages MUST carry an id (Pipecat's Message model requires it).

UNVERIFIED (collected)

  1. Whether any WebSocket subprotocol or specific binaryType is required by the client.
  2. Behavior of the JS client on receiving protobuf field 5 (interruption) and who calls DailyMediaManager.userStartedSpeaking() / what WavStreamPlayer.interrupt() does.
  3. The default timeout of sendClientRequest() (MessageDispatcher not fetched).
  4. Daily, SmallWebRTC, openai-realtime-webrtc, gemini-live-websocket, moq RTVI carrier details and existence (not fetched).
  5. Byte-exactness of the constructed JSON examples above and the timestamp string format.
  6. How Pipecat's example consumes the token query parameter (the example bot has no token code).
  7. An end-to-end run with a real JS client (not possible in this session); the implementation must do it.
  8. The example server's WebSocket path and the startBotAndConnect response shape; the effect of _maxMessageSize; base Transport default max size; whether the JS ws.on("message") handler catches deserialize errors; relative send order of bot-llm-text vs bot-transcription; whether the v1.12.0 server ever emits bot-llm-search-response; license of protobuf 3.7.2; commit hash 1559a684... of tag v1.12.0.

Fact-check log

Fact-checked 2026-10-08: 98 claims checked, 15 corrected. Corrections:

  1. Docs-page title/labels: page is titled "The RTVI Standard"; "version 1.0 ... June 2025" is body text; the "1.2.0" label covers five specific messages (mute and function-call started/in-progress/stopped).
  2. raw-audio data fields: single base64Audio, batch uses base64AudioBatch (list); no model in models.py; "Field semantics UNVERIFIED" replaced with the processor code facts.
  3. append-to-context: now CONFIRMED absent from the processor dispatch (was inferred from a summary).
  4. Server-initiated events: CONFIRMED to have no id field in models.py (was UNVERIFIED); payload-less events omit data (was UNVERIFIED); client messages require id (new fact).
  5. Reference example endpoints: README says POST /start; /ws and /api/start could not be confirmed (moved to UNVERIFIED).
  6. frames.proto (server) was labeled verbatim but omitted the regeneration comment block; now stated.
  7. "No per-word timing fields exist" was quoted as a model docstring; that phrase is not in models.py (closest text: "Word-level TTS progress within a spoken segment"); reworded.
  8. JS bot-ready: "no id check" CONFIRMED (was UNVERIFIED).
  9. sendClientRequest / connect(): no default timeout in client.ts; the note that requests "reject only by timeout" corrected (they never resolve without a timeout).
  10. dtmf: "needs protocol 2.x" corrected to 2.1.0+ for the buttons form; the earlier "button (2.0)" history is only a JS code comment about legacy bots; DTMF is not on the docs page.
  11. Connection param error: confirmed text is Unrecognized connection parameter: <key>.; the RTVIError class comes from the docs, not from the transport source.
  12. Max message size: base Transport 64 KiB claim not confirmed (UNVERIFIED); the 1 MiB value is never read after being set in the transport file.
  13. client-ready error texts and the exactly-three-integers version format added; the label-drop warning text added.
  14. Dependency lists: added @types/events (client-js) and @protobuf-ts/plugin, x-law ^0.3.1 (transport); ws.on("message") catches deserialize errors downgraded to UNVERIFIED; the FastAPI transport defaults (session_timeout, ws_close_timeout, allowed_origins) now cite transports/websocket/fastapi.py, which was previously uncited.
  15. Ordering of bot-llm-text relative to bot-transcription buffer update was not confirmed by the observer summary; downgraded to UNVERIFIED. bot-llm-search-response is not in models.py; shape is from the docs page only.

Confirmed unchanged (examples): versions/dates for Pipecat v1.12.0, client-js 1.13.1, websocket-transport 1.7.2, daily-transport 1.6.9, small-webrtc-transport 1.10.8, livekit-transport 1.0.0; commit hashes of main, examples, client-js, websocket-transport tags and the v1.12.0 tag object; PROTOCOL_VERSION 2.1.0 in both implementations; LEGACY_SUPPORTED_MAJOR 1; licenses (BSD-2-Clause for npm packages); prost/prost-build 0.14.4 Apache-2.0, 624,317,919 downloads, 2026-06-07; protobuf crate versions and license; "rtvi" crate search result; proto schemas (server and client); keepalive 0; 24 kHz / 16 kHz defaults; token param format; Blob requirement; audio queueing before ready; fatal error disconnect; observer defaults and metrics keys; bot-tts-audio not emitted; bot-interrupted/vad/audio-level/system-log/marker not handled by JS.

Silero VAD (ONNX) — VAD

  • Docs: https://github.com/snakers4/silero-vad (README, fetched 2026-10-08)
  • Source: https://raw.githubusercontent.com/snakers4/silero-vad/master/src/silero_vad/utils_vad.py (OnnxWrapper, VADIterator, get_speech_timestamps)
  • Source: https://raw.githubusercontent.com/snakers4/silero-vad/master/src/silero_vad/model.py (load_silero_vad)
  • Version history: https://github.com/snakers4/silero-vad/wiki/Version-history-and-Available-Models
  • Releases: https://github.com/snakers4/silero-vad/releases
  • PyPI: https://pypi.org/project/silero-vad/
  • Fetched: 2026-10-08. Fetched through a summarizing tool, so quotes are short excerpts; re-check exact text before relying on byte-level details.

Versions and license

  • Latest package: silero-vad 6.2.3 on PyPI, dated "Sep 23, 2026" (PyPI page). GitHub release v6.2.3 "Make torchaudio optional" dated "23 Sep" (year not shown there; matches PyPI).
  • Previous: v6.2.2 (17 Sep, adds silero_vad_16k_sequence.onnx, 16 kHz only), v6.2.1 (24 Feb, onnxruntime made optional), v6.2 (06 Nov), v6.1 (05 Nov), v6.0 (26 Aug), v5.1.x (Jul/Oct).
  • Model versions (wiki): v5.0 JIT+ONNX added 2024-06-27; v6.0 JIT+ONNX added 2025-08-25; v6.2 "Added ifless ONNX and tinygrad 16k models" 2025-12-10.
  • Wiki date vs release date for v6.2 do not match (wiki 2025-12-10; releases page "06 Nov", year not shown). UNVERIFIED which is the model-add date.
  • License: MIT. LICENSE text begins "MIT License / Copyright (c) 2020-present Silero Team" (https://raw.githubusercontent.com/snakers4/silero-vad/master/LICENSE). PyPI also says MIT.
  • UNVERIFIED conflict: the GitHub page has a badge whose alt text reads "License: CC BY-NC 4.0". Not resolved; check whether it refers to model weights or a sub-asset before shipping model files.

ONNX file

  • Path in repo: src/silero_vad/data/silero_vad.onnx. Other files there: silero_vad_16k_op15.onnx, silero_vad_16k_sequence.onnx, silero_vad_half.onnx, silero_vad_op18_ifless.onnx, silero_vad_openvino_16k.onnx, silero_vad.jit.
  • load_silero_vad(onnx=True, opset_version=16) loads silero_vad.onnx. Other opsets (only 15 and 16 are allowed) load silero_vad_16k_op{N}.onnx (16k only). sequence=True loads silero_vad_16k_sequence.onnx. silero_vad_op18_ifless.onnx is NOT referenced by any code path (op18 cannot be selected).
  • Sample rates: README says "Silero VAD supports 8000 Hz and 16000 Hz sampling rates." The wrapper sets sample_rates = [16000] only if '16k' is in the path, else [8000, 16000]. So silero_vad.onnx accepts both.
  • Opset: 15 or 16 (wiki: "ONNX opset 15 and 16").
  • Runtime: onnxruntime is optional. PyPI extra onnx-cpu installs onnxruntime>=1.16.1 and numpy. Brisvox can load the file with onnxruntime directly; no PyPI wrapper needed.

Pinned source

Pin by tag, not by master (master is mutable).

  • Release tag: v6.2.3 (GitHub release, published 2026-09-23T11:36:47Z; target_commitish master at publish time).
  • Tag v6.2.3 resolves to commit 5cd7945676eb32225748052e2e6a0580e4686a08 (source: https://api.github.com/repos/snakers4/silero-vad/git/refs/tags/v6.2.3, fetched 2026-10-08).
  • File at that tag: src/silero_vad/data/silero_vad.onnx, size 2327524 bytes, git blob SHA 80c5592ef1f4c9ede3e357bbd02eb863358a6a9d (source: https://api.github.com/repos/snakers4/silero-vad/contents/src/silero_vad/data/silero_vad.onnx?ref=v6.2.3, fetched 2026-10-08).
  • Download URL (pinned): https://raw.githubusercontent.com/snakers4/silero-vad/v6.2.3/src/silero_vad/data/silero_vad.onnx
  • Git blob SHA-1 is NOT the SHA-256 needed for vendoring. The GitHub API does not return a SHA-256 digest for this asset (field absent in the response). Release has no uploaded assets.
  • SHA-256 of silero_vad.onnx at v6.2.3: UNVERIFIED. Not computed: the research environment has no shell and fetched text cannot yield the raw bytes. To finish: download the pinned URL above, run sha256sum silero_vad.onnx, confirm size 2327524 and git blob hash (git hash-object gives the blob SHA-1 for the same bytes; it should equal 80c5592e...), then record the SHA-256 here and in the design notes.
  • Brisvox build code must not fetch this file until the SHA-256 is recorded and checked.

Model generation of the file (research, 2026-10-08): still UNVERIFIED

  • GitHub file page (https://github.com/snakers4/silero-vad/blob/master/src/silero_vad/data/silero_vad.onnx): only "2.22 MB" shown; no version, producer, opset or context size. Binary contents not fetchable, so model.metadata_props, producer_name and producer_version could NOT be read.
  • Wiki version table (URL above): lists v5.0, v6.0 and v6.2 (ifless ONNX, tinygrad 16k) but does NOT map file names to versions and does NOT state context sizes. Current table entry: 'silero_vad' about 260K params, streaming, ONNX opsets 15 and 16.
  • Releases page: v6.0 and v6.2 notes do not mention ONNX or context. Only v6.2.2 names a file (silero_vad_16k_sequence.onnx).
  • Code (master): no per-version branches (no 'v5', 'v6', 'op18' or 'ifless' checks). The context_size = 64 if sr == 16000 else 32 rule applies to whatever file is loaded, but the code does not prove which generation silero_vad.onnx is.
  • Consequence: the 64-sample context and (2,1,128) state shapes come from master code, not from the graph. Treat them as UNVERIFIED for the v6.2.3 file.

Graph I/O names (confirmed from the binary graph)

  • Source: https://raw.githubusercontent.com/snakers4/silero-vad/master/src/silero_vad/data/silero_vad.onnx (master, fetched 2026-10-08 via WebFetch as a 2.2 MB binary; printable strings were then extracted from the saved copy). Producer string: spox2.
  • Graph-level inputs, in order: input, state, sr. Graph-level outputs, in order: output, stateN. sr is consumed by node Equal_0; outputs come from node If_0 (If_0_outputs_0, If_0_outputs_1). The branch on sr is inside the graph; the else_branch holds the STFT (stft.forward_basis_buffer), encoder conv+ReLU blocks (encoder/0..3) and a decoder with an rnn sub-graph.
  • Therefore session.run(None, {'input': ..., 'state': ..., 'sr': ...}) returns [output, stateN], consistent with the wrapper's out, state = ort_outs.
  • NOT confirmed: element types and dims. The protobuf type fields were not decodable with the tools available (no Python/onnx or onnxruntime execution in the research environment). Dtypes/shapes in the table below are from wrapper code only.
  • To close locally: onnx.load(path).graph.input/output and onnxruntime.InferenceSession(path).get_inputs()/get_outputs() on the pinned v6.2.3 file; record names, dtypes, shapes here.

Tensor contract (from OnnxWrapper.call, master)

Session call (verbatim, utils_vad.py): ort_inputs = {'input': x.numpy(), 'state': self._state.numpy(), 'sr': np.array(sr, dtype='int64')} ort_outs = self.session.run(None, ort_inputs) out, state = ort_outs (outputs are positional; the code does not read names; graph names are output and stateN)

NameDirectionShapeDtypeNotes
inputin(batch, context + window) = (1, 576) at 16 kHz; (1, 288) at 8 kHzfloat32 (inferred from torch.zeros/.float(); graph dtype UNVERIFIED)name CONFIRMED in graph; context prepended each call
statein(2, batch, 128) = (2, 1, 128)float32 (inferred)name CONFIRMED; reset_states: torch.zeros((2, batch_size, 128)).float()
srinscalarint64name CONFIRMED; np.array(sr, dtype='int64'); 16000 or 8000
outputout(batch, 1) per code pathfloat32 (inferred)name CONFIRMED; speech probability; returned without reshaping
stateNout(2, batch, 128) (inferred from input)float32 (inferred)name CONFIRMED; new state; stored as self._state

Window / context (verbatim logic in __call__):

  • 16 kHz: window 512 samples (32 ms), context 64 samples. context_size = 64 if sr == 16000 else 32.
  • 8 kHz: window 256 samples (32 ms), context 32 samples.
  • Per call: x = torch.cat([self._context, x], dim=1); then self._context = x[..., -context_size:] after the call. So the context is the last 64 (or 32) samples of the previous chunk, carried across calls. Initial context is zeros (torch.zeros(batch_size, context_size)).
  • Input length check (_validate_input): if sr / x.shape[1] > 31.25: raises. Chunks shorter than 512 samples at 16 kHz (or 256 at 8 kHz) are rejected.
  • Multiples of 16 kHz (e.g. 48 kHz) are downsampled to 16 kHz by _validate_input (summary of code; exact line not quoted).
  • Whether v5 used the same 64-sample context: UNVERIFIED. Only master code was read.
  • The Validator class in the same file runs the session with only input (no state/sr). Do not copy that path.

Reset: OnnxWrapper.reset_states(batch_size=1) zeros _state and _context and clears last sr/batch. VADIterator.reset_states() also clears triggered, temp_end, current_sample. Call both between calls/utterances, or the context and LSTM-like state carry over.

Reference streaming algorithm (VADIterator, master)

Defaults (VADIterator.__init__):

  • threshold=0.5, sampling_rate=16000, min_silence_duration_ms=100, speech_pad_ms=30, neg_threshold=None.
  • If neg_threshold is None: max(threshold - 0.15, 0.01).

Methods (master, per fetched summary of the full file): VADIterator defines only __init__, reset_states, __call__. No finalize, flush, close or end method exists in utils_vad.py.

Start/end logic (verbatim lines):

  • Start: if (speech_prob >= self.threshold) and not self.triggered: then self.triggered = True and returns {'start': ...}; speech_start = max(0, self.current_sample - self.speech_pad_samples - window_size_samples)
  • End trigger: if (speech_prob < self.neg_threshold) and self.triggered: then self.temp_end = self.current_sample
  • Confirm end: if self.current_sample - self.temp_end < self.min_silence_samples: returns None (keep waiting); otherwise self.triggered = False and returns {'end': ...} with speech_end = self.temp_end + self.speech_pad_samples - window_size_samples
  • Returns sample indices (or seconds with return_seconds=True).

get_speech_timestamps defaults: threshold=0.5, sampling_rate=16000, min_speech_duration_ms=250, max_speech_duration_s=inf, min_silence_duration_ms=100, speech_pad_ms=30, neg_threshold=None, window_size_samples=512 (overwritten to 256 at 8 kHz), min_silence_at_max_speech=98, return_seconds=False.

  • min_speech_samples = sampling_rate * min_speech_duration_ms / 1000.

Note: get_speech_timestamps is offline (whole clip); use VADIterator or a copy of its per-chunk state machine for streaming.

End of stream / session close (researched 2026-10-08)

  • Source: https://raw.githubusercontent.com/snakers4/silero-vad/master/src/silero_vad/utils_vad.py (fetched 2026-10-08 via a summarizing tool; the tool did not return the class verbatim, so a byte-exact re-check is recommended).
  • Per that fetch: the file has no end-of-input handling. VADIterator.__call__ processes one chunk per call and never clears triggered by itself. reset_states() is called only from __init__ or explicitly by the caller.
  • Consequence: if the stream stops while triggered is True, VADIterator emits NO end event. Only reset_states() clears triggered, and it emits nothing.
  • Brisvox decision (implemented in the review fixes): the VAD adapter owns the stop-on-close rule. VadStream::finish() (and VadDetector::finish()) reports a Stop when speech is in progress, dated to the end of the last speech window plus speech_pad, never past the end of the last window fed, then resets the stream (reset_states() on the model). Close with no open speech returns None. VadStream::reset() discards speech in progress without an event and is for a new, unrelated stream only. The synthetic stop has one event kind: the EndOfStream/Cancelled reason field is NOT implemented (no consumer needs it yet; add it with the turn-taking code if a consumer does). A partial window (fewer than 512 samples) at close is dropped.
  • Test: stream_finish_reports_the_open_segment_and_resets, finish_closes_open_speech_with_a_stop (crates/brisvox-inference/src/vad.rs); the boundary test in tests/silero.rs closes each fixture with finish().
  • UNVERIFIED: whether any upstream release (including Python get_speech_timestamps) defines an end-of-stream rule. Only master utils_vad.py was checked.

CPU cost

  • README (https://github.com/snakers4/silero-vad, fetched 2026-10-08), verbatim: "One audio chunk (30+ ms) takes less than 1ms to be processed on a single CPU thread." No CPU model, OS, runtime version, or batch/thread conditions given; the wiki (not fetched) has detail. "Under certain conditions ONNX may even run up to 4-5x faster": baseline and conditions UNVERIFIED.
  • Same-machine comparison (Xeon Gold 6348 only): Silero RTF 0.0127 in the TEN README table. That table's RTF definition is UNVERIFIED (see vad-alternatives.md section 3). Derived [inference]: ~0.41 ms per 32 ms window.
  • Wrapper sets intra/inter-op threads to 1 (OnnxWrapper.__init__, summary).
  • Status: vendor claim only for the README figure. Brisvox (Rust ort 2.0.0-rc.13, one intra/one inter-op thread, release build) was measured on the development machine (Windows 11, AMD64 family 26 model 68), 2026-10-08, with cargo test --release -p brisvox-inference --all-features --test silero -- --ignored --nocapture: 7580 windows (20 passes over the EN fixture), mean 64.3 µs, p50 59.8 µs, p99 118.7 µs, max 506.6 µs per SileroVad::infer call. Still UNVERIFIED for the target container (no cgroup or CPU-quota run yet). The ~63 µs measured when the runtime was chosen is the same order and is not a separate measurement.
  • Allocations per call (Rust heap, counting allocator, a_call_makes_no_heap_copies_of_its_own): 1320 bytes, which is the ort wrapper's own bookkeeping. The brisvox-side input and state copies (estimated at about 4.3 KB per call before the review fixes; not measured on the old code) are gone. The test fails if one input copy (2304 bytes) comes back.
  • Measurement plan (NOT run; no execution tool in this pass):
    • (a) Python onnxruntime reference, intra/inter-op threads 1, pinned v6.2.3 file with SHA-256 recorded first. Per call: wall time over >= 10,000 calls after warmup; p50/p99/max; on the target container CPU (record /proc/cpuinfo model and cgroup CPU quota). Also batch=1 and N=8/32/128.
    • (b) Rust ort build, same model and options; compare per-call cost and output probabilities against (a) on the same recorded audio. Record results with date and commit hash. Anything above ~50 µs per window goes to brisvox-inference.

Engineering notes for Brisvox

  • Keepalive/timeouts: none (local model). Per-chunk inference should run on the inference pool (not on tokio workers), per the engineering invariants in CONTRIBUTING.md, if it exceeds ~50 µs. Whether it does on the target container is UNVERIFIED until measured.
  • Cancellation: no native cancel; drop the session and rebuild state with reset_states().
  • Interim results: none; output is one probability per 32 ms window.
  • Turkish: language-agnostic VAD; no language-specific claims found.
  • Gotchas: context must be prepended to every chunk; omitting it changes the probabilities. Keep state and context per session (never share across sessions). Use sr as int64.
  • Mocking/tests: the fixture needs a recorded (input, state) -> (output, stateN) sequence from the actual ONNX file; none recorded yet.

UNVERIFIED list

  • SHA-256 of silero_vad.onnx at tag v6.2.3 (blob SHA-1 and size are confirmed above; SHA-256 is not).
  • Model generation (v5, v6.0 or v6.2) of silero_vad.onnx at v6.2.3. Wiki lists versions but not file-to-version mapping. The graph's node naming (encoder/0..3/reparam_conv, decoder/rnn, stft) is consistent with the current wrapper but does not fix a version.
  • Element types and dims of graph tensors (names are confirmed; dtypes/shapes are inferred from wrapper code only).
  • Context size 64 and state (2,1,128) in the shipped graph (master code only).
  • Whether master's silero_vad.onnx is byte-identical to v6.2.3 (not compared).
  • Year of GitHub release dates without year (PyPI confirms Sep 23, 2026 for 6.2.3); wiki v6.2 date vs releases page date not reconciled.
  • The CC BY-NC 4.0 badge conflict.
  • v5-specific context handling (no v5 code or file was read).
  • Upstream end-of-stream behaviour beyond master utils_vad.py.
  • All Brisvox per-window CPU and latency numbers (no measurement run); TEN RTF definition, hop, thread count, CPU-row mapping.

Confirmed locally (2026-10-08)

Downloaded the pinned file and inspected it with onnx 1.23.2 / onnxruntime 1.30.0:

  • silero_vad.onnx at v6.2.3: 2327524 bytes, git blob 80c5592ef1f4c9ede3e357bbd02eb863358a6a9d (matches the GitHub API), SHA-256 1a153a22f4509e292a94e67d6f9b85e8deb25b4988682b7e174c65279d8788e3.
  • License: MIT (LICENSE file; README: "Published under permissive license (MIT)"). The "CC BY-NC 4.0" text is a stale alt text of a badge whose image reads "License: MIT" and links to the MIT LICENSE - not a license grant.
  • Graph (producer spox, IR 8, opset 16; top-level ops Constant/Equal/Identity/If - the If selects the 16 kHz or 8 kHz subgraph from sr):
    • inputs: input float [batch, samples], state float [2, batch, 128], sr int64 scalar;
    • outputs: output float [batch, 1] (speech probability), stateN float [2, batch, 128].
  • Streaming per the reference wrapper (512 new samples + 64 context = [1, 576] at 16 kHz) on the three committed fixtures: ~76 µs per 32 ms window (p99 ~86 µs) with onnxruntime, one thread, including Python call overhead - far below the README's "< 1 ms" claim. Golden speech probabilities from this run are the reference for the Rust implementation's tests.

In brisvox

brisvox_inference::silero::SileroVad (feature silero) implements SpeechModel; streams use brisvox_inference::VadStream + VadDetector (hysteresis: threshold 0.5 / neg 0.35, min speech 64 ms, min silence 100 ms, pad 30 ms). Tests (crates/brisvox-inference/tests/silero.rs):

  • probabilities within 1e-7 of the onnxruntime reference (fixtures/vad_golden.json, generated by scripts/models/make_vad_goldens.py) on every window. Measured worst case: 5.96e-8 on all three fixtures (one f32 ulp at 0.5; the goldens are rounded to 7 decimals, so this is the floor). The test asserts 1e-7, so the documented 6e-8 accuracy is enforced with a small margin.
  • start/stop within 50 ms of the fixtures' ground truth (EN and TR), asserted. Measured worst case: 50 ms (en_tr_mixed start, 400 ms vs 450 ms; the stream is fed 20 ms frames). A 60 ms default pad makes the test fail (78 ms), so a pad regression is caught.
  • Malformed model outputs (empty output, non-finite or out-of-range probability, wrong stateN length) are errors, not silence or a stale state (silero::tests).
  • Runtime choice: ONNX Runtime via the ort crate.

Reviewed behaviour (review fixes, crates/brisvox-inference/src/vad.rs):

  • A model error drops only its window; the rest of the batch is processed, the context still advances, and the first error is returned after the batch.
  • A band window (neg_threshold <= p < threshold) neither confirms nor cancels a pending stop; only a window >= threshold cancels it. The fetched reference (above) runs its end trigger only on windows below neg_threshold, so band windows leave its pending end alone; the cancel-on- threshold rule is from the reference source as remembered, not re-fetched (UNVERIFIED detail).
  • Events are time-ordered. A start backdated past the previous event is dated to that event; this only happens when speech_pad is at least half of min_silence (not with the defaults).
  • Stream close: VadStream::finish() reports the open segment's stop (see "End of stream").

Pure-Rust backend

brisvox_inference::silero_tract (feature silero-tract) runs the prepared graph (crates/brisvox-inference/assets/silero_vad_16k_tract.onnx, embedded): 36-45 µs per window, within 2.1e-6 of the reference, identical boundaries. Prepare/regenerate it with scripts/models/prepare_silero_tract.py (needs onnx + onnxruntime; byte-identical with onnxruntime 1.30.0). Speed comparison test: cargo test --release -p brisvox-inference --all-features --test vad_speed -- --nocapture.

VAD alternatives and turn detection — research notes

  • Fetched: 2026-10-08. Scope: alternatives/complements to Silero VAD, Pipecat defaults, Smart Turn.
  • Legend: [V] = stated on a page fetched today. [UNVERIFIED] = not confirmed by a fetched page.
  • Not a provider API doc; this file is a research note. Template _TEMPLATE.md fields do not apply.

1. Pipecat defaults [V]

  • Sources: speech-input guide https://docs.pipecat.ai/guides/learn/speech-input ; API ref https://docs.pipecat.ai/api-reference/server/utilities/audio/silero-vad-analyzer.md ; source https://reference-server.pipecat.ai/en/latest/_modules/pipecat/audio/vad/vad_analyzer.html
  • Default VAD: SileroVADAnalyzer() with no arguments. Silero is the recommended VAD; Krisp VIVA is offered for higher sample rates.
  • SileroVADAnalyzer(sample_rate=None, params=VADParams()). sample_rate must be 8000 or 16000. Frames: 256 samples at 8 kHz, 512 samples at 16 kHz (32 ms).
  • VADParams defaults (source constants VAD_CONFIDENCE, VAD_START_SECS, VAD_STOP_SECS, VAD_MIN_VOLUME):
    • confidence = 0.7 ("higher values make detection more strict", 0..1)
    • start_secs = 0.2
    • stop_secs = 0.2
    • min_volume = 0.6
  • Decision rule (source): speaking = confidence >= params.confidence and volume >= params.min_volume. Volume is exponentially smoothed with factor 0.2. A high-confidence frame below min_volume counts as non-speech.
  • State machine (source): QUIET -> STARTING (on first speaking frame) -> SPEAKING (after start_secs of speaking frames). SPEAKING -> STOPPING (first non-speaking frame) -> QUIET (after stop_secs). STOPPING returns to SPEAKING if speech resumes.
  • Model state reset: "Automatically resets model state every 5 seconds" (API ref).
  • Discrepancy: an older search snippet listed stop_secs default 0.8. The fetched current guide and source both say 0.2. Treat 0.2 as current.
  • The speech-input guide says Smart Turn is the default turn-end strategy: TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3()). VAD start/stop signals are inputs to the turn strategy, not the final turn decision. The guide's P99 STT latency numbers assume stop_secs=0.2.
  • The fetched guide does NOT say stop_secs is auto-modified with turn detection (an older snippet claimed this) [UNVERIFIED].

2. Silero VAD (current baseline)

  • License [V]: MIT, "Copyright (c) 2020-present Silero Team" — https://raw.githubusercontent.com/snakers4/silero-vad/master/LICENSE. A README badge alt text said "CC BY-NC 4.0"; the LICENSE file says MIT, so MIT governs.
  • Latency [V, vendor claim, no conditions]: "One audio chunk (30+ ms) takes less than 1 ms to be processed on a single CPU thread" — https://github.com/snakers4/silero-vad. No CPU model, OS, or runtime version given. README also says batching or GPU "can also improve performance considerably."
    • No Brisvox measurement exists; UNVERIFIED for our container. Details and the measurement plan: silero-vad.md "CPU cost".
  • Size: TEN README library-size table lists Silero "2.16MB (JIT) / 2.22MB (ONNX)" [V via TEN README, not Silero's own page]. Pinned v6.2.3 ONNX file is 2327524 bytes (see silero-vad.md).
  • Version label "v5" and the ONNX graph's dynamic batch axis: [UNVERIFIED] from fetched pages. The Silero README did not state a version.
  • Batched inference [V at wrapper level] — https://raw.githubusercontent.com/snakers4/silero-vad/master/src/silero_vad/utils_vad.py (OnnxWrapper):
    • Hidden state: torch.zeros((2, batch_size, 128)). Shape (2, 1, 128) for batch 1. The batch dimension is in the state tensor.
    • Context: torch.zeros(batch_size, context_size), context_size = 64 if sr == 16000 else 32, prepended to each chunk and carried over as x[..., -context_size:].
    • Chunk: num_samples = 512 if sr == 16000 else 256; other lengths raise ValueError.
    • batch_size = x.shape[0]; no assertion limits batch size to 1. Only inputs with more than 2 dims are rejected.
    • A batch-size change calls reset_states(batch_size), which zeroes state for all rows.
    • Implication (inference, [UNVERIFIED] against the raw ONNX graph): per-stream state is row-independent, so N sessions can be stacked as one batch, with per-stream state and context rows gathered and scattered each call. Batch membership must stay fixed between calls, or state is reset. Needs a benchmark and a test that a batched session matches its solo run.

3. TEN VAD

  • Repo: https://github.com/TEN-framework/ten-vad
  • License [V]: "Apache License v2.0 with additional conditions" by Agora (Copyright 2025 Agora), per https://raw.githubusercontent.com/TEN-framework/ten-vad/main/LICENSE. README: "This project is licensed pursuant to the Apache 2.0 with additional conditions." pitch_est.cc contains LPCNet-derived code (BSD-2/3-Clause) per NOTICES.
    • Clause 1: may not Deploy "in a way that competes with Agora's offerings."
    • Clause 2: Deploy "solely to create and enable deployment of your Application(s)" for you and your direct End Users.
    • Clause 3: Derivative Works stay under this license.
    • This is not plain Apache-2.0. The competition clause may conflict with unrestricted open-source redistribution. Needs legal review before any use in Brisvox. Not a legal conclusion.
  • Size [V]: library size (not model size): Linux 306 KB; Windows 464 KB (x86) / 508 KB (x64); macOS 731 KB; iOS 320 KB; Web 277 KB; Android 373 KB (v7a) / 532 KB (v8a). ONNX model size: not stated [UNVERIFIED].
  • Latency [V, README RTF table, fetched 2026-10-08; RTF definition UNVERIFIED]:
    • The README only expands the acronym ("RTF (Real-Time Factor)"). It does NOT state the formula, per-chunk vs per-second basis, hop size, thread count, or measurement procedure. Do not treat these numbers as comparable to our measurements.
    • Only same-machine Silero comparison: Linux Xeon Gold 6348 @ 2.60GHz, TEN 0.0086 vs Silero 0.0127. Row-to-CPU mapping came from a garbled text extraction; re-check on the page.
    • Other TEN RTFs (extraction): Ryzen 9 5900X 0.0150; Xeon Platinum 8253 0.0136; i7-10710U (Windows) 0.0150; M1 0.0160; Web/M1 0.010; iPhone 6 (A8) 0.0210; iPhone 8 (A11) 0.0050; Galaxy J6+ 0.0570; Oppo A3s 0.0490.
    • Derived [inference, assumes RTF = compute time / audio duration]: TEN 0.0086 on a 10 ms hop ~0.09 ms; Silero 0.0127 on a 32 ms window ~0.41 ms.
    • No Brisvox measurement exists. The CPU-per-frame baseline for Silero is still required.
  • Input [V]: 16 kHz only (other rates must be resampled). Hop 160 or 256 samples = 10 or 16 ms ("configurable hop sizes"; "optimized frame configurations").
  • Platforms [V]: Linux x64; Windows x64/x86; macOS arm64/x86_64; WASM; Android arm64-v8a/armeabi-v7a; iOS arm64 (not simulator).
  • Bindings [V]: Python, C, Java, Go, JavaScript. No official Rust crate listed. A third-party ten-vad-rs crate exists (https://rust-digger.code-maven.com/crates/ten-vad-rs); its license and maintenance are [UNVERIFIED].
  • Accuracy [V, vendor claim]: "superior precision compared to both WebRTC VAD and Silero VAD." Vendor PR curves are an image only; no P/R/F1 in text. Vendor test set: "precisely manually annotated" LibriSpeech/GigaSpeech/DNS audio in repo testset/ [V]; label format and image values [UNVERIFIED]. Third-party table with numbers: section 8.
  • Vendor claim [UNVERIFIED]: Silero "suffers from a delay of several hundred milliseconds" on speech-to-non-speech transitions. No measurement given.

4. WebRTC VAD

  • Rust crate: webrtc-vad 0.4.0. Crate metadata license MIT (https://docs.rs/crate/webrtc-vad/latest). It wraps libfvad and builds it with cc-rs, so a C compiler is required (https://github.com/kaegi/webrtc-vad).
  • Maintenance: the search snippet listed four releases between 2019-08-14 and 2019-10-01 [V via search snippet]. Repo shows no Releases section. Treat as stale/unmaintained [UNVERIFIED as a conclusion].
  • Underlying C library: libfvad, license BSD-3-Clause [V] (https://github.com/dpirch/libfvad). A fork of the WebRTC VAD engine; API in include/fvad.h.
  • Sample rates, frame lengths, and aggressiveness modes: NOT in fetched pages [UNVERIFIED; commonly 8/16/32/48 kHz, 10/20/30 ms, four modes, but not confirmed].
  • Quality vs neural VAD: third-party F1 52.30 / MR 64.15 at 0.5-threshold-type operating point (section 8) [V, third-party]. Vendor claims only otherwise [UNVERIFIED].
  • Pure-Rust alternative: none checked [UNVERIFIED].

5. Smart Turn (turn detection)

  • Repo: https://github.com/pipecat-ai/smart-turn ; HF card: https://huggingface.co/pipecat-ai/smart-turn-v3 ; HF file tree: https://huggingface.co/pipecat-ai/smart-turn-v3/tree/main
  • License [V]: BSD-2-Clause ("a truly open model"), per repo README and HF card.
  • Pipecat support: LocalSmartTurnAnalyzerV3 available since Pipecat v0.0.85 (repo README).
  • Architecture [V]: Whisper Tiny encoder layers plus a linear classifier head (HF card: "Whisper Tiny encoder" backbone, "shallow linear classifier", ~8M params). Static int8 QAT, exported to ONNX (Daily blog: https://www.daily.co/blog/announcing-smart-turn-v3-with-cpu-inference-in-just-12ms/).
  • Versions and ONNX files [V, HF file tree]: smart-turn-v3.0.onnx (8.76 MB), smart-turn-v3.1-cpu.onnx (8.68 MB), smart-turn-v3.1-gpu.onnx (32.4 MB), smart-turn-v3.2-cpu.onnx (8.68 MB), smart-turn-v3.2-gpu.onnx (32.4 MB). Repo README is titled "v3.2"; HF card says "v3.x". Which file is current: [UNVERIFIED]. Changelog not found.
  • Input contract [V, repo README]: 16 kHz, mono, PCM. Up to 8 s; shorter input is zero-padded at the beginning, longer input keeps the last ~8 s. Pass the full audio of the current user turn. Designed to be used with a lightweight VAD such as Silero.
  • ONNX I/O [V, repo inference.py at main, fetched 2026-10-08 — https://raw.githubusercontent.com/pipecat-ai/smart-turn/main/inference.py]:
    • Reference code loads ONNX_MODEL_PATH = "smart-turn-v3.1.onnx" via ort.InferenceSession(onnx_path, sess_options=so) (sequential execution, inter_op_num_threads = 1, ORT_ENABLE_ALL). Note: the reference file names v3.1, not v3.2.
    • Input tensor name: "input_features"; session.run(None, {"input_features": input_features}); dtype float32; shape (1, n_mels, frames) after expand_dims(axis=0). Exact n_mels/frames not stated in code; n_mels is not set explicitly (Whisper default 80 is [UNVERIFIED] inference).
    • Output: tensor name NOT given; code takes outputs[0][0].item() (first output by position). A code comment says the model returns sigmoid probabilities; the code applies no sigmoid [UNVERIFIED that the graph contains the sigmoid].
    • Threshold [V, code]: prediction = 1 if probability > 0.5 else 0 (strict >, 0.5). predict_endpoint returns a dict with keys prediction and probability.
    • Feature extractor [V, code]: WhisperFeatureExtractor(chunk_length=8), sampling_rate=16000, padding="max_length", max_length=8*16000 (128000 samples), truncation=True, do_normalize=True. Pre-step truncate_audio_to_last_n_seconds(audio, n_seconds=8) lives in audio_utils, not fetched [UNVERIFIED].
    • Rust implication: ort (ONNX Runtime) path is consistent with this code, but the log-mel frontend (Whisper-compatible, 16 kHz, 8 s, normalization) must be reimplemented in Rust and matched to the Python output. UNVERIFIED end-to-end; needs a parity test against Python on fixed audio.
  • Size [V]: CPU int8 ONNX 8 MB; unquantized (GPU) 32 MB (README: ~1% more accurate, per README). v2 about 400 MB (Daily blog).
  • Inference cost [V, Daily blog table, v3 numbers, includes preprocessing]: CPU AWS c7a.2xlarge 12.6 ms; c8g.2xlarge 15.2 ms; Modal 6-core CPU 17.7 ms; t3.2xlarge 33.8 ms; c8g.medium 59.8 ms; t3.medium 94.8 ms. GPU: L40S 3.3 ms; T4 6.6 ms. Repo README: "as little as 10 ms on some CPUs, under 100 ms on most cloud instances." These are vendor numbers; benchmark methodology and CPU models are not stated, and we have not measured them.
  • v2 [search snippets only, UNVERIFIED on primary page]: wav2vec2 backbone, ~94.8M params, ~360 MB, BSD-2-Clause, 14 languages. Not suitable for CPU-only.
  • Per-turn cost [inference from README]: one invocation per end-of-turn candidate, on the full turn audio (up to 8 s), not per 32 ms frame. Run on the brisvox-inference pool.
  • Batching [UNVERIFIED]: not documented. Fixed 8 s padded input suggests a batch dimension might work; needs a test.
  • Turkish [UNVERIFIED]: the README lists 23 languages "among others" (named: Arabic, Bengali, Chinese, English, Hindi, Japanese, Spanish, Vietnamese). The HF card has tag "multilingual" and no tr tag on the fetched page. Turkish presence not confirmed; treat as unknown.

6. Language and general notes

  • VAD operates on audio energy/spectral features; no language dependency is assumed for Silero or TEN. Turkish is not separately verified for any VAD here [UNVERIFIED].

7. Summary for implementers

  • Default: keep Silero (MIT). Vendor claim: <1 ms per 32 ms chunk on one CPU thread (UNVERIFIED for our container; no Brisvox measurement). Batch dimension present in its state tensor [V at wrapper level].
  • Latency comparisons: no Brisvox measurement exists, and the TEN RTF values have no stated definition. The per-window CPU must be measured for Silero (Python ORT reference and Rust ort) on the target container before any latency claim.
  • TEN VAD: better RTF on one benchmark machine [V vendor, definition UNVERIFIED], 16 kHz only, no official Rust binding, and a non-standard Apache license with a competition clause. Needs owner/legal decision before use in an open-source engine; benchmark only.
  • WebRTC VAD: MIT wrapper + BSD-3-Clause libfvad; stale crate. Only as a cheap pre-gate if accuracy is validated.
  • Smart Turn v3 (BSD-2-Clause, 16 kHz, 8 s window): best-documented candidate for turn detection. Input name input_features (float32, Whisper-style features), output = first ONNX output, probability with threshold 0.5 per reference code [V]. Output tensor name and Turkish coverage remain UNVERIFIED. Benchmark on target hardware; run on the inference pool.
  • VAD accuracy: use the section 8 protocol. Fixture labels are energy-derived, not independent; hand-label speech edges first.
  • Open questions: TEN VAD license acceptability; Smart Turn Turkish coverage and which ONNX version (v3.1 vs v3.2) to ship; hand-labelling of fixture speech edges.

8. Accuracy protocol for VAD (metric and labels) — added 2026-10-08

  • Repo ground truth [V, files read 2026-10-08]: fixtures/<name>.json lists speech segments (start, end in 16 kHz samples). Committed: en_two_utterances (2), tr_two_utterances (2), en_tr_mixed (2). Local-only fixtures/large/ has 4 sidecars.
  • Caveat [V, docs/TESTING.md "Ground truth"]: boundaries are where the build script's 10 ms energy gate fires, NOT human-labelled onsets. The energy-VAD check is circular, and model VADs may differ by tens of ms at soft word edges. Silence between clips is synthetic, so non-speech is exact; speech edges are the weak part.
  • Proposed metric [inference, not a fetched standard]:
    • Frames: 10 ms hop (160 samples at 16 kHz). Reference speech = frame centre inside a segment.
    • Frame F1, miss rate (MR), false-alarm rate (FAR) at a fixed threshold; AUC-ROC (threshold-free).
    • Boundary error: per segment, |pred_onset - ref_start| and |pred_offset - ref_end| in ms; report median, p90, and share within 50 ms (50 ms is a project choice, UNVERIFIED as a standard).
    • Silero gives one probability per 32 ms chunk (512 samples at 16 kHz). Map to 10 ms frames by holding the value, and say so in the report.
    • Operating points: 0.5 [Silero default threshold UNVERIFIED: README does not state it] and Pipecat VADParams.confidence 0.7 [V, section 1].
  • Label gap: hand-label the 6 committed speech onsets/offsets (10 ms resolution), e.g. hand_start/hand_end in each sidecar, and score against them. docs/TESTING.md (metric section) and the sidecars are NOT changed by this note.
  • Published labelled comparison [V: https://arxiv.org/html/2603.10420, Sec. 7.2, third-party FireRedASR2S paper]: FLEURS-VAD-102 (9,443 files, 102 languages, ~100 files per language from FLEURS test, manual binary labels; voice = speech + singing; non-voice = music, silence, noise). 25 ms window, 10 ms shift, fixed 0.5 posterior. Frame-level %:
    • FireRedVAD: AUC 99.60, F1 97.57, FAR 2.69, MR 3.62.
    • Silero-VAD: AUC 97.99, F1 95.95, FAR 9.41, MR 3.95.
    • WebRTC-VAD: F1 52.30, FAR 2.83, MR 64.15 (no AUC).
    • TEN-VAD: AUC 97.81, F1 95.19, FAR 15.47, MR 2.95.
    • Not reproduced here. FLEURS-VAD-102 download location and license: UNVERIFIED (search found no dataset page). Turkish breakdown: UNVERIFIED.
  • Implication for TEN [inference from the table]: at 0.5, TEN's FAR (15.47%) is higher than Silero's (9.41%). That does not support the vendor's precision claim at this operating point. Vendor claim stays UNVERIFIED.

Pipecat Smart Turn — turn-completion model (local ONNX, not a cloud provider)

  • Docs: https://github.com/pipecat-ai/smart-turn (README, inference.py, audio_utils.py, LICENSE) ; https://huggingface.co/pipecat-ai/smart-turn-v3 ; https://docs.pipecat.ai/api-reference/server/utilities/turn-detection/smart-turn-overview (fetched 2026-10-08)
  • Pipecat source: https://raw.githubusercontent.com/pipecat-ai/pipecat/main/src/pipecat/audio/turn/smart_turn/local_smart_turn_v3.py (fetched 2026-10-08)
  • Reference inference script: https://raw.githubusercontent.com/pipecat-ai/smart-turn/main/inference.py (fetched 2026-10-08)
  • Reference audio helper: https://raw.githubusercontent.com/pipecat-ai/smart-turn/main/audio_utils.py (fetched 2026-10-08)
  • Whisper feature extractor source: https://raw.githubusercontent.com/huggingface/transformers/main/src/transformers/models/whisper/feature_extraction_whisper.py (fetched 2026-10-08)
  • Type: local model, ONNX Runtime on CPU. No auth, no endpoint, no network calls.
  • Items marked UNVERIFIED were not confirmed by a fetched page.

Model

  • Latest version: Smart Turn v3.2 (HF commit f766f81d3cfdf7737ac64aad813d91bbfd56bf93, title "Smart Turn v3.2", author marcus-daily). Source: https://huggingface.co/pipecat-ai/smart-turn-v3/commits/main

    • Release date: 2026-01-07T18:00:39Z (CONFIRMED, HF API date field of commit f766f81, fetched 2026-10-08: https://huggingface.co/api/models/pipecat-ai/smart-turn-v3/commits/main). The HTML commit page shows the same commit at the top of main as "Jan 7" (year omitted there).
    • Head of main: f766f81 is the first (newest) entry in both the API list and the HTML history as fetched 2026-10-08. The API has no explicit HEAD flag, so "latest" means newest entry in the commit list, not a separately confirmed ref. Re-check before pinning a newer version.
    • Previous: v3.1 commit 16c8130, Dec 3, 2025; v3.0 commit 100f018, Sep 11, 2025.
  • HF repo pipecat-ai/smart-turn-v3 main branch files (https://huggingface.co/pipecat-ai/smart-turn-v3/tree/main):

    FileSizeNote
    smart-turn-v3.2-cpu.onnx8.68 MBint8-quantized per README (recommended for CPU)
    smart-turn-v3.2-gpu.onnx32.4 MBfp32 unquantized; README says ~1% better accuracy than CPU
    smart-turn-v3.1-cpu.onnx / -gpu.onnx8.68 MB / 32.4 MBolder
    smart-turn-v3.0.onnx8.76 MBoldest
  • SHA-256 of smart-turn-v3.2-cpu.onnx: 2bb026316b14a660486a75b1733cd3fbab8c2fd0314dc9af7be49f8cca967e4f (CONFIRMED on https://huggingface.co/pipecat-ai/smart-turn-v3/blob/main/smart-turn-v3.2-cpu.onnx, fetched 2026-10-08). Exact byte count: UNVERIFIED (page shows only "8.68 MB"). Xet hash aca6a9dc57f624c40984caae7a92965eee9850a034565a189d3e3969e0eb4080 (not a SHA-256).

  • GPU file SHA-256: UNVERIFIED (not fetched).

  • ONNX opset of the Pipecat CPU file: UNVERIFIED (opset 18 is stated only for a third-party GPU re-export, see Input/output contract).

  • Architecture: Whisper Tiny encoder + shallow linear classifier head, ~8M parameters (HF model card). Source: https://huggingface.co/pipecat-ai/smart-turn-v3

  • Training data: pipecat-ai/smart-turn-data-v3.1-train (~270k) and -test (~31.5k) per HF page; README references smart-turn-data-v3.2-*.

Bundled model used by Pipecat (CONFIRMED 2026-10-08)

  • Pipecat LocalSmartTurnAnalyzerV3 (local_smart_turn_v3.py): def __init__(self, *, smart_turn_model_path: str | None = None, cpu_count: int = 1, **kwargs).
  • When smart_turn_model_path is unset, the code sets model_name = "smart-turn-v3.2-cpu.onnx" and package_path = "pipecat.audio.turn.smart_turn.data", then loads impresources.files(package_path).joinpath(model_name). The file contains no URL and no download logic, so the model is read from the installed package at runtime. Source: the Pipecat URL above.
  • Pin for Brisvox parity: smart-turn-v3.2-cpu.onnx (v3.2, int8 CPU build), SHA-256 2bb026316b14...e4f. Brisvox must select the model by this name and hash, never by an unpinned default path.
  • Still UNVERIFIED: that the bytes in the Pipecat wheel's data/ directory match the HF file (packaged copy's SHA-256 not checked), and that the wheel ships the .onnx rather than requiring a separate download (pyproject/manifest not fetched).
  • Gotcha (CONFIRMED): inference.py (smart-turn repo) sets ONNX_MODEL_PATH = "smart-turn-v3.1.onnx". That filename is not in the current HF file list (only -v3.1-cpu.onnx / -v3.1-gpu.onnx exist), so the inference.py default is stale. Always pass the path explicitly. build_session(onnx_path) takes the path as its only argument.
  • Default turn stop strategy (CONFIRMED, version check, 2026-10-08): Pipecat's default user turn stop strategy is TurnAnalyzerUserTurnStopStrategy with LocalSmartTurnAnalyzerV3, "as of v0.0.102" (https://docs.pipecat.ai/api-reference/server/utilities/turn-detection/smart-turn-overview; release https://github.com/pipecat-ai/pipecat/releases/tag/v0.0.102, PR #3689, previous default TranscriptionUserTurnStopStrategy; CHANGELOG [0.0.102] - 2026-02-10, https://raw.githubusercontent.com/pipecat-ai/pipecat/main/CHANGELOG.md). The GitHub release page shows "11 Feb" without a year; the year comes from the CHANGELOG.
  • "Bundles this copy since v0.0.85": UNVERIFIED. The v0.0.85 release page (https://github.com/pipecat-ai/pipecat/releases/tag/v0.0.85) and CHANGELOG [0.0.85] - 2025-09-12 never mention SmartTurn or bundling. Nearby: 0.0.77 added LocalSmartTurnAnalyzerV2; 0.0.86 says torch/torchaudio are "no longer required for running Smart Turn locally". The bundling version is unconfirmed.
  • Old docs URL https://docs.pipecat.ai/server/utilities/smart-turn returns HTTP 404 (CONFIRMED 2026-10-08). Do not cite it.

License

Status (license check, 2026-10-08): code license CONFIRMED. Weights license DECLARED (HF tag and README) but no license text covers the weights. Whisper (MIT) attribution for the derived weights is IDENTIFIED; whether it applies is UNVERIFIED legal interpretation.

  • Code: BSD-2-Clause (CONFIRMED). https://github.com/pipecat-ai/smart-turn/blob/main/LICENSE: "BSD 2-Clause License", "Copyright (c) 2024–2025, Daily". SPDX header # SPDX-License-Identifier: BSD 2-Clause License in Pipecat's local_smart_turn_v3.py: https://raw.githubusercontent.com/pipecat-ai/pipecat/main/src/pipecat/audio/turn/smart_turn/local_smart_turn_v3.py
  • Weights, declared (declaration only): HF model card renders "License: bsd-2-clause" (https://huggingface.co/pipecat-ai/smart-turn-v3; raw YAML front matter not seen, UNVERIFIED for other license fields). README (https://github.com/pipecat-ai/smart-turn): "This is a truly open model (BSD 2-clause license)." and "The datasets, training script, and model weights are all open source."
  • Gap: the Daily LICENSE speaks only of "this software" and "source and binary forms" and never mentions model weights. No separate weights LICENSE or NOTICE appears in the HF file list. The BSD-2 grant over the ONNX file therefore rests on the HF tag and README wording alone.
  • Base model: Whisper Tiny (OpenAI). No base_model field on the rendered HF page (UNVERIFIED for raw front matter). Named in the HF card ("Backbone: Whisper Tiny encoder") and README ("uses Whisper Tiny as a base").
  • Whisper license (CONFIRMED): https://github.com/openai/whisper/blob/main/LICENSE is MIT License, "Copyright (c) 2022 OpenAI", and "The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software." https://github.com/openai/whisper/blob/main/README.md: "Whisper's code and model weights are released under the MIT License."
  • Attribution obligation: OpenAI applies MIT to Whisper's weights, and Smart Turn's encoder derives from Whisper Tiny. Conservative reading: redistributing smart-turn ONNX files requires the OpenAI copyright line and MIT permission notice. Whether a fine-tuned derivative is "substantial portions of the Software" is NOT addressed by any fetched source (UNVERIFIED). No fetched page gives a notice instruction for smart-turn.
  • Brisvox recommendation (not yet a recorded decision): do not commit or ship smart-turn-*.onnx in the repo or release artifacts; fetch the pinned file from HF at verify time. If any artifact bundles it, ship a notice with the Daily BSD-2 notice and the OpenAI MIT notice. Record the final choice in the design notes.
  • Training datasets license: UNVERIFIED.

Input/output contract

Sources: upstream inference.py, audio_utils.py, and Pipecat bundled local_smart_turn_v3.py (URLs above), fetched 2026-10-08. The ONNX graph of smart-turn-v3.2-cpu.onnx was NOT read (no local runtime; HF resolve URL is a binary CDN the fetch tool cannot parse). Tensor shapes, axes, and output name stay UNVERIFIED until the file is inspected.

Input:

  • ONNX input name: input_features (CONFIRMED: session.run(None, {"input_features": input_features}) in both upstream and Pipecat code).
  • Batch dim: np.expand_dims(x, axis=0) (CONFIRMED).
  • Dtype: float32 (CONFIRMED in code). Graph declared element type: UNVERIFIED.
  • Sample rate: 16 kHz, mono. Resample upstream (_MODEL_SAMPLE_RATE = 16000 in Pipecat source).
  • Window: 8 s = 128,000 samples (truncate_audio_to_last_n_seconds(audio, n_seconds=8, sample_rate=16000), audio_utils.py). Too long: keep the last 8 s (audio_array[-max_samples:]). Too short: np.pad with zeros at the start, audio at the end.
  • Feature extraction (two code paths; equivalence UNVERIFIED):
    • Upstream inference.py: WhisperFeatureExtractor(chunk_length=8), called with sampling_rate=16000, return_tensors="np", padding="max_length", max_length=8*16000, truncation=True, do_normalize=True.
    • Pipecat bundled copy: does NOT call WhisperFeatureExtractor. It calls a vendored compute_whisper_log_mel_features(audio_array, do_normalize=True), then np.expand_dims(log_mel, axis=0).
  • Whisper extractor defaults (CONFIRMED from transformers source, fetched 2026-10-08): feature_size=80, sampling_rate=16000, hop_length=160, n_fft=400, padding_value=0.0, dither=0.0; chunk_length default 30 s, overridden to 8 by smart-turn.
  • Log-mel steps (CONFIRMED from transformers source, PyTorch path): Hann STFT; power spectrum over all frames except the last (stft[..., :-1]); mel filters mel_filters.T @ magnitudes; clamp(min=1e-10).log10(); floor at max - 8.0; then (log_spec + 4.0) / 4.0.
  • do_normalize=True (CONFIRMED): per waveform (x - mean) / sqrt(var + 1e-7) over valid (unpadded) samples, applied before the STFT.
  • Input shape (1, 80, 800): DERIVED (128000 / 160 = 800 frames after dropping the last frame), NOT read from the graph. Verify with the dump. Fixed vs dynamic axes: UNVERIFIED.

Output:

  • Read as outputs[0][0] (Pipecat, cast to float) or outputs[0][0].item() (upstream). Index 0 is the probability. Pipecat comment: "ONNX model returns sigmoid probabilities" (CONFIRMED from code comment; no sigmoid applied in code).
  • Output tensor name: UNVERIFIED (code passes None, so all outputs are returned and the name is never referenced).
  • Output dtype and shape: UNVERIFIED (code implies a batch-1 array whose first element is the scalar probability).
  • Decision: prediction = 1 if probability > 0.5 else 0. 1 = complete, 0 = incomplete.
  • Pipecat SmartTurnParams: stop_secs default 3.0 s (silence fallback), pre_speech_ms default 500, max_duration_secs default 8.0 (as stated in the earlier fetch; not re-checked in the version check).
  • VAD stop_secs: default 0.2 s since v0.0.102 (was 0.8 s; PR #3659; CONFIRMED on the v0.0.102 release page and CHANGELOG). Docs: "We recommend 0.2 seconds, which is the default value" (https://docs.pipecat.ai/api-reference/server/utilities/turn-detection/smart-turn-overview).
  • The 0.5 threshold is the upstream default, not tuned for Turkish (see Languages).

Third-party, NOT the Pipecat CPU file (do not apply without checking the file):

  • https://huggingface.co/soniqo/Smart-Turn-v3.2-ONNX describes a re-export of the GPU fp32 variant (smart-turn-v3.2-gpu.onnx, revision f766f81d) with the log-mel front end INSIDE the graph. Stated I/O: input audio float32 [1, 128000] (16 kHz mono, zeros at front), output probability float32 [1, 1], opset 18. Different contract from input_features. Do not conflate.

Session options (CONFIRMED from both sources):

  • Pipecat LocalSmartTurnAnalyzerV3: execution_mode = ORT_SEQUENTIAL, inter_op_num_threads = 1, intra_op_num_threads = cpu_count (default 1), graph_optimization_level = ORT_ENABLE_ALL, ort.InferenceSession(path, sess_options=so). No providers argument (ORT default CPU EP).
  • Reference inference.py build_session: same options but no intra_op_num_threads set. Follow the Pipecat analyzer for thread config.
  • Brisvox forbids blocking work on tokio workers, so run inference on the inference pool.

Languages

  • README (https://github.com/pipecat-ai/smart-turn, fetched 2026-10-08) says "Support for 23 languages" and lists Turkish among them: Arabic, Bengali, Chinese, Danish, Dutch, German, English, Finnish, French, Hindi, Indonesian, Italian, Japanese, Korean, Marathi, Norwegian, Polish, Portuguese, Russian, Spanish, Turkish, Ukrainian, Vietnamese. (CONFIRMED as a vendor claim; not a tested result.)
  • The HF card is tagged multilingual but does not list languages. Pipecat docs do not mention Turkish.
  • Turkish accuracy/evaluation numbers: not found (UNVERIFIED).
  • Third-party (not primary): arXiv 2609.05631 (https://arxiv.org/pdf/2609.05631) reports smart-turn-v3.2 zero-shot int8 on Tamil at 70.30%. Expect degradation outside the training distribution; measure Turkish on our own data.

Accuracy and latency

Accuracy (vendor or third-party claims)

  • No absolute accuracy number on the official pages. README: GPU fp32 is "slightly improved accuracy by around 1%" over CPU int8.
  • Third-party soniqo.audio: ~92.9–93.0% on 1,000 clips of the upstream test set. Not verified against a primary source (UNVERIFIED).

Latency: claimed vs measured

Status: measured (2026-10-08) - see "In brisvox" below and bench/results/smart-turn.md. The vendor and third-party claims below are kept for comparison.

  • Pipecat README (https://github.com/pipecat-ai/smart-turn): "Runs in as little as 10ms on some CPUs, and under 100ms on most cloud instances." "around 65ms on a standard 1x instance" on Pipecat Cloud (no CPU model; UNVERIFIED for v3.2).
  • Daily v3 article (https://www.daily.co/blog/announcing-smart-turn-v3-with-cpu-inference-in-just-12ms/): 12.6 ms on AWS c7a.2xlarge; c8g.2xlarge 15.2 ms; Modal 6-core 17.7 ms; t3.2xlarge 33.8 ms; c8g.medium 59.8 ms; t3.medium 94.8 ms. Session: ORT_SEQUENTIAL, inter_op_num_threads=1, ORT_ENABLE_ALL, CPUExecutionProvider. Intra-op threads and vCPU counts not stated.
  • soniqo.audio guide (https://soniqo.audio/guides/turn), Apple M5 Pro, one 8 s window, not comparable to x86: ONNX fp32/int8 about 20 ms at 4 threads, about 36 ms at 2 threads. HF page (https://huggingface.co/soniqo/Smart-Turn-v3.2-ONNX) gives mean/p95 at 2 threads: fp32 36.3 / 51.4 ms, int8 37.3 / 50.6 ms. Which number belongs to which setup: UNVERIFIED.
  • Neither source gives ORT version, language binding, run count, warm-up policy, or cold-start timings.

Benchmark (run 2026-10-08)

Rust: cargo test --release -p brisvox-inference --all-features --test smart_turn -- --nocapture --test-threads=1 features_and_model_latency; Python reference: scripts/models/bench_smart_turn.py. Results, hardware and settings: bench/results/smart-turn.md. Still to do on the target container (Linux, CPU limits): see docs/LATENCY.md.

Golden fixtures: recorded

scripts/models/make_smart_turn_goldens.py (onnxruntime 1.28.0, Pipecat extractor at commit 3b2dccd8) writes fixtures/smart_turn_golden.json: 12 cases (3 fixtures x 2 segments x complete / cut at 55%) with probability (7 decimals) and the FNV-1a 64 hash of each case's features, plus the full feature matrix of the first case (fixtures/smart_turn_features.f32). Tolerances (measured, then enforced): features bit-identical on all 12 cases; probability < 1e-6 (rounding of the goldens). Only Pipecat's vendored extractor was used; transformers' WhisperFeatureExtractor was not compared.

Cost

  • Model: free, BSD-2-Clause (declared, see License), self-hosted. No per-call API fee. No Pipecat Cloud pricing was fetched (UNVERIFIED).
  • Compute: CPU inference only; ~8.7 MB model. Per-turn CPU time on our target container: UNVERIFIED (see benchmark plan).
  • GPU path (onnxruntime-gpu, 32 MB fp32) is optional per Pipecat docs; not needed for a CPU-first design.

Open items for Brisvox

  1. Confirm the Pipecat wheel's packaged copy matches the HF file byte for byte (UNVERIFIED).
  2. Measure Turkish accuracy on our own recorded utterances; the 0.5 threshold is untuned.
  3. Latency on the target container (Linux, CPU limits), cold and warm.
  4. License: if the ONNX file is ever bundled, ship the Daily BSD-2 and OpenAI MIT notices (record the decision). Until then it is fetched at verify time and not redistributed.
  5. Version pin: compare against Pipecat >= v0.0.102 behaviour (Smart Turn default, VAD stop_secs 0.2). The 0.0.85 bundling claim stays UNVERIFIED.
  6. Pure-Rust backend: investigated (2026-10-13) and not adopted; see "Pure-Rust backend" below.

Confirmed locally (2026-10-08)

  • Downloaded smart-turn-v3.2-cpu.onnx: 8679182 bytes, SHA-256 2bb026316b14a660486a75b1733cd3fbab8c2fd0314dc9af7be49f8cca967e4f (matches the HF LFS oid); fetched by scripts/models/fetch_models.py.
  • Graph (onnxruntime 1.30.0): input input_features float [batch, 80, 800], output logits float [batch, 1] - despite the name, already a sigmoid probability (values 0..1; Pipecat compares it to 0.5).
  • Pipecat's _predict_endpoint (read at commit 3b2dccd8): resample to 16 kHz, keep the LAST 8 s or zero-pad at the START to 128000 samples, compute_whisper_log_mel_features(do_normalize=True) (vendored numpy: waveform mean/variance normalization (eps 1e-7) in f32, reflect pad 200, periodic Hann 400, hop 160, |rFFT|^2, Slaney mel 80 x 201 (0-8 kHz, area-normalized), max(1e-10), log10, drop last frame, max(x, max-8), (x+4)/4 -> (80, 800) f32).
  • Goldens (scripts/models/make_smart_turn_goldens.py -> fixtures/smart_turn_golden.json and the full feature matrix of the first case in fixtures/smart_turn_features.f32; onnxruntime 1.28.0): complete utterances 0.91-0.99; utterances cut at 55% from 0.010 to 0.99 (cuts at a natural pause score high).

In brisvox (measured 2026-10-08)

  • brisvox_inference::mel::WhisperLogMel: Rust port of Pipecat's extractor, bit-identical features on all 12 golden cases (FNV-1a hashes in the goldens; normalization reproduces numpy's float32 pairwise sums and f32 division; Hann and |z|^2 use numpy's formulas; log-mel kept in f64 until the final cast). Bit-exactness is measured on Windows x86-64; another libm (log10/cos/hypot) could differ in the last ulp, which the hash test would report.
  • brisvox_inference::smart_turn::{SmartTurn, TurnModel}: per-session PCM16 ring of the last 8 s (256 KB) plus a snapshot buffer (256 KB). analyze() copies the ring (~2 us, the only work on the caller's thread) and runs window, features and model on the inference pool, with scratch buffers owned by the pool thread (~3.4 MB per thread that has run one); it returns probability, verdict (> 0.5) and pool wait/run. Backend smart_turn::ort_backend::SmartTurnOrt (feature smart-turn-ort): a stack of idle ONNX Runtime sessions (1 intra-op thread each); warm(n) at startup avoids the ~100 ms cold start, cold_starts() counts misses. Probabilities within 6e-8 of the reference (golden rounding).
  • Sensitivity (measured): the int8 model moves by up to 0.13 for +-1e-6 uniform noise on the features, and by up to 0.16 between onnxruntime 1.28.0 and 1.30.0 on identical features (e.g. tr_two_utterances/0/cut 0.936 vs 0.779; en_tr_mixed/0/cut 0.136 vs 0.177). Its uint8 activation quantization dominates the output: the same graph with every QuantizeLinear/DequantizeLinear removed (int8 weights dequantized to fp32, activations fp32) gives 0.932 vs 0.936 on the first case but 0.019 vs 0.940 on en_tr_mixed/0/complete and 0.133 vs 0.936 on tr_two_utterances/0/cut (onnxruntime on both), so the int8 file is effectively a different model from its float version. Pipecat's verdicts therefore depend on the installed onnxruntime/numpy; brisvox pins both sides: goldens are made with onnxruntime 1.28.0, the version ort 2.0.0-rc.13 bundles (make_smart_turn_goldens.py refuses other versions).
  • tract (0.23.8; loads the graph only with its declared value shapes ignored, symbolic batch s6): its declutter pass moves the int8 output far (0.914 -> 0.201 on the first case; default plan up to 0.83 off the reference, ~75 ms). Stepping the pass (Optimizer::stopping_at) shows why: an exact-in-math rewrite (GELU's x / sqrt(2) -> x * (1/sqrt(2)), a last-bit change) moves it 0.914 -> 0.940, and each of the 4 attention Softmax nodes on uint8 input becomes a u8 lookup table (lossy), moving it 0.940 -> 0.366 -> 0.264 -> 0.356 -> 0.201. Typed plan without declutter: all 12 verdicts equal to onnxruntime but up to 0.18 off (e.g. 0.661 vs 0.829, 0.313 vs 0.136; requantization rounding differs) and ~100 ms. On the QDQ-free fp32 copy tract matches onnxruntime on that copy on all 12 cases, and node-by-node on all 214 nodes (rel 1e-3), optimized or not. Not used.
  • Latency (AMD Ryzen 9 9950X, Windows 11, release, one thread, median of 15): features 1.0-1.2 ms (Python reference 2.1 ms), model 22.5-28 ms (Python + onnxruntime 1.28.0: 24.2 ms, the same runtime), 25-35 ms per analysis on the pool, pool wait ~10-20 us. bench/results/smart-turn.md. Tests: crates/brisvox-inference/tests/smart_turn.rs.

Platform dependence (measured 2026-10-15)

The same file, the same features (bit for bit) and the same ONNX Runtime version (1.28.0) give different probabilities per operating system and CPU instruction set. 12 golden cases, ort 2.0.0-rc.13 in the repository's tests, on one AMD Ryzen 9 9950X (AVX-512 with VNNI), difference to the goldens (which Python onnxruntime 1.28.0 reproduces to 1e-7 on both Windows and Linux on this CPU):

runtimecases exact (< 1e-6)offverdict flips
ort on Windows x86_6412none0
ort on Linux x86_64, AVX-512 + VNNI (WSL2, and the GitHub ubuntu-latest runner for the one case its log shows)10en_tr_mixed/0/complete 0.9333 for 0.9403 (0.007), tr_two_utterances/0/cut 0.8395 for 0.9357 (0.096)0
ort on Linux x86_64 restricted to AVX2, no VNNI (qemu-user TCG, CPU models Haswell and EPYC; emulated, not measured on silicon)04e-4 to 0.66, e.g. tr_two_utterances/0/cut 0.272 for 0.936, en_tr_mixed/0/cut 0.616 for 0.1362 of 12

Why: the model's uint8 activation quantization turns any last-bit float difference into a whole quantization step (above), and the int8 matrix kernels differ by instruction set (the u8s8 AVX2 kernel saturates its 16-bit intermediate sums, the VNNI one does not; ONNX Runtime documents this for quantized models) and the Linux ort binary's float kernels are not bit-equal to the Windows one's. Consequences:

  • Tests: features and everything that compares two paths on one machine stay bit-exact on every machine (the executor equals a direct call, thread counts and spinning change no bit, a bare ONNX Runtime session equals SmartTurnOrt). The comparison with the goldens runs only where their kernels run (brisvox_sim::platform::smart_turn_goldens_apply_here: Windows x86_64 with VNNI); elsewhere the tests print the deviation. A tolerance would have to be 0.7 to hold for the AVX2 row and would then protect nothing.
  • Product: a CPU without VNNI (AVX2-only server parts, such as AMD EPYC Zen 2/3 and older Intel Xeons) can decide turns differently from the reference on this model. Not verified on real hardware of that kind; measuring it on such hardware is an open item. Pipecat runs the same file through the same runtime and has the same property.

Pure-Rust backend (measured 2026-10-13): not adopted

Question: can a pure-Rust runtime reproduce the ORT backend within 1e-3 with identical verdicts, at least as fast? Answer: no, and the 1e-3 bar is below what different correct implementations of this graph agree on. ORT stays the backend (the earlier decision to use ORT stands).

The model is chaotic in the last bit, so there is no implementation-independent answer. scripts/models/smart_turn_runtime_variance.py (onnxruntime 1.28.0, the 12 goldens, base = ORT_ENABLE_ALL, 1 thread) compares execution plans of the SAME file. The ORT and numpy rows come from that script (needs onnxruntime 1.28.0; not re-run in the review-fix session, whose Python had no onnxruntime); the two tract rows were re-measured on 2026-10-13 by the Rust harness described under (a):

planmax abs difference to the goldenscases > 1e-3verdict flips
ORT_ENABLE_ALL, 4 intra-op threads0.00000/120
ORT_ENABLE_EXTENDED (QDQ fused to integer QLinear kernels)0.00000/120
ORT_ENABLE_BASIC / ORT_DISABLE_ALL (Q/DQ kept as float nodes)0.13078/120
ONNX reference evaluator (numpy, literal semantics; opset raised to 21)0.05938/120
tract 0.23.8, typed plan, no declutter (smart_turn_tract example)0.17699/120
tract 0.23.8, default into_optimized (declutter incl. the Softmax lookup-table fusion, below)0.833212/1210

The unfused ORT plan, numpy and tract all execute the graph exactly as the ONNX text says and still differ from the fused ORT plan, and from each other (tract typed vs ORT BASIC 0.136, vs numpy 0.21; measured in the tract investigation, re-run with BRISVOX_TRACT_JSON), by 0.06-0.18: every uint8 activation quantization step turns a last-bit float difference (summation order of a matmul, erf/exp polynomial, LayerNorm reduction) into a whole quantization step when a value sits on a rounding boundary, and 12 encoder layers amplify it (sensitivity measured earlier: +-1e-6 on the features moves the output by up to 0.13). Verdicts stayed equal in every plan except tract's default, which is a property of these 12 cases, not a guarantee. To reach 1e-3 a runtime would have to reproduce ORT's graph transformer decisions (which Q/DQ pairs become QLinear kernels) AND the MLAS float kernels bit for bit, for the CPU ISA in use (option (b) of the tract investigation); that is not a reasonable engineering target and would break on every ort upgrade (1.28.0 vs 1.30.0 already differ by up to 0.16).

Options examined

  • (a) tract: 0.23.8 is the newest tract-onnx/tract-core (https://crates.io/crates/tract-onnx, published 2026-09-21, checked 2026-10-13; the 0.21.18 and 0.22.4 maintenance releases are from 2026-09-07, nothing newer). Harness: crates/brisvox-inference/examples/smart_turn_tract.rs (cargo run --release -p brisvox-inference --features silero-tract --example smart_turn_tract [-- --json tract.json], needs the model in models/); it runs the 12 goldens through the crate's bit-exact feature extractor in two plans, typed (into_typed() only: no declutter, no optimizer) and optimized (into_optimized(), tract's default), and prints max |d| to the goldens, cases > 1e-3, verdict flips and median latency. BRISVOX_TRACT_JSON=tract.json makes the variance script add the tract-vs-ORT-BASIC and tract-vs-numpy rows. Measured numbers: the table above, the bisect and the latency below. smart_turn_tract -- --bisect runs tract's declutter Optimizer one patch at a time (Optimizer::declutter().stopping_at(n)) on the first golden case and prints every step that changes the operator mix or the probability (2026-10-13, 234 steps; case en_tr_mixed/0/complete: golden 0.9403, typed plan 0.8858, fully optimized 0.2492):

    stepprobabilityoperator change
    1-500.8858 (unchanged)constant folding, AddAxis/Cast clean-up
    510.9466 (+0.061)one Div becomes a Mul (GELU x / sqrt(2) -> x * (1/sqrt(2)))
    570.3747 (-0.572)DequantizeLinear -> Softmax -> QuantizeLinear becomes a LookupTable
    700.2798 (-0.095)the second attention Softmax, same fusion
    830.4803 (+0.201)the third
    960.2492 (-0.231)the fourth; nothing after step 96 changes the answer (234 = end)

    So the two suspects of the earlier notes are confirmed pass by pass: the GELU rewrite is a last-bit change that already moves the output by 0.06, and the four attention Softmax fusions move it by 0.57 / 0.09 / 0.20 / 0.23, i.e. they, not the GELU rewrite, produce the 0.83 error. Source reading (tract-core 0.23.8) explains the fusion and points at a probable tract bug: DequantizeLinearF32::declutter (src/ops/quant.rs) walks the nodes between a dequantize and a quantize and, when Op::quantize has no answer for them, evaluates the whole chain on the 256 possible inputs and stores the result as a LookupTable; it continues through a node when axes_mapping(..).is_element_wise_unary() is true. Softmax::axes_mapping (src/ops/nn/softmax/mod.rs) returns AxesMapping::natural, for which is_element_wise_unary() (src/axes/mapping.rs) is true, although Softmax normalizes across its axis and is not pointwise. The table is then softmax over the vector 0..=255, applied per element, which is not the quantized softmax. That is consistent with the step-57 jump, but it is a reading of the source, not a minimal repro: no upstream issue is filed and disabling just that rule was not tried (the optimizer does not expose single rules; Optimizer.passes could be filtered in a follow-up). It does not change the decision: with the whole declutter off (typed plan) tract is still up to 0.18 off the goldens (9/12 cases above 1e-3), so fixing this bug would not give a 1e-3 match, and the typed plan is slower than ORT. Latency (release, this machine, tract single-threaded, median of 12 cases, measured while other builds ran): typed 104 ms, optimized 72 ms (wrong answers), ORT 25-35 ms, so the "at least as fast" bar fails independently; why tract is 3-4x slower was not profiled.

  • (b) emulating ORT's QLinear kernels: infeasible, see above (needs the MLAS float kernels too).

  • (c) other Rust runtimes (checked 2026-10-13):

    • candle-onnx 0.11.0 (https://crates.io/crates/candle-onnx, published 2026-06-26): the op match in https://github.com/huggingface/candle/blob/main/candle-onnx/src/eval.rs (116658 bytes, read in full with curl and grepped) has no QuantizeLinear, DequantizeLinear or LayerNormalization arm, so it cannot run this file.
    • wonnx 0.5.1 (https://crates.io/crates/wonnx, published 2023-09-30): GPU-only (wgpu) and unmaintained since.
    • ort-tract 0.5.0+0.23 (https://crates.io/crates/ort-tract): the ort API on top of tract, so the same engine and the same result as (a).
    • burn-onnx 0.22.0 (https://crates.io/crates/burn-onnx, published 2026-10-06; burn-import is its deprecated predecessor): its op table (https://github.com/tracel-ai/burn-onnx/blob/main/SUPPORTED-ONNX-OPS.md) lists DequantizeLinear, QuantizeLinear, LayerNormalization, Gelu, Erf, MatMulInteger and QLinearMatMul as supported, so unlike candle it is not ruled out by op coverage. It was NOT run: it generates Rust from the ONNX file at build time and needs the burn crates (not added: no new dependencies in this task). It is another implementation of the float and rounding steps, so the variance argument above applies to it, but that is an inference, not a measurement.
  • The QDQ-free fp32 copy of the model stays excluded (measured earlier: up to 0.92 from int8).

What would change this: a Smart Turn release that is robust to float noise (e.g. a float variant that tract matches node by node, as it does the dequantized copy), or a tolerance defined by verdict agreement on a labelled set instead of probability equality. Either is a new decision, not a follow-up of this investigation.

ONNX Runtime from Rust (ort) — local inference (VAD, optional STT/TTS models)

  • Docs (fetched 2026-10-08):
    • crates.io API: https://crates.io/api/v1/crates/ort
    • ort guide: https://ort.pyke.io/ (setup/linking, setup/cargo-features, setup/platforms, misc/version-mapping, misc/prebuilt-binaries)
    • Prebuilt target table (authoritative for download-binaries, tag v2.0.0-rc.13): https://raw.githubusercontent.com/pykeio/ort/v2.0.0-rc.13/ort-sys/build/download/dist.tsv
    • Release notes: https://github.com/pykeio/ort/releases (v2.0.0-rc.13, 28 Jul)
    • docs.rs: https://docs.rs/ort/2.0.0-rc.13/ort/ , .../ort/session/struct.Session.html , .../ort/session/builder/struct.SessionBuilder.html , .../ort/session/struct.IoBinding.html , .../ort/value/index.html , .../ort/value/type.TensorRef.html , .../ort/fn.api.html
    • IoBinding guide: https://ort.pyke.io/perf/io-binding
    • Repo: https://github.com/pykeio/ort
    • ORT release (upstream): https://github.com/microsoft/onnxruntime/releases/tag/v1.30.0 (exists; "ONNX Runtime v1.30.0", released 10 Sep per the page)
    • Official ORT threading: https://onnxruntime.ai/docs/performance/tune-performance/threading.html
    • Official ORT FAQ: https://raw.githubusercontent.com/microsoft/onnxruntime/main/docs/FAQ.md
    • Official ORT threading notes: https://raw.githubusercontent.com/microsoft/onnxruntime/main/docs/NotesOnThreading.md
  • 404 or no usable content: ort.pyke.io/perf/threading, ort.pyke.io/fundamentals/session, ort.pyke.io/setup/linking (no version text), ort.pyke.io/setup/platforms (no data), raw GitHub docs/setup/platforms.mdx and docs/perf/threading.mdx (404), raw/blob ort-sys/build.rs (404), docs.rs .../value/struct.TensorRef.html (use type.TensorRef.html). Unverified items are marked below.
  • WebFetch returns model-summarised text, not verbatim bytes. "Quoted" below means paraphrased by the fetch tool; re-check the raw page before quoting in code comments.

Versions, license, MSRV

  • crates.io: NO stable 2.x release is published. Latest is 2.0.0-rc.13 (created 2026-07-28), not yanked. Previous: rc.12 (2026-03-05), rc.11 (2026-01-07). Source: crates.io API above.
  • Pin exactly: ort = "=2.0.0-rc.13" (form shown on ort.pyke.io). Pre-release means API may still change; pin and record any bump in the design notes.
  • License: MIT OR Apache-2.0 (crates.io; repo has LICENSE-MIT and LICENSE-APACHE). Compatible with the project rule.
  • MSRV: 1.88 (crates.io rust_version of rc.13). Toolchain must be >= 1.88.

ORT version (research gap)

  • Documented target of ort 2.0.x: ONNX Runtime 1.28.
    • crates.io description of ort-sys/ort (rc.13): "Unsafe Rust bindings for ONNX Runtime 1.28" / "A safe Rust wrapper for ONNX Runtime 1.28" (docs.rs ort, ort-sys).
    • ort.pyke.io/misc/version-mapping: table row "v2.0.0+ | v1.28.0"; sentence "i.e. 2.0 ships with ONNX Runtime 1.28, but 2.1 may ship with 1.29."
  • CONFIRMED for the rc.13 tag: its dist.tsv (URL above) lists only rows with ORT version 1.28.0. Release notes say rc.13 "skips ahead 4 ONNX Runtime versions to v1.28" (https://github.com/pykeio/ort/releases). So rc.13 downloads 1.28.0 prebuilts.
  • Conflicting signal: the GitHub README badge reads "onnxruntime-v1.30.0", and the main branch dist.tsv lists 1.30.0 rows. Both are AHEAD of the rc.13 tag; do not use them for rc.13.
  • Runtime print check: NOT EXECUTED (no cargo available to the author). Close with a minimal crate that prints the runtime version via the API (confirm names on docs.rs) and inspects target/*/build/ort-sys-*/output.
  • Opset 16 for Silero: UNVERIFIED. The silero-vad README gives only onnxruntime>=1.16.1 as a pip extra, and no opset number. Load the actual model with the ORT the build reports and record the result here.
  • Decision rule: treat the loaded runtime version as authoritative; design for 1.28.

Cargo features (ort.pyke.io/setup/cargo-features)

  • Default: std, ndarray, tracing, download-binaries, copy-dylibs, tls-native.
  • Not default: load-dynamic, alternative-backend, pkg-config, lax-feature-matching, half, num-complex, preload-dylibs, tls-rustls, tls-native-vendored, and one feature per execution provider (e.g. cuda; full list not read).
  • download-binaries: fetches prebuilt ORT from pyke's CDN over HTTPS; requires a TLS feature.
  • copy-dylibs: copies dylibs next to the target so the loader finds them (relevant for CUDA-style EPs; on Windows only for binary crates unless Developer Mode).
  • load-dynamic: no link-time dependency; the library is loaded at runtime. Call ort::init_from(path) or set ORT_DYLIB_PATH BEFORE any other ort use. Relative ORT_DYLIB_PATH is resolved relative to the executable (examples/tests build to different dirs).
  • Static/compile-time link: ORT_LIB_PATH (dir with .a/.lib), ORT_LIB_PROFILE; ORT_PREFER_DYNAMIC_LINK=1 for dynamic link. Static is preferred if both exist.
  • rpath note from docs: Linux needs -Clink-args=-Wl,-rpath,$ORIGIN for dynamic link.

Prebuilt binaries per target (download-binaries)

Source: dist.tsv at tag v2.0.0-rc.13 (URL above). All rows are ORT 1.28.0:

  • x86_64-unknown-linux-gnu: feature sets none, webgpu, nvrtx, cuda13,tensorrt,nvrtx
  • aarch64-unknown-linux-gnu: none only
  • x86_64-pc-windows-msvc: directml, webgpu, nvrtx,directml, cuda13,tensorrt,nvrtx,directml (no none row)
  • aarch64-pc-windows-msvc: directml only
  • No *-musl rows (CONFIRMED absent).

Constraints from https://ort.pyke.io/misc/prebuilt-binaries (fetched 2026-10-08):

  • "All x86-64 binaries are compiled with a baseline requirement of x86-64-v3" (Haswell/AVX2-class CPU or later).
  • "Linux binaries are compiled with Clang, not GCC, and depend on libc++." Container images must provide libc++ or ship it.
  • "All Windows builds come with the DirectML EP enabled." CPU-only Windows selection: UNVERIFIED.
  • Release notes rc.13: CUDA 13 only (CUDA 12 dropped). rc.11 dropped Intel macOS.

Status for Brisvox (Linux first): Linux glibc x86_64 and aarch64 have a CPU (none) row, so download-binaries is a candidate; actual download, link, and libc++ presence in the target image: UNVERIFIED until a CI build runs. Windows rows are DirectML-only. musl has no rows: use load-dynamic with a musl-built libonnxruntime.so or a source build (neither verified).

Session API (docs.rs 2.0.0-rc.13)

#![allow(unused)]
fn main() {
use ort::session::{builder::GraphOptimizationLevel, Session};

let mut model = Session::builder()?
    .with_optimization_level(GraphOptimizationLevel::Level3)?
    .with_intra_threads(4)?
    .commit_from_file("yolov8m.onnx")?;
}

(example from ort.pyke.io home page)

Signatures (docs.rs SessionBuilder):

  • pub fn commit_from_file<P>(&mut self, model_path: P) -> Result<Session> where P: AsRef<Path> (requires std)
  • pub fn commit_from_memory(&mut self, model_bytes: &[u8]) -> Result<Session> (not on wasm)
  • pub fn with_intra_threads(self, num_threads: usize) -> BuilderResult
  • pub fn with_inter_threads(self, num_threads: usize) -> BuilderResult
  • pub fn with_parallel_execution(self, parallel_execution: bool) -> BuilderResult
  • pub fn with_memory_pattern(self, enable: bool) -> BuilderResult
  • pub fn with_allocator(self, info: MemoryInfo<'_>) -> BuilderResult
  • pub fn with_config_entry(self, key: impl AsRef<str>, value: impl AsRef<str>) -> BuilderResult
  • pub fn with_independent_thread_pool(self) -> BuilderResult
  • BuilderResult is a type alias (expansion not read; the ? usage shows it is fallible).

Running (docs.rs Session):

  • pub fn run<'s, 'i, 'v: 'i, const N: usize>(&'s mut self, input_values: impl Into<SessionInputs<'i, 'v, N>>) -> Result<SessionOutputs<'s>>
  • Inputs are built with ort::inputs!["name" => tensor]; run is &mut self (reason not stated).
  • Outputs: let predictions = outputs["output0"].try_extract_array::<f32>()?; (ort.pyke.io home page).
  • run_async exists (std feature) and requires with_intra_threads > 1 per the docs; not needed for a dedicated CPU pool.

Tensors (docs.rs value module, type.TensorRef.html):

  • Tensor::from_array(([1usize, 2, 3], vec![...].into_boxed_slice()))? — shape + boxed slice (owned data).
  • Tensor::from_array(ndarray::Array4::<f32>::zeros((1, 16, 16, 3)))? — requires ndarray feature (default on).
  • TensorRef<'v, T> = ValueRef<'v, TensorValueType<T>>; TensorRefMut analogous.

Zero-copy input (CONFIRMED signature, copy behaviour UNVERIFIED)

  • pub fn from_array_view(input: impl TensorArrayData<T> + 'a) -> Result<TensorRef<'a, T>> in impl<'a, T: PrimitiveTensorElementType + Debug> TensorRef<'a, T> (https://docs.rs/ort/2.0.0-rc.13/ort/value/type.TensorRef.html). It is the only constructor listed there.
  • Doc text: "Construct a tensor from borrowed data." Accepted with ndarray: &CowArray, &Array, ArcArray, ArrayView; also (shape, data) with data &[T], Arc<[T]> or Arc<Box<[T]>>.
  • Ndarray inputs must be standard layout; otherwise an error. Lifetime 'a ties the tensor to the borrowed buffer.
  • NOT stated: whether it copies internally. Zero-copy is UNVERIFIED until the allocation test below passes.

Threading and concurrency (researched 2026-10-08)

Confirmed by fetched pages (quoted by fetch tool):

  • Default intra_op_num_threads = 0/unset: "INTRA Threads Total = Number of physical CPU Cores"; main thread on core 1 not affinitized. Source: https://onnxruntime.ai/docs/performance/tune-performance/threading.html
  • Default inter_op_num_threads = one per physical core; the inter-op pool is only created in parallel execution mode. Default execution mode is sequential. Same URL.
  • "By default, session.run() uses all the computer's cores." Source: https://raw.githubusercontent.com/microsoft/onnxruntime/main/docs/FAQ.md
  • "If built with OpenMP, set the environment variable OMP_NUM_THREADS to 1. If not built with OpenMP, set the session options intra_op_num_threads to 1." Same FAQ.
  • OpenMP is a build-time choice (--use_openmp); inter-op always uses the ORT thread pool. Source: https://raw.githubusercontent.com/microsoft/onnxruntime/main/docs/NotesOnThreading.md
  • docs.rs with_intra_threads: "If ONNX Runtime was built with OpenMP (as is the case with Microsoft's prebuilt binaries), this will have no effect"; use OMP_NUM_THREADS. Source: https://docs.rs/ort/2.0.0-rc.13/ort/session/builder/struct.SessionBuilder.html
  • docs.rs with_parallel_execution: "By default, this is disabled." with_independent_thread_pool: opt-in instead of "defaulting to the Environment's global thread pool if one was defined." Same URL.

NOT confirmed:

  • Whether pyke CDN binaries are OpenMP builds. The ort doc comment names only Microsoft's prebuilt binaries. If OpenMP, with_intra_threads is ignored and OMP_NUM_THREADS decides. UNVERIFIED by docs; MEASURED on the binary brisvox uses (Windows, 2026-10-08 and 2026-10-12): Smart Turn gets faster with 2 and 4 intra-op threads (~23 / 12.5 / 8 ms), so with_intra_threads takes effect there (docs/LATENCY.md; ignored test more_threads_cut_the_latency_of_warm_and_cold_sessions). Re-run that test on the Linux target image.
  • Whether ort overrides ORT default thread counts (ort.pyke.io/perf/threading 404). UNVERIFIED.
  • Concurrent run on one Session from several threads: not stated on any fetched official page; run* takes &mut self. A third-party package claims concurrent calls are permitted and a forum report shows a crash under an ACL EP when tensors were shared. Treat as UNSUPPORTED: one Session per worker thread, or Mutex<Session>.
  • Latency and thread count of a 1-thread session on the target container: NOT BENCHMARKED (no shell or target container for the author). The implementer must measure.

Settings for a 1-thread-per-session CPU pool (design choice from the FAQ, not a doc-stated recommendation for this setup):

  • OMP_NUM_THREADS=1 in the process environment (OpenMP builds) AND .with_intra_threads(1)? (non-OpenMP builds). Both are harmless otherwise.
  • .with_inter_threads(1)? and .with_parallel_execution(false)? (sequential is the default).
  • .with_independent_thread_pool()? only if sessions must not share the Environment global pool; confirm validity before relying on it.
  • Verify at runtime: count threads spawned by a session (Linux /proc/self/task, or Windows thread count) after commit_from_file.
  • Inference runs on the dedicated brisvox-inference pool, not tokio workers (engineering invariant, see CONTRIBUTING.md).

Per-call allocation and IoBinding (docs.rs IoBinding + Session; https://ort.pyke.io/perf/io-binding)

Confirmed signatures (docs.rs 2.0.0-rc.13, non-wasm):

  • Session::create_binding(&self) -> Result<IoBinding> — "Creates a new [IoBinding] for this session."
  • Session::run_binding<'b, 's: 'b>(&'s mut self, binding: &'b IoBinding) -> Result<SessionOutputs<'b>> (CONFIRMED)
  • Session::run_binding_with_options<'r, 'b, 's: 'b>(&'s mut self, binding: &'b IoBinding, run_options: &'r RunOptions<NoSelectedOutputs>) -> Result<SessionOutputs<'b>>
  • IoBinding::bind_input<T: ValueTypeMarker + ?Sized, S: Into<String>>(&mut self, name: S, ort_value: &Value<T>) -> Result<()> — "The data is only copied upon invocation of this function."
  • IoBinding::bind_output<T, S: Into<String>>(&mut self, name: S, ort_value: Value<T>) -> Result<()> — later runs "will reuse the same tensor to store the output instead of creating a new one each time."
  • IoBinding::bind_output_to_device<S: Into<String>>(&mut self, name: S, mem_info: &MemoryInfo<'_>) -> Result<()> — for unknown output shapes; prefer bind_output when the shape is known.
  • Guide: bindings apply to individual IoBinding instances, so create one per session worker, not per call. The guide's example uses Tensor::<f32>::new(&Allocator::default(), ...), which is NOT the rc.13 Tensor::from_array API; the rc.13 equivalent is UNVERIFIED.

Still UNVERIFIED: whether from_array_view copies; whether ORT allocates internally per run (arena/memory pattern; effects of with_memory_pattern / with_allocator not documented on fetched pages); whether a warm run_binding performs zero heap allocations (only the docs' "reuse" intent is confirmed).

Partial result (review fixes, 2026-10-08): SileroVad::infer now lends its input and state to session.run as TensorRef::from_array_view (no brisvox-side copies) and writes the new state into a reused buffer. The counting-allocator test a_call_makes_no_heap_copies_of_its_own (crates/brisvox-inference/tests/silero.rs) measures 1320 bytes of Rust heap per call over 200 warm runs; that is the ort wrapper's own bookkeeping, and the test fails above one input copy (2304 bytes). Items (a) and (b) of the comparison below, and the run_binding item (c), are still NOT measured.

Required measurement (the comparison below is NOT DONE): counting global allocator test (same pattern as the chaos tests). Warm 10 runs, then count allocations over ~1000 runs with Silero-sized input (512 f32 samples plus state tensors) for (a) Tensor::from_array per call, (b) from_array_view per call, (c) create_binding once + bind_input/bind_output + run_binding. Invariant passes only if (c) is 0 per run, or a documented constant from ORT internals is recorded here.

Alternatives (brief)

  • tract (Sonos): tract-onnx 0.23.8 (2026-09-21), MIT OR Apache-2.0, rust_version 1.91 (crates.io + sonos/tract README). ONNX load supported (no save); no operator coverage list and no benchmarks in the README.
    • Silero VAD v5 on tract: NOT VERIFIED. All found Rust Silero wrappers (silero, silero-vad-rs, silero-vad-rust) use ort. Must be tested by loading the actual model with tract.
  • candle: candle-core 0.11.0 (2026-06-26), candle-onnx 0.11.0 (2026-09-16), MIT OR Apache-2.0 (crates.io). Op coverage and speed: UNVERIFIED. Not evaluated further.
  • Silero VAD license: the silero-vad repo shows conflicting signals: text "Published under permissive license (MIT)" but a badge reads "License: CC BY-NC 4.0" (github.com/snakers4/silero-vad, fetched 2026-10-08). UNVERIFIED until the primary LICENSE file is read.

Open items (UNVERIFIED, to resolve by build test)

  1. Runtime version print: rc.13 = ORT 1.28.0 per the tag's dist.tsv; the runtime print is NOT executed.
  2. Silero opset 16 load test against the bundled runtime (not yet run).
  3. Actual CI download/link per target, CPU-only Windows selection, libc++ in target images; no musl rows.
  4. Whether pyke CDN binaries are OpenMP builds; ort default thread counts. Official ORT defaults are confirmed (see Threading section).
  5. Measured 1-thread session latency and thread count on the target container: NOT DONE.
  6. Concurrent run on one session: treat as unsupported (one session per thread).
  7. Per-run allocations: whether from_array_view copies, and whether warm run_binding is allocation-free (counting-allocator test above).
  8. Silero VAD v5 op support in tract.

rubato 5.0.1 — audio resampling crate (not a network provider)

  • Fetched 2026-10-08 from:
    • https://docs.rs/rubato/5.0.1/rubato/ (+ struct/trait/enum pages: Async, Fft, Resampler, Indexing, FixedSync, FixedAsync, SincInterpolationParameters, SincInterpolationType, WindowFunction, Sample)
    • https://docs.rs/rubato/5.0.1/src/rubato/synchro.rs.html and .../asynchro.rs.html (source)
    • https://docs.rs/crate/rubato/5.0.1 (deps, MSRV)
    • https://raw.githubusercontent.com/HEnquist/rubato/master/README.md (README + changelog), https://github.com/HEnquist/rubato
    • https://raw.githubusercontent.com/HEnquist/rubato/master/examples/process_raw.rs
    • https://docs.rs/audioadapter/5.0.0/audioadapter/
    • https://docs.rs/audioadapter-buffers/5.1.0/audioadapter_buffers/ (+ direct, number_to_float, InterleavedNumbers)
  • Versions: rubato 5.0.1 (released 2026-10-01 per docs.rs); audioadapter 5.0.0 (2026-10-07 per docs.rs); audioadapter-buffers ^5.1 required (5.1.0 page exists; newest patch UNVERIFIED).
  • License: MIT OR Apache-2.0 (rubato, audioadapter, audioadapter-buffers). MSRV: rustc 1.87.
  • Dependencies (rubato 5.0.1): audioadapter ^5.0, audioadapter-buffers ^5.1, num-integer, num-traits, visibility, windowfunctions ^0.1.1; optional: realfft ^3.5.0 + num-complex (feature fft_resampler, default on), log ^0.4.18 (feature log, off by default; keep off in real time, it allocates).
  • no_std: rubato docs do not mention it (UNVERIFIED, assume std). audioadapter core traits and audioadapter-buffers work without std (audioadapter-buffers: alloc feature for owned buffers).
  • SIMD: Async sinc has AVX/SSE3 (x86_64) and Neon (aarch64) with runtime detection and scalar fallback; Fft gets SIMD through RustFFT. No feature flag needed (none listed).

1. Resampler types in 5.x

  • Fft<T> (synchronous, fixed ratio from two integer rates, needs fft_resampler). Exact rational ratio via gcd of the rates. Best fit for fixed-ratio voice.
  • Async<T> (asynchronous: new_sinc = high quality/CPU heavy, new_poly = fast/lower quality). Ratio is f64 and adjustable at runtime (Adjustable); built for clock drift. Output frames per call may vary.
  • Slip<T>: only for nearly equal rates (insert/drop frames with crossfade); NOT for 48k->16k.
  • Old 3.x names (SincFixedIn, FftFixedIn, ...) are gone; replaced by Async/Fft + FixedAsync/FixedSync (4.0.0 changelog and current docs).

Constructors (exact, verbatim from docs.rs):

#![allow(unused)]
fn main() {
// Fft<T: Sample>
pub fn new(sample_rate_input: usize, sample_rate_output: usize, chunk_size: usize,
           nbr_channels: usize, fixed: FixedSync) -> Result<Self, ResamplerConstructionError>
pub fn new_custom(sample_rate_input: usize, sample_rate_output: usize, chunk_size: usize,
           sub_chunks: usize, nbr_channels: usize, window: WindowFunction,
           fixed: FixedSync) -> Result<Self, ResamplerConstructionError>
// Async<T: Sample>
pub fn new_sinc(resample_ratio: f64, max_resample_ratio_relative: f64,
           parameters: &SincInterpolationParameters, chunk_size: usize,
           nbr_channels: usize, fixed: FixedAsync) -> Result<Self, ResamplerConstructionError>
pub fn new_poly(resample_ratio: f64, max_resample_ratio_relative: f64,
           interpolation_type: PolynomialDegree, chunk_size: usize,
           nbr_channels: usize, fixed: FixedAsync) -> Result<Self, ResamplerConstructionError>
}
  • FixedSync { Input, Output, Both }; FixedAsync { Input, Output }. chunk_size is "size of input data in frames" for Async (docs), "desired chunk size in frames" for Fft. PolynomialDegree: Cubic, Quadratic, Linear.
  • Fft::new uses BlackmanHarris2 window and targets sub-chunks of ~256 frames (sub_chunks = (chunk_size/256).max(1) in source), "which keeps the resampler delay low". Fft also has fft_size_in(), fft_size_out(), cutoff().
  • Fft sizing (from source): gcd = gcd(in, out); fft_size_out = fft_chunks*out/gcd, fft_size_in = fft_chunks*in/gcd; invalid chunk_size is NOT an error, it is rounded UP to a multiple of the minimum block. So the actual per-call size may differ from the requested chunk_size: always read input_frames_next() after construction; do not assume it equals 160/320/480. (Exact rounding for 20 ms frames UNVERIFIED; test in the resampler tests.)
  • Async errors: chunk_size == 0 -> InvalidChunkSize; ratio must be finite and > 0; max_resample_ratio_relative finite and >= 1.0 (use 1.0 for fixed ratio).
  • Recommendation for Brisvox (fixed ratios, mono, 20 ms): Fft with FixedSync::Input (or Both; which one gives exact 20 ms in/out is UNVERIFIED), one instance per session per direction. Rates 8k/16k/24k/44.1k/48k are all integer rationals, so Fft gives exact ratios (e.g. 44100->16000: gcd 100, block 441 in / 160 out; 882-sample 20 ms frame = 2 blocks). Use Async::new_sinc only if the Fft delay or quality turns out worse in the resampler benchmark.

2. Process API (5.x)

#![allow(unused)]
fn main() {
// trait Resampler<T>
fn process_into_buffer(&mut self, buffer_in: &dyn Adapter<T>, buffer_out: &mut dyn AdapterMut<T>,
                       indexing: Option<&Indexing>) -> ResampleResult<(usize, usize)>; // (frames_read, frames_written)
fn input_frames_next(&self) -> usize;   // exact frames needed by the next call
fn input_frames_max(&self) -> usize;
fn output_frames_next(&self) -> usize;  // frames the next call produces
fn output_frames_max(&self) -> usize;   // size output buffers with this
fn output_delay(&self) -> usize;        // in OUTPUT frames
fn nbr_channels(&self) -> usize;  fn resample_ratio(&self) -> f64;
fn reset(&mut self);                    // clears state, restores initial conditions
fn as_adjustable(&mut self) -> Option<&mut dyn Adjustable<T>>;  // Some for Async only
fn as_resizable(&mut self) -> Option<&mut dyn Resizable<T>>;
// provided: process (ALLOCATES, returns InterleavedOwned<T>), process_all, process_all_into_buffer, process_all_needed_output_len, is_adjustable, is_resizable
}
  • Buffers are audioadapter::{Adapter, AdapterMut} trait objects (not Vec<Vec> as in 3.x). Wrappers come from audioadapter-buffers.
  • Indexing { input_offset: usize, output_offset: usize, partial_len: Option<usize>, active_channels_mask: Option<Vec<bool>> }, Indexing::new() = zero offsets, None, all channels; Default implemented; fluent setters exist (4.0.0 changelog; exact names UNVERIFIED). Passing None instead of Some(&indexing) is allowed by the signature.
  • Fixed input chunk: feed exactly input_frames_next() frames per call. Partial last chunk: set indexing.partial_len = Some(n); the rest is treated as silence and the output buffer must still hold a full output chunk. Some(0) pushes pure silence (flush the delay). Default None = read the full required count.
  • Zero-alloc steady state: process_into_buffer does not allocate (Async: preallocated buffers, copy_within; Fft: scratch buffers preallocated in the constructor; README states real-time safe). Adapter wrappers are thin views (no heap). Indexing::new() does not allocate unless active_channels_mask is set. Never use process/process_all on the hot path. Construction allocates (sinc tables, FFT plans): construct at session setup, off the tokio workers (CONTRIBUTING.md: >50us goes to the inference pool).
  • CPU: processing is synchronous and CPU-bound; measure whether a 20 ms frame stays under the 50 us budget, else run it on the inference pool.

3. Delay and parameters

  • output_delay() returns delay in output frames. Fft: fft_size_out / 2 (source). Async: (nbr_points * resample_ratio / 2.0) as usize (source; nbr_points presumably = sinc_len, UNVERIFIED). Convert: delay_ms = output_delay() * 1000 / out_rate. Example from the formula only (not measured): Async sinc_len 128, 48k->16k (ratio 1/3) ~21 output frames ~1.3 ms; Fft delay depends on the block size, measure in the resampler benchmark.
  • SincInterpolationParameters fields: sinc_len ("256 is a good starting point"), f_cutoff: Option<f32> (None = auto via calculate_cutoff, preferred), oversampling_factor ("Start at 128"; lower keeps tables in CPU cache), interpolation: SincInterpolationType, window: WindowFunction. Defaults: 256 / None / 128 / Cubic / BlackmanHarris2.
  • 5.x builder: SincInterpolationParameters::new(sinc_len: usize, window: WindowFunction) then .sinc_len() .window() .f_cutoff(f32) .oversampling_factor(usize) .interpolation(SincInterpolationType).
  • SincInterpolationType: Nearest (only for ratios with small-denominator fractions), Linear (fastest for 1-2 ch, needs larger oversampling), Quadratic, Cubic (best quality per oversampling).
  • WindowFunction: Blackman, Blackman2, BlackmanHarris, BlackmanHarris2 (slower rolloff, better attenuation), Hann, Hann2. Repo example uses new(128, Blackman2).oversampling_factor(256).interpolation(Quadratic).
  • Voice starting point (UNVERIFIED quality, tune with the SNR sweep): sinc_len 64-128, BlackmanHarris2 or Blackman2, oversampling 128, Cubic; shorter sinc_len lowers delay but lowers cutoff/roll-off near Nyquist.

4. Sample types and i16

  • Sample is implemented only for f32 and f64. No direct i16 processing.
  • Convert via audioadapter-buffers number_to_float::InterleavedNumbers (also SequentialNumbers): wraps &[i16] / &mut [i16] and exposes Adapter<f32> / AdapterMut<f32> with on-the-fly conversion: pub fn new(buf: &'a [U], channels: usize, frames: usize) -> Result<Self, SizeError>; new_mut(buf: &'a mut [U], ...); new_from_bytes(_mut) for raw byte buffers. Docs example (data: Vec<i16>): let buffer = InterleavedNumbers::<_, f32>::new(&data, 2, 3).unwrap(); Writing via AdapterMut uses write_sample_unchecked() returning a bool (clipping flag). Exact i16<->f32 scaling (expected 32768) UNVERIFIED; verify with a unit test (full-scale sine round trip).
  • Alternative: convert i16->f32 by hand into a preallocated scratch and use direct::InterleavedSlice<f32> (generic over T: Clone; new(&[T], channels, frames), new_mut(&mut [T], channels, frames)). Choose by benchmark.
  • Mono: channels = 1; interleaved == sequential.

5. Examples

Quoted from the docs.rs / README (Async poly, f64, stereo):

#![allow(unused)]
fn main() {
use rubato::{Resampler, Async, FixedAsync, PolynomialDegree, Indexing};
use audioadapter_buffers::direct::InterleavedSlice;
let channels = 2; let chunk_size = 1024;
let mut resampler = Async::<f64>::new_poly(48000.0 / 44100.0, 1.1, PolynomialDegree::Cubic,
    chunk_size, channels, FixedAsync::Input).unwrap();
let mut indata = vec![0.0; channels * chunk_size];
let mut outdata = vec![0.0; channels * resampler.output_frames_max()];
let indexing = Indexing::new();
let input_adapter = InterleavedSlice::new(&indata, channels, chunk_size).unwrap();
let mut output_adapter = InterleavedSlice::new_mut(&mut outdata, channels, outdata.len() / channels).unwrap();
let (_frames_read, frames_written) = resampler
    .process_into_buffer(&input_adapter, &mut output_adapter, Some(&indexing)).unwrap();
}

From examples/process_raw.rs (loop and last partial chunk):

#![allow(unused)]
fn main() {
while input_frames_left >= input_frames_next {
    let (nbr_in, nbr_out) = resampler.process_into_buffer(&input_adapter, &mut output_adapter, Some(&indexing)).unwrap();
    indexing.input_offset += nbr_in; indexing.output_offset += nbr_out;
    input_frames_left -= nbr_in; input_frames_next = resampler.input_frames_next();
}
indexing.partial_len = Some(input_frames_left);   // last partial chunk
}

The example trims resampler.output_delay() frames from the start of the (offline) output.

COMPOSED by me from the verified signatures, NOT compiled or run (verify in the resampler tests), 48k -> 16k mono, 20 ms (960 -> 320 expected, UNVERIFIED rounding):

#![allow(unused)]
fn main() {
let mut rs = Fft::<f32>::new(48000, 16000, 960, 1, FixedSync::Input)?; // 960 = 20 ms @48k
let need = rs.input_frames_next();                 // read it; may differ from 960
let mut out = vec![0f32; rs.output_frames_max()];
// per frame (inp: &[f32] of len `need`):
let ia = InterleavedSlice::new(inp, 1, need)?;
let mut oa = InterleavedSlice::new_mut(&mut out, 1, rs.output_frames_max())?;
let (_read, written) = rs.process_into_buffer(&ia, &mut oa, None)?;
}

Gotchas / open items for the resampler

  • Fft rounds chunk_size up; 20 ms frames may not map 1:1 to resampler chunks. Keep a per-session accumulator if input_frames_next() != frame size.
  • Resampler adds output_delay() frames of delay; the first output contains the startup transient/silence. Do not trim in streaming; account for it in the latency budget (docs/LATENCY.md).
  • reset() if state continuity is not wanted (e.g. new stream).
  • Async output frame count varies per call for non-integer ratios; size buffers with output_frames_max().
  • Send/Sync: Fft lists Send + Sync auto traits (docs); Async UNVERIFIED. Sample requires Send.
  • Not fetched: audioadapter 5.0.0 trait method signatures in detail, SizeError / ResamplerConstructionError variants, benchmark numbers (none in fetched pages).

Risk register (each risk → mitigation → proving test)

#RiskMitigationProof
R1Cross-session state mix-up in inferenceper-session state, no batchinginference pool and VAD tests
R2CPU work stalls tokio → latency for alldedicated pool; performance reviewinference pool tests, in-process load simulation
R3Provider concurrency limits cap capacity before our codeper-provider semaphores, admissionadmission and budget tests, capacity benchmark
R4One crash drops all callsper-session supervision, panic isolation, drainsession manager isolation and drain tests
R5False barge-in from echo/backchannelsecho guard, min words, backchannel listsinterruption policy tests
R6Speculative run triggers side effectshold SideEffect until commitspeculation tests
R7Context contains unplayed text after interruptword-timestamp truncationplayed-text truncation tests
R8Stale audio after interrupt (transport buffers)epochs + transport clear/markturn epoch tests, telephony mark tests
R9Context growth in long callswindowing/summarizationlong-call context tests
R10Turkish quality (turn model, STT/TTS, normalization)verify support, Turkish fixturesSmart Turn tests, Turkish quality pass
R11API facts from memory are wrong/outdatedfacts fetched from current provider documentation and cited in docs/providers/all provider adapters
R12Benchmark unfairness vs Pipecatidentical providers/settings, published scriptsPipecat baseline bot
R13Privacy/compliance of recordingsopt-in recording, redaction, retentionrecording and privacy tests
R14SDK hop adds latencyout-of-process but off audio path; measuredagent protocol and SDK benchmarks
R15Twilio client lead replaces lead_margin (reasoned, not measured): a small reported lead can keep one word the caller did not hear; a genuine end-mark echo racing a barge-in is read as a cut mark and can drop up to one lead of heard textmax(lead, lead_margin) is the fallback rule; stale Mark after Clear to be stopped in the transport stagelive Twilio measurement (needs keys)
R16One shared HTTP client per origin multiplexes hundreds of calls onto few HTTP/2 connections; the server's MAX_CONCURRENT_STREAMS (often 100) queues call N+1 on the LLM critical pathmeasure with N concurrent calls against each provider; shard the pool key (net::http Key) by a connection index if it limits; the stream-multiplexing mechanics are tested (tests/gemini/review.rs)pre-warming follow-up (capacity/live)
R17A provider closes an idle TTS socket (Cartesia: 5 min) and the next turn pays a connectreconnect at LlmResponseStarted overlaps the LLM's first token (tested); every call's TTS is a ResilientTts that replaces a session idle for 80 % of idle_limit() (Cartesia: 4 of 5 minutes) in the background, swapped in only while idle; the server wraps every call's providers (ResilientProviders)hybrid_prewarm.rs, resilience.rs, engine_failover.rs, brisvox-server/tests/resilience.rs (a hosted call refreshes its idle session)
R18A provider outage or a flaky provider fails every call, and retries multiply the load on itper-provider circuit breakers shared by all calls, retry budgets per call and per process, mid-call recovery with replay, wired into the server for every call; a secondary per stage is opt-in with `[sttllm
R19The int8 Smart Turn model answers differently per OS and CPU instruction set (up to 0.66 off with flipped verdicts on an AVX2-only CPU without VNNI, emulated; 0.007 and 0.096 on 2 of 12 cases on Linux with VNNI), so end-of-turn decisions on hosts that differ from the reference machine may differmeasured and documented (docs/providers/smart-turn.md "Platform dependence"); tests pin only what is machine-independent (features bit-exact, code paths equal on one machine) and compare with the reference only on its kernels; on real VNNI-less hardware not yet measuredbrisvox-inference::smart_turn, brisvox-engine::smart_turn_executor

Add rows as risks are discovered; link the test that proves the mitigation.

Inference capacity (pool measured 2026-10-08; model cost assumed)

Per-window model VAD at ~1 ms per 32 ms window (assumed; measured below) costs ~3% of a core per session: a 4-thread pool at a safe 70% load carries ~90 sessions (the pool's queueing cliff is sharp just above 100%, measured). "Hundreds of calls per container" needs cross-session batching, an energy pre-gate (run the model only near speech), or more cores. Mitigation decided with the VAD and Smart Turn work.

Update (measured): Silero VAD v6.2.3 costs ~76 µs per 32 ms window with onnxruntime on one thread (Python reference, this desktop), not the assumed ~1 ms: ~0.24% of a core per session, so per-window VAD is not the capacity limit (a 4-thread pool at 70% would carry ~1000 sessions). Re-measure with the Rust runtime and on the target container.

Update (measured): Smart Turn costs ~30 ms of one core per candidate end of turn (not per window). At one candidate every ~5 s of user speech, that is ~0.6% of a core per talking session: 300 sessions need ~2 cores of pool capacity at worst-case alignment. Memory: 512 KB per session (ring + snapshot) plus ~3.4 MB per pool thread and one ONNX Runtime session per concurrent analysis (300 sessions: ~150 MB + threads x ~3.4 MB). Larger risk: its int8 output depends on exact features and on the onnxruntime version (up to 0.16 between 1.28 and 1.30), so upgrading ort silently changes turn-taking. Mitigation: bit-exact features and goldens pinned to the bundled runtime; an ort upgrade must regenerate the goldens and re-check verdicts (docs/providers/smart-turn.md).

Update (measured): Smart Turn with 2 intra-op threads is ~17 ms per call at ~34 ms of CPU (0.7% of a core per talking session, ~2 cores for 300). The opt-in early analysis (a hint at the first VAD fall) replaces the analysis at the stop when the fall becomes a stop (no extra job) and costs one extra job per fall that does not: 0 per 6 stops on the fixtures at the default 64 ms delay, 1 per 6 at 0-32 ms. early_analysis.max_per_turn (default 4) bounds it per turn and the driver counts early_wasted / early_failed / early_capped to watch it (docs/LATENCY.md, "Early analysis").

Turkish quality pass

Turkish is a first-class language of brisvox (risk R10 in docs/RISKS.md). This page covers the three Turkish-specific pieces: the TTS text normaliser, the Turkish backchannel defaults of the interruption policy, and the Turkish real-speech fixtures in the benchmarks. Everything here is a documented default chosen without live data; none of it is tuned (see "Unverified").

1. TTS text normaliser

Code: brisvox_agent::normalize (Normalizer, normalize, spell_cardinal, spell_ordinal, spell_digits, INLINE_CHUNK_CHARS). Tests: crates/brisvox-agent/tests/normalize.rs (table rows in both languages, Turkish letters, per-chunk use with the segmenter including a streaming test that feeds the segmenter 1 to 1000 chars at a time and compares the spoken chunks with the spoken whole text, a scaling test that 16 times the input costs under 64 times the time, absolute cost guards for a typical and a digit-dense chunk (release bounds, see docs/LATENCY.md), proptest: never panics, bounded growth). Every abbreviation of the two tables has at least one row, as has every rule named below that the docs call "pinned" or give an example for.

Status: wired. The TTS stage (crates/brisvox-engine/src/tts.rs, spoken) normalizes every chunk before the provider: short chunks inline, long ones on the inference pool; the context records the raw text the LLM wrote (tests: crates/brisvox-engine/tests/tts_stage.rs, chunks_are_spoken_normalized_but_recorded_verbatim, a_long_chunk_is_normalized_on_the_offload_and_gives_the_same_text). Live 2026-10-13 (bench/results/live-checks-2026-10-13.md): Cartesia reads a raw 14.30'da digit by digit, the normalized on dört otuzda correctly, which is what a call sends.

LLM text is written for eyes (%15, 1.250.000, 150 TL, 12.03.2026, 14:30, Dr.). TTS engines read such tokens inconsistently, and differently per provider. The normaliser rewrites them into the words a person says, so the spoken form is the same whichever TTS provider is used. It is a pure function of (text, Language): no state, no clock, no allocation when there is nothing to rewrite (the result is a Cow::Borrowed; a period alone costs one scan), no panic on any input, linear time.

Cost (release, dev machine, 2026-10-08): about 4 us for a 250-char chunk without numbers, 9-10 us for 328 chars of mixed Turkish or English text (numbers, date, time, phone), 0.7 us for a 14-char chunk, about 40 us for 1,230 mixed chars, and about 0.1 ms per 1,250 chars for pathological text with a number every six chars. The caller should pass chunks of at most INLINE_CHUNK_CHARS (256, the segmenter's default max_chunk_chars is 250): 256 digit-dense chars cost 11-26 us in Turkish and 15-45 us in English (worst shape 1 2 3 in English, 45 us), which is within the ~50 us rule for work on a tokio worker, but with little margin on that shape. These are measurements; the tests only guard loose bounds (docs/LATENCY.md). A chunk the segmenter hard-cut (up to five times max_chunk_chars) should be split again, or normalised off the worker. There is no input cap in the function itself; memory is a Vec<char> plus the output per call.

Using it

#![allow(unused)]
fn main() {
use brisvox_agent::{Language, Normalizer, Segmenter, SegmenterConfig};

let norm = Normalizer::new(Language::Tr);
let mut seg = Segmenter::new(SegmenterConfig::new(Language::Tr));
let mut chunks = Vec::new();
seg.push("Fatura 1.250,50 TL, ödeme 12.03.2026 tarihinde.", &mut chunks);
seg.finish(&mut chunks);
for chunk in &mut chunks {
    norm.normalize_in_place(chunk); // "Fatura bin iki yüz elli lira elli kuruş, ..."
}
}

The normaliser is meant to be applied per chunk, after the segmenter; the segmenter itself is unchanged (its output is the raw text, tested in works_per_segmenter_chunk_without_changing_the_segmenter; the test calls the normaliser itself, as the pipeline does not yet). The segmenter already keeps 1.250, 3,14, 10:30, 3. sınıf and abbreviations inside one chunk, so the normaliser sees whole tokens. The TTS stage is the natural caller and the owner of the integration (see "Status" above).

What is rewritten

Output uses the real Turkish letters.

KindTurkishEnglish
Integers1.250.000 -> bir milyon iki yüz elli bin (bir is dropped before yüz and bin only: 1000 -> bin, 100.000 -> yüz bin, 1.000.000 -> bir milyon)1,250,000 -> one million two hundred fifty thousand
Decimals3,5 -> üç virgül beş; 0,05 -> sıfır virgül sıfır beş; 3,25 -> üç virgül yirmi beş; 3,14159 -> üç virgül bir dört bir beş dokuz3.5 -> three point five (fraction digit by digit)
Negative-5 -> eksi beşminus five
Ordinals3. sınıf -> üçüncü sınıf; 21. yüzyıl -> yirmi birinci yüzyıl; 12. Sok. -> on ikinci Sokak; 5. Cadde -> beşinci Cadde; 2026. yılında -> iki bin yirmi altıncı yılında3rd -> third, 22nd -> twenty-second, 1000th -> one thousandth
Percent%15 / 15% -> yüzde on beş15% -> fifteen percent
Currency150 TL, 150TL, TL 150, 150 TRY, ₺150 -> yüz elli lira; US$20, USD 20 -> yirmi dolar; 5 milyar $ -> beş milyar dolar; 150,50 TL -> yüz elli lira elli kuruş; 20 $, $20, 20 USD -> yirmi dolar; €20, 20 EUR -> yirmi avro; £5 -> beş sterlin; ₺1,5 milyon -> bir virgül beş milyon lira$1.50 -> one dollar fifty cents; $1.5 million -> one point five million dollars; £5.01 -> five pounds one penny
Dates12.03.2026, 12/03/2026, 12-03-2026, 2026-03-12 -> on iki Mart iki bin yirmi altı (ranges: 12.03.2026-15.03.2026 -> on iki Mart iki bin yirmi altı-on beş Mart iki bin yirmi altı); 12 Mart 2026 already reads correctly (cardinal, month, cardinal)March 12, 2026 -> March twelfth, twenty twenty-six; 12 March 2026 -> the twelfth of March, twenty twenty-six; 2026-03-12; 03/12/2026
Times09:00-18:00 -> dokuz-on sekiz (a range: each side is a time, the dash stays); 14:30 -> on dört otuz; 09:05 -> dokuz sıfır beş; 14:00 -> on dört; 14:30:15; Saat 14.30 gibi -> Saat on dört otuz gibi; yarın 10.30'da -> yarın on otuzda9:05 -> nine oh five; 9:00 -> nine o'clock; 9:30 PM -> nine thirty p m; 14:00 -> fourteen hundred; years: in 1999 -> in nineteen ninety-nine, the 1990s -> the nineteen nineties
Phone numbers+90 532 123 45 67 -> artı dokuz sıfır, beş üç iki, bir iki üç, dört beş, altı yedi; 05321234567 regrouped 4-3-2-2; 5321234567 and 532 123 45 67 (no leading zero) regrouped 3-3-2-2; 444 12 34 -> dört dört dört, bir iki, üç dört555-123-4567 -> five five five, one two three, four five six seven
Versions / addressessürüm 2.0.1 -> sürüm iki nokta sıfır nokta bir; v2.0.1 unchangedversion 2.0.1 -> version two dot zero dot one
Abbreviations (context rules below)Dr. Doktor, Prof. Profesör, Doç. Doçent, Öğr. Gör. Öğretim Görevlisi, Av. Avukat, Sn. Sayın, Mah. Mahallesi, Cad. Caddesi, Sok. Sokak, A.Ş. Anonim Şirketi, T.C. Türkiye Cumhuriyeti, vb. ve benzeri, vs. vesaire, vd. ve diğerleri, dk. dakika, sn. saniye, bkz., örn., ...Mr., Mrs., Ms., Dr., Prof., Jr., Sr., Inc., vs. versus, etc. et cetera, e.g., i.e.
Apostrophe suffixes (Turkish)15'inde -> on beşinde; 3'üncü -> üçüncü; 1990'larda -> bin dokuz yüz doksanlarda; 14:30'da -> on dört otuzdanot applied

Rules that are guesses (change them in one place, normalize.rs)

  • Separators follow the language. Turkish: . thousands, , decimal. A lone . not followed by exactly three digits is read as a decimal point, because LLMs often write 3.5 (3.5 -> üç virgül beş). 1.250 is bin iki yüz elli, never one point two five zero. English is the reverse. A Turkish comma list without spaces (1,2,3) is read bir virgül iki,üç.
  • Fractions. Turkish reads one or two fraction digits as a number (leading zeros spoken), a longer fraction digit by digit (3,14159). English always reads digit by digit.
  • Ordinal heuristic (Turkish). Digits (at most four), a period, a space and then a lowercase word, an abbreviated noun (Sok.) or a capitalised noun from a short list (Cadde, Sokak, Kat, Sınıf, Bölge, Bölüm, Lig, Sezon, Daire, Blok, Sayfa, Madde, Dönem, Yüzyıl, Asır, Etap, Kısım) is an ordinal. A period before any other capital is a sentence end (Ben 3. Sonra gittik. -> Ben üç. Sonra gittik.), the same rule the segmenter uses. English ordinals (1000th) take any number of digits.
  • Capitalised ordinal nouns across a chunk boundary. The segmenter cuts digit. Capital as a sentence end (the pipeline's rule, deliberately unchanged), so kızım 5. Cadde üzerinde is one chunk only while the segmenter does not cut there. When it cuts (pinned by a_capitalised_ordinal_noun_split_by_the_segmenter_is_a_documented_limit), the first chunk ends in 5. and is read beş.; lowercase nouns (5. cadde) are never split.
  • Suffix after a currency is dropped (150 TL'ye -> yüz elli lira). The suffix of TL'ye is inflected for the letters "te-le"; re-inflecting lira (liraya) needs vowel and consonant harmony on top of the symbol, which is not implemented. The meaning survives (the case ending is lost, the TTS prosody is flat there).
  • Abbreviation context. Many abbreviations are also words or end a sentence (Elm Dr., Bul. "find!", Av. "hunt", Tel. "wire"), and expanding them would change the sentence. Titles (Dr., Prof., Doç., Av., Sn., Mr., Mrs. ...) are expanded only when a capitalised name follows (Dr. Ayşe, Prof. Dr. Ahmet; Dr. at the end of a chunk is left). Sok., Bul., Tel. are expanded only after a capitalised word or a number (Gazi Bul., 12. Sok.) or before a digit (Tel. 0212 ...); English approx., vs., e.g., i.e. need a word after them. Other entries (Mah., Cad., Apt., Ltd., vb., dk. ...) are not words and always expand. yak. was removed (it is the imperative "yak" before a period). Pinned in the table (Ateşi yak., Hemen bul., Elm Dr., Dr.).
  • Abbreviation periods. An expanded abbreviation loses its period (Dr. Ayşe -> Doktor Ayşe). Sentence-final ones (vb., vs., vd., dk., sn., etc.) keep it when they are the last thing in the chunk or a capital follows; A.Ş., T.C., Inc. keep it only at the very end of the chunk (a company name is usually followed by more words). Matching is case sensitive (Sn. Sayın, sn. saniye).
  • Numbers glued to letters are left alone (A4, mp3, COVID19); a number followed by a unit gets a space (5km -> beş km).
  • Dates. Turkish numeric dates are day-month-year. English . is day-month-year; English / and - are US month-day-year unless the first number is above 12. A two-digit year is not a date. A date that does not exist on the calendar (32.13.2026, 31.02.2026, 31.04.2026, 29.02.2026; 29.02.2024 is fine) is read as dotted numbers. Month names with a day (February 30) are not checked.
  • Ranges. A - followed by a digit continues a number only when the number itself is written with - (12-03-2026-15 is not a date). After a :, . or / token it is the dash of a range: Pazartesi-Cuma 09:00-18:00 -> Pazartesi-Cuma dokuz-on sekiz, 12.03.2026-15.03.2026 and 12/03/2026-15/03/2026 read as two dates. Before this fix the first half of such a range was read digit by digit (sıfır dokuz:sıfır sıfır-on sekiz). The dash is left in the text for the TTS to pause on. The second half of a dotted time range is a time when the first half follows saat (saat 09.00-17.00 -> saat dokuz-on yedi); without saat both halves stay decimals (17.00-18.00 -> on yedi virgül sıfır sıfır-on sekiz virgül sıfır sıfır). Phone numbers are unchanged: a - between digit groups joins them into one number.
  • Times need a two-digit minute and hour 0-23 (1-12 with AM/PM). 16:9 is not a time. The Turkish dotted form 14.30 is also a decimal point (10.30 TL), so it is a time only after the word saat (Saat 14.30 gibi) or with a time case suffix (10.30'da, 'dan, 'a, 'ye ...). Ambiguity kept: Fiyat 12.50'ye reads as a time; 7.30 alone is yedi virgül otuz.
  • Phone numbers are recognised by shape: + and 8-15 digits, a leading 0 with 10-11 digits, in Turkish a 10-digit mobile number starting with 5 (bare or 532 123 45 67) and a 7-digit 444 call-centre number (444 12 34, 444 0 123, 444 1234), or 10-11 digits grouped 3-3-4 / 1-3-3-4. Any other bare digit run is a number (1234567890 is read as a billion ...), as are 7-digit local numbers (123 45 67). A Turkish amount of five billion written without separators is therefore read as a phone number. Groups are separated by , so the TTS pauses between them.
  • Space-grouped thousands. 100 000 TL, 1 000 000 and the no-break-space forms are one number (a first group of 1-3 digits without a leading zero, then groups of exactly three). Two quantities written next to each other (2 150 TL for two items at 150) cannot be told apart and are read as 2150.
  • Currency forms. 150 TL, 150TL, TL 150, ₺150, USD 20, US$20, US$ 5, 5 milyar $. $ after a letter other than US (R$ 5, A$5) is another currency: symbol and amount are left as written. $ 5 (symbol, space) alone is not rewritten.
  • English years and decades. Four digits from 1100 to 2099 after in, since, by, until, till, from, during, before or after, not before a unit, % or currency, are read as a year (in 1999 -> in nineteen ninety-nine); 1990s, 1990's, 1900s, 2000s are decades. Other four-digit numbers stay cardinals (1995 people).
  • Large numbers go up to kentilyon (10^18) in Turkish and quintillion in English (u64::MAX is spelled correctly in both); anything longer than 18 digits, or with a leading zero (007), is read digit by digit.
  • Not covered: units (km, kg, °C), Roman numerals, fractions (1/2), two-digit years, e-mail and URLs (the segmenter keeps them whole; the TTS spells them), emoji.

Interaction with played-text tracking and provider normalisation

This is a design proposal; none of it is built (see "Status").

  • If the TTS provider is sent the normalised text, its word timestamps are words of the spoken form (yüz, elli, lira). Feed PlayedText the same normalised chunk it sent, and the heard text in the LLM context will be the spoken form ("yüz elli lira") instead of the raw "150 TL". That is consistent with "the context contains only what was played" and needs no mapping. Normalise exactly once: feeding PlayedText the raw chunk while the TTS got the normalised one, or normalising the already normalised text again, breaks the word alignment. If the context must keep the raw text, PlayedText needs the raw chunk plus a raw-to-spoken alignment; that is not built.
  • Some TTS providers have their own normalisation switch (for example a text-normalisation option in the request). Turn it off or leave it on, but not both layers expecting different results: with brisvox normalising first, the provider sees words and has nothing left to do. Provider behaviour was not checked (see "Unverified").

2. Turkish backchannels

Code: BackchannelSet::turkish() in crates/brisvox-agent/src/interrupt.rs; tests: crates/brisvox-agent/tests/turkish_backchannels.rs (plus the existing table in tests/interruption.rs, unchanged). The test file lists all 60 phrases below and asserts that the set contains every one of them and has exactly 60 entries, so a typo, a swap, a duplicate or a silent removal fails. A backchannel is a listener sound or short acknowledgement that must not interrupt the bot (InterruptionPolicy::recommended() unions the English and Turkish sets). Matching is on whole normalised tokens of the latest interim: lower case, every non-alphanumeric is a separator, capital İ folds to i, the dotless ı stays distinct (hı is not hi, and an all-capitals HIHI is not hıhı). Repetitions need no entries: evet evet, hı hı hı, tamam tamam are sequences of listed phrases.

The original list had 19 phrases (evet, hı-hı, hıhı, hı, he, hm, hmm, mhm, mm, tamam, tamamdır, aynen, anladım, anlıyorum, peki, tabii, tabi, öyle mi, ya). It missed what Turkish STT writes for the same sounds and several common acknowledgements. The Turkish pass extends it to 60 phrases (the original 19 plus 41) in four groups:

  • Listener sounds as STT spells them: ha, haa, hıı, hım, hımm, ımm, mmm, aha, aa, ah, oh, ohh, ay, of.
  • Fillers while thinking (the caller is not speaking to the bot): ee, eee, ıı, ııı, şey, yani.
  • Acknowledgements: olur, doğru, tabii ki, tabi ki, elbette, anlaşıldı, anlaşılıyor, eyvallah, peki tamam.
  • Reactions: gerçekten mi, sahi mi, ciddi mi, yok artık, hadi ya, demek öyle, vay, vay be, harika, güzel, süper, iyi.

Deliberately not in the list, because they stop, correct or answer the bot: hayır, olmaz, yok, dur, bekle, bir dakika, dinle, and the thanks teşekkürler / sağ ol (thanks often end the call). The test substantive_turkish_still_interrupts pins these, including near misses: evet ama, iyi değil, güzel ama pahalı, olur mu, gerçekten mi dedin, şey var.

Consistency note: evet is in the list (it was in the original list) although an English "yes" is not, because a Turkish "evet" mid-answer is overwhelmingly a listener signal ("yes, go on"), while a direct answer to a question normally arrives when the bot has finished speaking. olur and doğru are the same kind of call. All of this is a guess until live Turkish calls say otherwise; the lists are plain data on BackchannelSet and can be replaced per deployment.

3. Turkish fixtures in the benchmarks

Before the Turkish pass, brisvox-bench (sim-load, infer-load, resample) used no recorded speech: sim-load plays a synthetic scripted caller, so none of the Turkish FLEURS fixtures (fixtures/tr_two_utterances, fixtures/en_tr_mixed, see fixtures/NOTICE and docs/TESTING.md) was in the benchmark set; they were only in the correctness suites (brisvox-sim tests/fixtures.rs).

The Turkish pass adds brisvox-bench fixtures (crates/brisvox-bench/src/fixture_load.rs): each committed fixture (en_two_utterances, tr_two_utterances, en_tr_mixed) is played as N concurrent simulated calls in real time through the full reference pipeline against mock providers with the same latency model as sim-load. The mock STT returns the fixture transcript; replies are Turkish for Turkish utterances and English for English ones (same length, so the mock TTS audio is identical). Both are checked, not assumed: every call's conversation as the LLM saw it must be exactly [user: transcript 1, bot: reply for language 1, user: transcript 2, ...] (FixtureReport::conversation_mismatches; a run with a mismatch is invalid and exits non-zero; the integration test asserts 0 and was mutation-checked by answering every turn in English). The integration test's sanity floor for the median voice-to-voice is derived from the configured latency medians (response_floor(): 60 percent of LLM first token plus TTS first byte), not a magic number. It reports server-side voice-to-voice per fixture and per utterance language, plus the internal overhead.

cargo run --release -p brisvox-bench -- fixtures --sessions 10 --out bench/results/fixtures-sim.md

The recordings leave only 1.0-1.5 s between utterances, which is less than any bot's answer (about 1.2 s voice-to-voice plus the audio), so as recorded the caller would talk over every answer. pad_gaps therefore inserts digital silence so that every gap between utterances is at least 4 s (FixtureLoadConfig::gap); the speech samples and their ground truth are untouched (tested byte for byte). The run is invalid (non-zero exit, nothing written) when a call fails, a turn goes unanswered or a barge-in happens.

Result (2026-10-08, release, 4 tokio workers, 10 calls per fixture, shared desktop; bench/results/fixtures-sim.md): every turn answered, no barge-ins, voice-to-voice p50 995 ms (en_two_utterances), 1009 ms (tr_two_utterances), 994 ms (en_tr_mixed; English turns 1039, Turkish turns 932 ms). The language makes no systematic difference, as expected with mock providers: the numbers show the pipeline treats Turkish speech like English, not how fast a Turkish provider is.

What this does not measure: Turkish recognition quality or real provider latency. The mock STT is perfect and the mock providers are synthetic; word error rate and TTS first byte for Turkish need live provider runs (BRISVOX_LIVE_TESTS=1).

Live Turkish checks (2026-10-13)

Deepgram nova-3 language=tr (Turkish is listed for nova-3, nova-2 and base; not for Flux; docs fetched 2026-10-13): WER 0.038 and 0.000 on the FLEURS fixture tr_two_utterances.wav, WER 0.00-0.17 on Cartesia and ElevenLabs Turkish speech of ç ğ ı İ ö ş ü sentences (0.31 for Cartesia on raw digits). Both TTS providers return word timestamps with the Turkish letters intact, first audio 135-240 ms. One-word first chunk Tamam, (FIRST_MIN_WORDS_TR = 1): Cartesia shows no extra pause; ElevenLabs adds a 210-230 ms pause after Tamam, that a single chunk does not have; Aura-2 has no Turkish voice. The intonation question is for a listening test of the audio the live checks write to bench/results/live-audio/ (provider output, not published with the repository). Deepgram's interims arrive once per second in Turkish as in English, so on_partial speculation does not pay off for Turkish either (bench/results/speculation-live-deepgram-2026-10-13.md).

Unverified

  • Live check 2026-10-13 (bench/results/live-checks-2026-10-13.md): Cartesia and ElevenLabs read the normaliser's output (iki yüz elli lira, on dört otuzda) correctly and do not re-normalise it (Deepgram transcribes it back with WER 0.154 from the spacing otuz da alone). Aura-2 was not checked (no Turkish voice). Without the normaliser, Cartesia reads 14.30'da digit by digit (bir dört üç sıfır da) while ElevenLabs reads it correctly: the normaliser is needed for Cartesia. Whether a provider's own normaliser should be switched off remains unverified.
  • The backchannel list and the ordinal / separator heuristics are reasoned from how Turkish is written and spoken, not measured on Turkish calls.
  • English year reading (twenty twenty-six) and the US reading of ambiguous English dates are conventions, not requirements.

Speech fixture sources

Research date: 2026-10-07. Every fact below was read from the cited URL on that date unless marked UNVERIFIED. The research itself ran no download.

Update 2026-10-07 (after scripts/fixtures/build_fixtures.py ran): the pinned resolve URLs work anonymously; read-and-close prefix streaming works (en 16 MB, tr 64 MB prefixes); tar members are keyed by basename; the WAVs are 16 kHz mono 32-bit float (format 3), not PCM16, and are converted; clip levels vary by more than 100x and some clips carry long leading/trailing silence (hence normalization + trimming); Turkish dev clips often contain mid-sentence pauses up to 1.6 s and are filtered. Still UNVERIFIED: that FLEURS transcripts are FLORES-101 text under CC BY-SA (handled conservatively). The per-clip notes further down are the original research and may not match the clips finally selected (see fixtures/sources.lock).

Goal: short (a few seconds) English + Turkish speech clips with transcripts, permissive license, anonymous HTTPS download (python3 stdlib or curl), < 10 MB committed, larger sets fetched with SHA-256.

Recommendation (primary)

Google FLEURS (google/fleurs on Hugging Face), configs en_us and tr_tr.

  • One source for both languages, gated: false, license cc-by-4.0, 16 kHz audio, transcripts in TSV.
  • Files are public over plain HTTPS resolve URLs (no token, no account).
  • Pin the dataset commit 70bb2e84b976b7e960aa89f1c648e09c59f894dd ("Convert dataset to Parquet", 2026-05-15) in every URL so bytes never change under us.
  • Caveat (legal, see "License risk"): the sentences come from FLORES, which is CC BY-SA 4.0.

Fallbacks: LibriSpeech (English only, CC BY 4.0) and OpenSLR 108 MediaSpeech (Turkish only, CC BY 4.0 but YouTube-derived). Rejected: Common Voice (account + redistribution ban), ISSAI TSC (single huge tarball), Tatoeba (per-clip licenses, some unusable).

Candidate comparison

SourceLanguagesLicenseAnonymous HTTPSSmallest unit to fetchTranscriptsVerdict
FLEURS (HF google/fleurs)en_us, tr_tr (+100)CC-BY-4.0 (card); text from FLORES CC BY-SA 4.0yes, not gateddev.tar.gz 171 MB (en) / 199 MB (tr); read only a prefixyes, TSVPRIMARY
Common Voice (Mozilla Data Collective)en, trCC0 historically (NOT re-verified now)NO, account + termsn/ayesrejected
LibriSpeech (OpenSLR 12)en onlyCC BY 4.0yesdev-clean.tar.gz 337 MByes (in tar)English fallback
MediaSpeech (OpenSLR 108)fr, ar, es, tr (no en)CC BY 4.0yesTR.tgz 618 MByesTurkish fallback, YouTube copyright caveat
ISSAI Turkish Speech Corpus (HF)tr onlyHF card says MIT; arXiv/ISSAI says CC BY 4.0 (inconsistent, UNVERIFIED which is right)yesone 218 h tarball (size not fetched)yesrejected (huge, license inconsistency)
Tatoeba audiomanyper-contributor, mixedyes, per filesingle mp3sentence text separaterejected (license per clip)

FLEURS details

Sources (all fetched 2026-10-07):

  • Card: https://huggingface.co/datasets/google/fleurs and raw https://huggingface.co/datasets/google/fleurs/raw/main/README.md
  • API: https://huggingface.co/api/datasets/google/fleurs (fields: "gated": false, "private": false, "license": "cc-by-4.0", sha 70bb2e84..., lastModified 2026-05-15)
  • Paper: https://arxiv.org/abs/2205.12446 (Conneau et al. 2022)
  • Commit history: https://huggingface.co/datasets/google/fleurs/commits/main (latest: 70bb2e8 "Convert dataset to Parquet (#31)")

License quotes:

  • Card, "Licensing Information": "All datasets are licensed under the Creative Commons license (CC-BY)." YAML: license: cc-by-4.0.
  • Card, "Dataset Summary": "Fleurs is the speech version of the FLoRes machine translation benchmark."
  • FLORES repo (https://github.com/facebookresearch/flores) states in its Licenses section: "FLORES-200: CC-BY-SA 4.0". UNVERIFIED: that FLEURS prompts are exactly the FLORES-101 sentences under the same SA license (the card says speech version of FLoRes; we did not fetch a FLORES-101 license statement; the FLORES-101 README and the OLDI GitHub page did not state it explicitly, the OLDI page only loosely mentioned CC BY-SA 4.0 and Wikinews/Wikijunior/Wikivoyage).
  • The Google TFDS XTREME-S catalog page (https://www.tensorflow.org/datasets/catalog/xtreme_s) carries only the generic page license (CC BY 4.0 / Apache 2.0 for code) and the citation; it gives no dataset-specific license.

Audio / metadata:

  • 16 kHz (card "Sample Rate: 16,000 Hz"; dataset viewer feature "audio": {"sampling_rate": 16000}).
  • Files are .wav (viewer serves audio/wav). UNVERIFIED: 16-bit PCM mono (assumed from FLEURS WAV convention; check with wave module).
  • num_samples is samples at 16 kHz: 94080 = 5.88 s. At 16-bit mono, 32 000 bytes/s, so a 5 s clip is about 160 KB and 10 clips per language is about 1.6 MB.
  • Many sentences have several speakers (same id, different filename, gender MALE/FEMALE).
  • TSV (data/<lang>/{train,dev,test}.tsv) has 7 tab-separated columns, no header (observed on test.tsv, both languages): id, filename.wav, raw_transcription, transcription (normalized), tokenized chars with "|", num_samples, gender. (The Turkish row fetched had 7 columns once the tokenized field is counted; one summary mis-counted it as 6.)
  • Gotcha (observed in tr_tr dev.tsv through the fetch summarizer, re-verify): the normalized transcription lowercases "I-dot" as i + combining dot (U+0307) e.g. "i̇spanyollar". Compare Turkish text with Unicode normalization or use raw_transcription.
  • Data fields on HF viewer: id, num_samples, path, audio, transcription, raw_transcription, gender, lang_id, language, lang_group_id.
  • Paper: about 12 h supervision per language (abstract); card says about 10 h in train.

Files (repo tree API, commit 70bb2e84...), with LFS sha256 of whole files:

PathBytessha256 (LFS oid)
data/en_us/audio/dev.tar.gz171,250,9002658fda72f199e12676ecac9415094667a4e14e149b146e568ea00b2a2f0954c
data/en_us/audio/test.tar.gz289,851,356d9c2e37b41aacd41bc283554a0a82b5476b36887049774ecb2819dcaaa55a356
data/en_us/audio/train.tar.gz1,380,572,2415f4491948c2bd29ac00f4b8afae2378f0a1dcdde4041b5cd284a80dff01fa9f5
data/tr_tr/audio/dev.tar.gz198,683,8677476fc01bc50750f2854dbc1b4fa285f00092808e7e43244e0b978e031cbe712
data/tr_tr/audio/test.tar.gz464,259,241a7db7a681e4460d2922626bf2037ddc1a9146efba27d78bfc9543250eabc3a09
data/tr_tr/audio/train.tar.gz1,468,477,481bb8f81d19282cab8068b915265f60147f84127d37c71ad920fd45409ba5072aa
data/en_us/{dev,test,train}.tsv213,065 / 367,864 / 1,414,907not fetched
data/tr_tr/{dev,test,train}.tsv191,300 / 447,909 / 1,460,325not fetched

Parquet alternative (parquet-data/<lang>/{train,validation,test}-00000-of-00001.parquet): 236 MB to 1.9 GB, single shard each, footer-at-end format; not usable with stdlib. Not recommended. The refs/convert/parquet branch only has empty language dirs plus .gitattributes in the tree listing we saw.

Per-clip access options:

  1. Tar prefix streaming (recommended). .tar.gz is a sequential stream, so the first N MB decompress to the first complete members. Fetch with stdlib urllib.request (follows the 302 to the CDN), read only the first ~12-16 MB, close the connection, decompress with zlib.decompressobj(wbits=31), parse with tarfile.open(mode="r|") and ignore the truncation error on the last partial member. Optional header Range: bytes=0-16777215; the script must NOT depend on Range being honoured (read-and-close works either way). UNVERIFIED: tar member naming (the old loader script fleurs.py matches on audio_path.split("/")[-1], so members may sit under a dev/ style prefix; key everything by basename) and which clips fall inside the prefix. Therefore the script selects clips from the prefix by looking each basename up in dev.tsv and keeping those with num_samples <= 96000.
  2. Dataset viewer API (anonymous, per-row): https://datasets-server.huggingface.co/first-rows?dataset=google%2Ffleurs&config=en_us&split=validation returned JSON with audio[0].src = signed, expiring URL to .../assets/google/fleurs/--/<sha>/--/en_us/validation/<row>/audio/audio.wav?Expires=.... Docs: https://huggingface.co/docs/dataset-viewer/rows says the audio src is a signed URL that expires and cached assets may be emptied ("you may have to call /rows again"). The /rows endpoint (offset/length, max 100) returned HTTP 500 once in our test and first-rows worked. The WAV is re-rendered by the viewer, so its bytes are NOT guaranteed stable: only use for exploration, never for checksummed fixtures.
  3. Whole-tar download: sizes above; verify against the LFS sha256. Not needed for the committed set.

Concrete URL patterns (replace <REV> with 70bb2e84b976b7e960aa89f1c648e09c59f894dd):

https://huggingface.co/datasets/google/fleurs/resolve/<REV>/data/en_us/dev.tsv
https://huggingface.co/datasets/google/fleurs/resolve/<REV>/data/tr_tr/dev.tsv
https://huggingface.co/datasets/google/fleurs/resolve/<REV>/data/en_us/audio/dev.tar.gz   (171,250,900 B)
https://huggingface.co/datasets/google/fleurs/resolve/<REV>/data/tr_tr/audio/dev.tar.gz   (198,683,867 B)

(The resolve/main/...tsv URLs for test.tsv were fetched successfully today; the <REV> forms for dev.tsv also worked. resolve returns a redirect to the storage CDN; the HF docs note redirects, so the client must follow them.)

Candidate short clips seen today (via a summarizing fetcher, so treat names as hints; the script must re-read the TSV):

  • en_us dev.tsv: 4175831345558885921.wav (96,000 samples = 6.0 s, FEMALE, "there are many beaches due to auckland's straddling of two harbours..."), 9742912202579443140.wav (99,840), 2842775607363710885.wav (90,240, "with only eighteen medals available a day...").
  • tr_tr dev.tsv: 14650425844216857989.wav (94,080 = 5.88 s, MALE, "i̇spanyollar üç asır süren kolonileşme dönemini başlattı"), 4110479544867812528.wav (120,000), 602812882332095246.wav (120,000), 11297332020673814086.wav (116,160). Turkish dev rows seen were all MALE speakers; gender balance for TR is UNVERIFIED.
  • en_us test.tsv: 621925716775998155.wav (94,080, FEMALE, id 1914, "Twentieth century research has shown that there are two pools of genetic variation: hidden and expressed."), in test.tar.gz (290 MB). These do not give us exact end-of-speech times: FLEURS has no alignments. UNVERIFIED whether clips carry leading/trailing silence. The fixture builder must compute end-of-speech itself (energy threshold on the 16 kHz PCM) and store it in the committed manifest; the manifest then is the ground truth.

Size budget: 12 clips per language, each <= 6 s, is about 2.3 MB total at 16 kHz mono 16-bit (estimate). Commit WAV (or trimmed WAV) plus a manifest.json (filename, language, speaker gender, transcript, num_samples, sha256 of committed bytes, end_of_speech_sample).

License risk and attribution

CC BY 4.0 legal code, section 3(a)(1) (https://creativecommons.org/licenses/by/4.0/legalcode.en), requires on redistribution: (i) identification of creator(s) / those designated for attribution, (ii) a copyright notice, (iii) a notice referring to the Public License, (iv) a notice referring to the disclaimer of warranties, (v) a URI/hyperlink to the Licensed Material where reasonably practicable; and (B) "indicate if You modified the Licensed Material and retain an indication of any previous modifications". Section 3(a)(2): conditions may be satisfied "in any reasonable manner based on the medium, means, and context". Concatenating clips or trimming silence counts as a modification: say so.

Redistribution inside an Apache-2.0/MIT repo: allowed as long as the fixtures keep their own license notice; CC BY data is not relicensed. Keep it in a dedicated directory (e.g. tests/fixtures/speech/) with its own LICENSE/NOTICE, excluded from the code license claim. Generated mixes are "Adapted Material" and must carry the same notice.

ShareAlike risk (a legal question for the maintainers): FLORES text is CC BY-SA 4.0 (per the FLORES repo). Under CC BY-SA 4.0 section 3(b), adapted material must be offered under BY-SA (or compatible) and "You may not offer or impose any additional or different terms or conditions" restricting the adapter license. The FLEURS card labels the whole dataset CC-BY and Google presumably cleared this, but the question applies to the transcripts (and possibly the readings). Mitigation options: (a) ship the fixtures directory under CC BY-SA 4.0 notice (safest, harmless for tests); (b) ship audio without sentence text and put transcripts only in the fetched (uncommitted) layer; (c) use LibriSpeech (public-domain books, CC BY 4.0 audio) for English-only committed clips. Our recommendation is (a). This is not legal advice.

Attribution text to ship (tests/fixtures/speech/NOTICE):

Speech clips in this directory are taken from the FLEURS dataset (Google),
https://huggingface.co/datasets/google/fleurs (revision 70bb2e84b976b7e960aa89f1c648e09c59f894dd),
licensed under Creative Commons Attribution 4.0 International (CC BY 4.0),
https://creativecommons.org/licenses/by/4.0/ . Sentence texts originate from the FLORES benchmark
(Wikimedia-derived, CC BY-SA 4.0, https://creativecommons.org/licenses/by-sa/4.0/); this directory is
therefore also made available under CC BY-SA 4.0. The clips were modified by Brisvox: selected,
trimmed and/or concatenated with synthetic silence, and resampled/re-encoded only if stated in manifest.json.
No endorsement by the dataset creators or speakers is implied. The material is provided "as is"
without warranties (see the license's disclaimer of warranties).
Citation: Conneau, Ma, Khanuja, Zhang, Axelrod, Dalmia, Riesa, Rivera, Bapna. "FLEURS: Few-shot Learning
Evaluation of Universal Representations of Speech." arXiv:2205.12446, 2022.

Rejected / fallback candidates

Mozilla Common Voice

  • Since October 2025 Common Voice datasets are "exclusively available through Mozilla Data Collective" (notice on https://huggingface.co/datasets/mozilla-foundation/common_voice_17_0, which is effectively deprecated; search summary of the Mozilla announcements, https://datacollective.mozillafoundation.org).
  • MDC terms (https://mozilladatacollective.com/terms): users must register a Data Consumer Account; rights are "determined by individual Data Providers"; the terms forbid to "copy, scrape, download or otherwise acquire any Dataset ... for the purpose of (A) mirroring, duplicating or reproducing the Dataset in whole or in substantial part, or (B) hosting, storing or making the Dataset available on any platform, server or repository other than the Platform", and forbid re-identification of contributors.
  • Result: account + terms acceptance (violates "no accounts"), and committing clips to our repo conflicts with the platform terms. The current CC0 status on MDC was NOT re-verified (FAQ URL returned 404; discourse posts did not state it). Rejected.

LibriSpeech (OpenSLR 12) - English only

  • https://www.openslr.org/12/ : license CC BY 4.0, 16 kHz read English. Files: dev-clean.tar.gz 337M, dev-other 314M, test-clean 346M, test-other 328M, train-clean-100 6.3G; md5sum.txt. Mirrors: https://openslr.trmal.net/resources/12/<file>, https://openslr.elda.org/resources/12/<file>, https://openslr.magicdatatech.com/resources/12/<file>. (OpenSLR publishes md5, not SHA-256: pin our own SHA-256 after first download.)
  • Citation: Panayotov, Chen, Povey, Khudanpur, "LibriSpeech: an ASR corpus based on public domain audio books", ICASSP 2015.
  • Archives are FLAC inside tar.gz (UNVERIFIED from this fetch); same prefix-streaming trick works. Forced alignments for word end times were not researched.

MediaSpeech (OpenSLR 108) - Turkish, no English

  • https://www.openslr.org/108/ : AR/ES/FR/TR, 10 h each, CC BY 4.0, file https://openslr.trmal.net/resources/108/TR.tgz (618M). Citation: Kolobov et al. 2021, arXiv:2103.16193.
  • https://github.com/NTRLab/MediaSpeech : "converted to single-channel 16 kHz 16-bit PCM encoded WAV files using FFmpeg"; "distributed under the Creative Commons Attribution 4.0 International License"; but audio is from YouTube channels and "copyright remains with the original owners of the video", with a takedown policy. Higher legal risk than FLEURS for committing clips; usable only as a fetched (not committed) Turkish extension (news-style, noisy, ASR-cut segments, so durations are less controlled).

ISSAI Turkish Speech Corpus

  • https://huggingface.co/datasets/issai/Turkish_Speech_Corpus : not gated, only ISSAI_TSC_218.tar.gz (218.2 h, 186,171 utterances), HF license tag mit. Another search summary listed it as CC BY 4.0 (UNVERIFIED which is authoritative). Single huge tarball, sample rate not stated on the card. Rejected.

Tatoeba

  • https://tatoeba.org/en/downloads : per-file URL https://tatoeba.org/audio/download/<audio_id>; export https://downloads.tatoeba.org/exports/sentences_with_audio.tar.bz2 (IDs only). License is "chosen by the contributor"; "If the license field is empty, you may not reuse the audio outside the Tatoeba project." Per-clip license vetting would be needed. Rejected.

TTS-free alternatives

All above are human recordings. Self-recorded clips by the maintainers would avoid licenses entirely (and give clean silence) but need a human; consider them if FLEURS ends up unsuitable. Synthetic silence/noise gaps are produced by the fixture builder, not downloaded.

Open items for the implementer

  1. Run the fetch script once; record: HTTP status chain for the resolve URL, whether Range is honoured, tar member names, WAV header (channels, bit depth), first-member order, leading/trailing silence of chosen clips.
  2. Pick clips by num_samples <= 96000 from the dev TSV, balance genders where possible, compute end-of-speech by energy gate, write manifest.json.
  3. Pin SHA-256 of each committed WAV in the manifest; the fetch script for larger sets pins the LFS sha256 above (whole-tar) or per-clip hashes recorded after first extraction.
  4. Add the NOTICE text above; record the CC BY-SA assumption (option a).

Source URLs (all accessed 2026-10-07)

  • https://huggingface.co/datasets/google/fleurs ; /raw/main/README.md ; /commits/main ; /raw/d7c758a/fleurs.py (old loader)
  • https://huggingface.co/api/datasets/google/fleurs ; .../tree/main/{data,parquet-data}/{en_us,tr_tr}[/audio] ; .../tree/70bb2e84.../data/{en_us,tr_tr}/audio
  • https://datasets-server.huggingface.co/first-rows?dataset=google%2Ffleurs&config=en_us&split=validation ; https://huggingface.co/docs/dataset-viewer/rows
  • https://arxiv.org/abs/2205.12446 ; https://www.tensorflow.org/datasets/catalog/xtreme_s ; https://github.com/facebookresearch/flores
  • https://creativecommons.org/licenses/by/4.0/legalcode.en ; https://creativecommons.org/licenses/by-sa/4.0/legalcode.en
  • https://mozilladatacollective.com/terms ; https://huggingface.co/datasets/mozilla-foundation/common_voice_17_0 ; https://discourse.mozilla.org/t/datasets-a-public-api-and-more-coming-in-2025/145859
  • https://www.openslr.org/12/ ; https://www.openslr.org/108/ ; https://arxiv.org/abs/2103.16193 ; https://github.com/NTRLab/MediaSpeech
  • https://huggingface.co/datasets/issai/Turkish_Speech_Corpus ; https://tatoeba.org/en/downloads

Releasing

How a release of brisvox is made: what it consists of, the steps a maintainer takes, what each workflow does, and the registry settings that are entered once. Everything is released together under one version number.

What a release contains

ArtifactWhereNameBuilt and published by
Prebuilt brisvox-serverGitHub Release assetsbrisvox-server-vX.Y.Z-<target>.tar.gz (.zip on Windows), SHA256SUMSrelease.yml: binaries, github-release
Container imageghcr.io/bilal07karadeniz/brisvox-servertags vX.Y.Z and latest, linux/amd64 + linux/arm64release.yml: docker-build, docker-push, docker-manifest
Rust cratescrates.ioeleven crates, see below; brisvox is the umbrellarelease.yml: crates-dry-run, publish-crates
Python SDKPyPIbrisvox-agent, then the umbrella brisvoxrelease.yml: python-dist, publish-pypi
TypeScript SDKnpm@brisvox/agentrelease.yml: npm-pack, publish-npm
Browser modulenpm@brisvox/webrelease.yml: npm-pack, publish-npm
npm umbrellanpmbrisvox (after the two above)release.yml: npm-umbrella, publish-npm-umbrella
The bookGitHub Pageshttps://bilal07karadeniz.github.io/brisvox/pages.yml (on every push to main)

Prebuilt targets: x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, aarch64-apple-darwin and x86_64-pc-windows-msvc. The Linux binaries are built on Ubuntu 24.04 and need glibc 2.39 or newer (the prebuilt ONNX Runtime needs 2.38). The binaries and the image are built with the feature set all-providers,call-control,smart-turn-ort,webrtc (every provider adapter, call control, Smart Turn on ONNX Runtime, the WebRTC listener); the README says so under "What to build with". The Smart Turn model is not inside the binary archives (its licence notice and a checksum-verified downloader are); the container image contains it.

The umbrella packages

The plain name brisvox is a real package on all three registries, so cargo add brisvox, pip install brisvox and npm i brisvox work next to the specific names. They hold no logic of their own and are released with everything else, under the same version:

RegistryPackageSourceWhat it is
crates.iobrisvoxcrates/brisvoxRe-exports brisvox-server (brisvox::server), the tools API (brisvox::agent), the provider adapters (brisvox::providers) and the core types (brisvox::core); brisvox::run_with_tools is a whole main. Forwards the features of brisvox-server by the same names (default: yaml, silero-tract; not the binary's mimalloc). Published last of the crates.
PyPIbrisvoxsdk/python-umbrellaDepends on brisvox-agent==X.Y.Z and re-exports its public API (from brisvox import Agent).
npmbrisvoxsdk/npm-umbrellaDepends on @brisvox/agent and @brisvox/web at exactly X.Y.Z; brisvox re-exports the agent SDK (Node), brisvox/web the browser client.

scripts/check_release.py keeps them honest: one version, the exact pins, every feature of brisvox-server forwarded, the license files in place.

The crates and their publish order

crates.io needs every dependency of a crate to exist before the crate itself, so the crates are published in this order (python3 scripts/check_release.py --order prints it and scripts/check_release.py fails when the order stops being a valid dependency order):

  1. brisvox-core
  2. brisvox-audio
  3. brisvox-inference
  4. brisvox-agent
  5. brisvox-providers
  6. brisvox-sim
  7. brisvox-transport
  8. brisvox-engine
  9. brisvox-agent-link
  10. brisvox-server
  11. brisvox (the umbrella: it depends on brisvox-agent, brisvox-core, brisvox-providers and brisvox-server, so it goes last)

brisvox-bench (the benchmark harness) and everything under examples/ are never published (publish = false). brisvox-sim is published because brisvox-engine has an optional dependency on it (its sim feature); it is also the mock providers and simulator for testing an agent. Two rules in the manifests keep publishing possible: every dependency between workspace crates carries version next to path, and every dev-dependency between workspace crates is path-only (no version), so cargo drops it from the published manifest. Several crates are each other's dev-dependency (brisvox-agent and brisvox-sim), which would otherwise be a cycle.

Each package contains src/, its README and the two license files, nothing else (include in the manifest): no tests, fixtures, examples or benchmark output. cargo publish prints "ignoring test ... is not included in the published package" for every integration test; that is expected. The tests run from the repository. The Silero VAD graph that brisvox-inference embeds (MIT) is in the package under assets/ with its NOTICE.

Making a release

  1. Bump the version in one commit: version in the workspace Cargo.toml and the version = "..." of the eleven brisvox* entries in its [workspace.dependencies]; sdk/python/pyproject.toml and __version__ in sdk/python/brisvox_agent/__init__.py; sdk/python-umbrella/pyproject.toml (its version and the brisvox-agent==X.Y.Z pin) and __version__ in sdk/python-umbrella/brisvox/__init__.py; and in sdk/typescript and sdk/web run npm version X.Y.Z --no-git-tag-version (it also updates the lock file). In sdk/npm-umbrella run the same and set both entries under dependencies (@brisvox/agent, @brisvox/web) to X.Y.Z. Then cargo check so Cargo.lock follows.
  2. Write the changelog. In CHANGELOG.md move the entries of [Unreleased] under a new ## [X.Y.Z] - YYYY-MM-DD heading and update the two compare links at the bottom. The text of that section becomes the GitHub Release notes.
  3. Check locally.
    python3 scripts/check_release.py        # versions, metadata, licenses, publish order, workflows
    scripts/publish_crates.sh --dry-run     # package and build all eleven crates, no token
    scripts/verify.sh                       # the full quality gate
    
  4. Merge to main and wait for the ci run on that commit to finish green. The release workflow refuses a tag whose commit has no successful ci run.
  5. Rehearse (optional, recommended for the first release and after changing a workflow). Actions > release > Run workflow on main. It does everything up to the gate (all binaries, both image platforms, the Python and npm packages, the crate dry run) and publishes nothing.
  6. Tag and push the tag.
    git tag -a vX.Y.Z -m "brisvox X.Y.Z"
    git push origin vX.Y.Z
    
    A version with a hyphen (0.2.0-rc.1) is a pre-release: the GitHub Release is marked as one and the image does not get the latest tag.
  7. Watch the run. If one publish job fails (a registry hiccup, the crates.io rate limit), fix the cause and re-run that job from the Actions page. Publishing is idempotent: crates, PyPI files, npm versions and image tags that already exist are skipped or replaced with the same content.

A published registry version cannot be replaced. A broken release is fixed by publishing the next patch version; cargo yank, npm deprecate and the PyPI "yank" mark the broken one.

What the workflows do

release.yml (on a v* tag; Run workflow = rehearsal)

Build stage, nothing is published here:

  • prepare: scripts/check_release.py --tag (the tag names the workspace version; crates, Python and npm packages carry the same version; the changelog has the entry; metadata, license files and publish order are right), extracts the release notes, and requires a successful ci run on the tagged commit.
  • crates-dry-run: cargo publish --workspace --dry-run, which packages all eleven crates in dependency order and builds each from its package against the ones before it.
  • python-dist: runs the SDK tests, builds the sdist and wheel of brisvox-agent and of the umbrella brisvox, twine check --strict on both, and runs the umbrella's tests with both wheels installed (the umbrella's pin on brisvox-agent must resolve to the wheel of this release).
  • npm-pack: tests and npm pack of sdk/typescript and sdk/web.
  • npm-umbrella: the static checks of sdk/npm-umbrella, npm pack of it, and a fresh install of its tarball together with the two tarballs of npm-pack, importing brisvox and brisvox/web.
  • binaries: cargo build --release --locked on a native runner per target, strip, a smoke test (--version, check of a reference definition), and scripts/package_release.py (archive plus .sha256).
  • docker-build: the Dockerfile for linux/amd64 and linux/arm64, each on a native runner, without pushing.
  • gate: succeeds only when every job above did.

Publish stage, tags only, each job needs gate:

  • github-release: creates the GitHub Release for the tag with the changelog section as notes, the archives and SHA256SUMS.
  • docker-push and docker-manifest: build each platform again (from the build cache), push it by digest to GHCR with the repository's GITHUB_TOKEN, then create one multi-platform image with the tags vX.Y.Z and latest.
  • publish-crates: scripts/publish_crates.sh, which publishes the crates in the order above (the umbrella brisvox last), skips versions that already exist and waits out crates.io's limit on new crates.
  • publish-pypi: pypa/gh-action-pypi-publish with trusted publishing, environment pypi: brisvox-agent.
  • publish-pypi-umbrella: the same for brisvox, environment pypi-brisvox, after publish-pypi (PyPI allows one pending publisher per repository + workflow + environment).
  • publish-npm: npm publish --provenance --access public of the tarballs made by npm-pack (@brisvox/agent, @brisvox/web).
  • publish-npm-umbrella: the same for brisvox, only after publish-npm succeeded (it depends on the two scoped packages being on the registry).

pages.yml (on every push to main)

Checks the book structure (scripts/check_book.py), builds it with mdBook (mdbook build book) and deploys it to GitHub Pages with actions/deploy-pages.

One-time setup

Nothing here is needed again for later releases, except that the first release of each name needs a token (below), and the token is removed afterwards.

Repository settings (GitHub)

  • Settings > Pages > Build and deployment > Source: GitHub Actions.
  • Settings > Code security > Private vulnerability reporting: enabled (SECURITY.md points reporters there).
  • Settings > Actions > General > Workflow permissions: Read repository contents is enough; the workflows declare what they need.
  • Settings > Environments: create pypi (the workflow creates it on first use; add required reviewers if every PyPI upload should wait for a person).
  • After the first image push: the package brisvox-server starts private. On the repository's Packages page open it, Package settings > Danger Zone > Change visibility: Public, and confirm that the repository is linked (the image carries the org.opencontainers.image.source label).

crates.io

Trusted publishing is configured per crate and, as of 2026-10-15, only for a crate that already exists; the crates.io announcement says the first release of a crate is published with a token.

  1. Verify the account's email address at https://crates.io/settings/profile: crates.io refuses every publish from an account without a verified email (400 Bad Request: A verified email address is required), token or not.

  2. Create a token at https://crates.io/settings/tokens: scopes publish-new and publish-update, crate patterns brisvox-* and brisvox (the pattern brisvox-* does not match the umbrella crate's plain name), an expiry of a few weeks.

  3. Add it as the repository secret CARGO_REGISTRY_TOKEN (Settings > Secrets and variables > Actions). While this secret exists the workflow uses it instead of OIDC.

  4. Push the first tag. publish-crates publishes all eleven crates; crates.io lets you publish five new crates at once and then one every ten minutes, so the job takes about an hour and waits by itself. (To avoid the wait, write to help@crates.io before the first release for a higher limit.)

  5. For each of the eleven crates (including brisvox) open its settings page on crates.io (for the first one the address ends in /crates/brisvox-core/settings) > Trusted Publishing > Add, choose GitHub and enter:

    FieldValue
    Repository ownerbilal07karadeniz
    Repository namebrisvox
    Workflow filenamerelease.yml
    Environmentleave empty
  6. Delete the secret CARGO_REGISTRY_TOKEN and revoke the token. From the next release the job authenticates with rust-lang/crates-io-auth-action (OIDC, a short-lived token). Optionally turn on "Require trusted publishing for new versions" in each crate's settings.

PyPI

PyPI can register a publisher before the project exists (a pending publisher), so no token is ever needed. There are two projects, so add two pending publishers at https://pypi.org/manage/account/publishing/ > "Add a new pending publisher" > GitHub, one for each PyPI project name (brisvox-agent and the umbrella brisvox). PyPI allows only one pending publisher per repository + workflow + environment, so each uses its own environment:

FieldValue
PyPI project namebrisvox-agent for the first, brisvox for the second
Ownerbilal07karadeniz
Repository namebrisvox
Workflow namerelease.yml
Environment namepypi for brisvox-agent, pypi-brisvox for brisvox

The first successful publish creates each project and turns its pending publisher into an ordinary trusted publisher. A pending publisher does not reserve the name: if somebody registers brisvox-agent or brisvox first, it stops working, so publish soon after adding them. The workflow publishes brisvox-agent first and brisvox second, in two jobs; if only one pending publisher exists, the other upload fails with a trusted-publishing error and the job can be re-run after adding it (skip-existing makes the finished one a no-op).

npm

An npm trusted publisher can only be added to a package that already exists, so the first version of each package is published with a token. The scope needs an owner. There are three packages: @brisvox/agent, @brisvox/web and the unscoped brisvox (the umbrella, a name on the public namespace rather than in the @brisvox scope).

  1. Create the organization brisvox on https://www.npmjs.com/org/create (free for public packages); that owns the @brisvox scope.

  2. Create a granular access token (https://www.npmjs.com/settings/~/tokens): read and write, allowed to bypass two-factor authentication (CI has no second factor), a short expiry. Its scope must cover both the @brisvox scope and the unscoped package brisvox: a token limited to @brisvox cannot publish brisvox, which is outside the scope. A granular token can only name packages that already exist, and brisvox does not yet, so for the first publish give it access to all packages (read and write) with a short expiry, and revoke it in step 5. Add it as the repository secret NPM_TOKEN.

  3. Push the first tag. publish-npm publishes @brisvox/agent and @brisvox/web with --provenance --access public, then publish-npm-umbrella publishes brisvox the same way.

  4. For each of the three packages (@brisvox/agent, @brisvox/web, brisvox) open npmjs.com > the package > Settings > Trusted Publisher > GitHub Actions and enter (case-sensitive; npm does not validate them when you save, a mistake shows at publish time):

    FieldValue
    Organization or userbilal07karadeniz
    Repositorybrisvox
    Workflow filenamerelease.yml
    Environment nameleave empty

    With npm 11.15 or newer and two-factor authentication on the account, the same can be done with npm trust github @brisvox/agent --repo bilal07karadeniz/brisvox --file release.yml --allow-publish (and the same for @brisvox/web and brisvox).

  5. Delete the secret NPM_TOKEN and revoke the token; under each of the three packages' Publishing access choose "Require two-factor authentication and disallow tokens". From the next release npm authenticates by OIDC (npm 11.5.1 and Node 22.14 or newer, which the job has). The package's repository.url must match this repository exactly; package.json has it.

Registry facts

The registry behaviour described here (trusted publishing on crates.io, PyPI and npm; the first release of a name; the crates.io rate limit on new crates; the container registry's default visibility) was read from the registries' own documentation and sources in October 2026. Registries change: when a step stops matching what the website shows, the website is right and this page should be corrected.