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
Finalizeruns 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
| Part | What is in it |
|---|---|
| Quickstart | Run the server with an example agent, write an agent in Python or TypeScript, run it all with Docker compose. |
| Concepts | The mental model: the pipeline, turns and interruptions, played-text truncation, tools. Short pages that point into the reference. |
| Configuration, Telephony, Operations | Agent definition files, phone carriers, running the server in production. |
| Benchmarks | The numbers that exist, where they come from, and what they do not show. |
| Reference | Architecture 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
- To see it work: Run the server with an example agent.
- To put your own application logic behind a call: Write an agent with the SDK.
- To understand why it behaves the way it does: The pipeline.
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:
- Run the server with an example agent. Build
brisvox-server, point it atexamples/agents/support-en.toml, set three provider keys, and talk to it over the websocket protocol. - 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.
- Docker and compose. The container image and
deploy/compose.yaml: a websocket agent and a Twilio phone agent from one image. - 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;rustuppicks 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
.envfile: export them in the shell (or use--env-filewith Docker). - For the browser example (
examples/web-assistant) a server built with thewebrtcfeature, 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-providerscompiles 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-ortloads 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-toolsadds a cannedlookup_ordertool so the example runs without an application. Never ship it to callers. Your own tools come from an SDK agent or frombrisvox_server::tools::AppToolsin 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 thewebrtcfeature andBRISVOX_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 SDK to put your own tools behind the call.
- Telephony to answer real phone calls.
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:
agent-ws, a websocket agent for browsers and apps (the websocket protocol);agent-twilio, a phone agent behind Twilio Media Streams with Twilio call control (transfer, DTMF, hang-up).
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-speechare the three adapters this agent names (all-providersturns every adapter on instead).smart-turn-ortloads 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.webrtcis 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 andcmake(sudo apt install build-essential cmakeon Debian and Ubuntu). If you talk to the server with your own websocket client instead, leave it out and useBRISVOX_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 athttp://127.0.0.1:8088/demo?force=ws: WSL2 normally forwards loopback ports to Windows, andlocalhostcounts as a secure context for the microphone. If the page does not load, checklocalhostForwardingin.wslconfigor 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
checksays a provider's feature is off. The binary was built without the adapter it names; rebuild with the--featuresof step 1.- The first turn is empty or answered late. Look at the server log for the STT connection
(
ws://127.0.0.1:6007must be the port sherpa-onnx runs on) and atsmart-turn-ortin 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
| Stage | Job |
|---|---|
AudioIn | Resamples 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. |
VadWorker | Voice-activity detection (Silero) as jobs on the dedicated inference pool. Emits backdated speech start/stop events. |
SttStage | Streams audio to the speech-to-text provider; at the VAD stop it sends Finalize immediately, in parallel with the end-of-turn model. |
TurnDriver | The 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. |
LlmRunner | One task per turn: streams the LLM, runs the tool loop, applies played-text truncation to the context. |
SegmenterStage | Cuts the streaming text into speakable chunks; the first clause goes to the voice after one word. |
TtsStage | Streams text to the text-to-speech provider and cuts audio into 20 ms frames at the call's rate. |
TransportOut | Paces 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-inferencepool. - 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
Clearapplied), 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:
- starts the new epoch (an
Interruptalways precedes the newUserTurnStarted); - sends
Clearstraight to the transport on a fast path, so queued audio is dropped and the client is told to flush what it buffered; - cancels the LLM round, the segmenter and the open TTS contexts;
- 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_onlytools 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_effecttools 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 asalready_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
- The LLM streams a tool call; the argument deltas are buffered until complete. Partial JSON never executes.
- 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.
- Independent calls run in parallel; results are appended to the context in the order the LLM issued them.
- 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-toolsis 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:
- 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. - Cross-field checks (all reported together, in file order), see below.
- Secrets. Each
api_key_envnames an environment variable; its value is read into abrisvox_core::Secret<String>(Debug/Displayprint[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 intoapi_key_envby 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:
| finding | level |
|---|---|
an environment variable named by api_key_env / token_env is not set | warning; 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 inside | error |
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 exist | warning (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) andnull/~/nothing are typed; every other plain scalar and every quoted or block (|,>) scalar is a string.yes/no/on/offare strings, soenabled: yesis an error (invalid type: string "yes", expected a boolean), exactly asenabled = "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 emptybackchannels:keeps the preset's phrase sets ([]turns them off). A required value left empty (voice:) isinvalid 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 thebrisvox-serverfeatureyaml, on by default. A build with--no-default-featuresreads 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)
| key | type | notes |
|---|---|---|
name | string | 1-64 of A-Z a-z 0-9 _ - |
language | string | en or tr, region allowed (en-US, tr-TR). Selects the segmenter and speech normalizer; default STT and TTS language |
system_prompt | string | non-empty, at most 100 000 bytes |
tools | array of strings | tool 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_tools | array of strings | optional: 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]
| key | type | default | notes |
|---|---|---|---|
policy | "adaptive" / "fixed_silence" | adaptive | Smart Turn, or a fixed silence |
silence_ms | 1..=10000 | fixed_silence only, required there | |
fallback_silence_ms | 100..=30000 | 3000 | adaptive only |
require_final | bool | false | adaptive only |
final_grace_ms | 0..=10000 | 800 | adaptive only |
final_wait_ms | 1..=30000 | 1500 | must stay below the STT finalize timeout (3000) |
response_deadline_ms | 1000..=300000 | 20000 | must 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).
| key | type | default | environment variable it overrides |
|---|---|---|---|
transcript | bool | true | BRISVOX_RECORDING_TRANSCRIPTS |
audio | bool | false | BRISVOX_RECORDING_AUDIO (audio is never redacted) |
redact | bool | true | BRISVOX_RECORDING_REDACT_PII |
redact_kinds | list of email, iban, id, card, phone, number | all | BRISVOX_RECORDING_REDACT_KINDS; not empty while redact is on, and not combined with redact = false |
retention_days | integer 1..=3650 | 30 | BRISVOX_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.
| key | type | notes |
|---|---|---|
destinations | array of tables | where 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 |
dtmf | bool | offer 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) |
warm | bool | offer 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 |
voicemail | table | answering-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.
| key | type | notes |
|---|---|---|
message | string | required; 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_unknown | string | treat_as_human (default), treat_as_machine (wait for the greeting to end by a fixed time, then leave the message) or hang_up |
detect_only | bool | the 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.
[agent_link] (optional)
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.
| stage | key | notes |
|---|---|---|
| stt | provider | deepgram, assemblyai, gladia, sherpa_onnx, openai_realtime_stt |
| stt | api_key_env | required 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) |
| stt | language | default agent.language |
| llm | provider | openai, anthropic, gemini, openai_compatible |
| llm | api_key_env | required except for openai_compatible (a local vLLM/Ollama has no key; set it for a hosted service such as OpenRouter or Groq) |
| llm | max_tool_rounds | 1..=32, default 6 |
| llm | context_window_tokens | 256..=2000000, default 4000 |
| tts | provider | cartesia, elevenlabs, deepgram_aura, openai_speech |
| tts | api_key_env | required except for openai_speech (a local Kokoro/Chatterbox server has no key) |
| tts | voice | provider voice id; for Aura the model string (aura-2-thalia-en); for openai_speech the server's voice (af_heart on Kokoro) |
| tts | sample_rate | rate the provider delivers, default 24000; the engine resamples to the call |
| tts | language | default 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], defaultws://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 beforeDone, 0 for the C++ server). The model decides the language. A token (api_key_env) is only allowed overwss://or to a loopback host.[stt.openai_realtime_stt](docs/providers/openai-realtime-stt.md):dialect(openai,nim,speaches,vllm; defaultopenai),base_url(default per dialect),path_and_query,model(required forspeachesandvllm; OpenAI defaultgpt-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), orbase_urlandmodel;max_tokens,temperature(0..=2),reasoning_effort(sent as the request field of that name, e.g.noneorlowfor a reasoning model that thinks by default),openrouter_order(OpenRouter provider slugs, sent asprovider.order) withopenrouter_allow_fallbacks(provider.allow_fallbacks;false= served by the listed provider or refused). Both are OpenRouter-only: setting either one withoutpreset = "openrouter"or abase_urlwhose host isopenrouter.ai(or a subdomain) is a configuration error (brisvox-server checkprints 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 withbase_urlandmodel. OpenRouter has the presetopenrouter(base_url = "https://openrouter.ai/api/v1", default modelopenai/gpt-6-luna):preset = "openrouter", optionallymodel = "<author>/<model>"(the id from OpenRouter's model page) andapi_key_envnaming the variable that holds your OpenRouter key; the full recipe is "OpenRouter" indocs/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 speakingPOST {base_url}/audio/speech, docs/providers/openai-speech.md):base_url(required, with the/v1prefix;http://to any host is accepted, but not together with anapi_key_env),preset(genericdefault,kokoro,chatterbox,speaches,vllm_omni; sets the model, the audio format and the request fields),model(required forgeneric),speed(0.25..=4),format(pcmorwav),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."forkokoro, 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: leavetts.sample_rateunset or set it to exactly24000(the example agents underexamples/agents/local/set it, which is the same thing written out); any other value is a load error, because only thespeachespreset can resample on the server. The engine resamples 24 kHz to the call's rate itself.examples/agents/local-kokoro.tomlis 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.
| Variable | Default | Meaning |
|---|---|---|
BRISVOX_WEBRTC_WS_PATH | /ws | Path 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_ADDR | unset (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_PORT | the listener's port | Port written into the TCP candidate when a proxy/port mapping in front of the server uses another (1 to 65535). |
BRISVOX_WEBRTC_TCP_MAX_CONNECTIONS | 256 | ICE-TCP connections served at once (1 to 65536); more are closed unanswered. |
BRISVOX_WEBRTC_TCP_MAX_PER_IP | 16 | ICE-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_TIMEOUT | 10s | How 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_FRAME | 2048 | Largest RFC 4571 frame in bytes (512 to 65535); a larger one closes the connection. |
BRISVOX_WEBRTC_TCP_QUEUE | 20 | Frames queued per connection towards the browser (8 to 4096, drop-oldest). A writer stuck longer than the write timeout closes the connection. |
BRISVOX_TURN_PROVIDER | unset (off) | coturn, cloudflare or twilio. Unset: the server offers no relay and GET /whip/ice returns {"iceServers":[]}. |
BRISVOX_TURN_TTL | 600 | Lifetime of issued credentials, seconds, 30 to 86400. Fetched credentials are cached for 60 s (at most half of it). |
BRISVOX_TURN_URLS | required for coturn | Comma-separated turn: / turns: URLs of your coturn server (up to 16). |
BRISVOX_TURN_SECRET | required for coturn | coturn's static-auth-secret (use-auth-secret mode). Never sent to browsers, never logged. |
BRISVOX_TURN_CLOUDFLARE_KEY_ID | required for cloudflare | The TURN key id (Cloudflare dashboard > Realtime > TURN). |
BRISVOX_TURN_CLOUDFLARE_API_TOKEN | required for cloudflare | The TURN key API token. |
BRISVOX_TURN_TWILIO_ACCOUNT_SID | required for twilio | The account SID (in the request path). |
BRISVOX_TURN_TWILIO_AUTH_TOKEN | required for twilio | The account auth token, or the API key secret when BRISVOX_TURN_TWILIO_API_KEY_SID is set. |
BRISVOX_TURN_TWILIO_API_KEY_SID | unset | Authenticate with this API key SID instead of the account SID. |
BRISVOX_WEBRTC_TURN_RELAY | unset (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_TIMEOUT | 3s | Longest 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:
| Variable | Meaning |
|---|---|
BRISVOX_SESSION_SECRET / BRISVOX_SESSION_SECRET_PREV | Secret (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_TOKEN | Static 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_LISTENERS | Lets those listeners bind a non-loopback address without any of the above (logged loudly), and serves /metrics without a token. |
BRISVOX_TRUSTED_PROXIES | CIDR list of reverse proxies whose forwarding header the WebRTC listener believes (never 0.0.0.0/0). |
BRISVOX_CLIENT_IP_HEADER | The one forwarding header believed behind them: x-forwarded-for (default), forwarded or x-real-ip (one address; Railway). |
BRISVOX_WEBRTC_HTTP_MAX_PER_IP | Signaling 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_MINUTE | WebRTC flood limits: calls per address, calls still handshaking, offers per address per minute. |
BRISVOX_METRICS_TOKEN | Bearer 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
| rule | reported 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 tr | agent.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 language | tts.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 English | tts.elevenlabs.model |
Turkish STT: Deepgram nova-3/nova-2/base, AssemblyAI *-pro models | the model key, or the language |
turn keys match the policy; silence_ms with fixed_silence | the key |
| early analysis needs the adaptive policy | early_analysis.enabled |
final_wait_ms < STT finalize timeout; response_deadline_ms > final_wait_ms | the key |
frame_ms whole samples at 16 kHz and call_sample_rate | audio.frame_ms |
filler.delay_ms not given with filler.per_tool = true | filler.delay_ms |
recording.retention_days 1 to 3650; recording.redact_kinds not empty and not given with recording.redact = false | the 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 machine | stt.* |
| tool names valid and unique; keyterm/vocabulary lists bounded | the entry |
llm.api_key_env present (except openai_compatible); compatible server has a preset or URL+model | llm.* |
| 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 secondary | the 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_yamlsuccessors (checked on crates.io 2026-10-13:yaml_serde0.10.7 by The YAML Organization, MIT OR Apache-2.0, last release 2026-08;serde_yaml_ng0.10.0, MIT, last release 2024-05;serde_norway0.9.42, last release 2024-12;serde_yamlitself 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 atagent.language) could not say where the value is. They also cannot fillSpanned. saphyr-parser0.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::yamlbuilds a small tree with byte ranges from them and runs the derivedDeserializeofAgentFileover it;Spanned<T>is filled throughserde_spanned::de(the same cratetoml::Spannedcomes from, already in the tree). So both formats produce the sameAgentFile, 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
| Piece | Location | Depends on |
|---|---|---|
Vocabulary (DtmfString, PhoneNumber, TransferRequest, AnsweredBy, TelephonyOps, errors) | brisvox_agent::telephony | core |
Tools transfer_call, send_dtmf, end_call; TelephonyPolicy | brisvox_agent::telephony::tools | the vocabulary only |
DTMF in: note, DtmfCollector | brisvox_agent::telephony::dtmf | core |
Voicemail: voicemail_flow, VoicemailDriver, voicemail_hint / looks_like_voicemail | brisvox_agent::telephony::voicemail | flows |
REST call control: CallControl, TwilioCallControl, TelnyxCallControl, AMD config and parsers, BoundCall | brisvox_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::telnyx | transport only |
| The binary's wiring: telephony tools, the live-call hub, warm-transfer and AMD callbacks, voicemail handling | brisvox_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
| Transport | How | Status |
|---|---|---|
| Telnyx | POST /calls/{id}/actions/send_dtmf with digits, duration_millis (default 250), command_id | works; 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 |
| Twilio | cannot 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 / loopback | no REST route | Unsupported |
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.
destinationis anenumof the names inTelephonyPolicy(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 (PhoneNumberdebug-prints+90******33). mode:cold(default) orwarm(only offered whenTelephonyPolicy::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;OutcomeUnknownkeeps it and tells the model not to repeat it. - The hand-off sentence is played first. The tool awaits
TelephonyOps::settle_speech()(bounded bysettle_timeout, default 8 s, on the injected clock), then asks for the transfer. The application implementssettle_speechfrom the engine's play-out marks (TransportEvent::MarkPlayed);BoundCall::with_settletakes the function. - The tool timeout covers the whole chain.
TelephonyPolicy::effective_transfer_timeout()issettle_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). Setprovider_budgettorequests * request_timeoutwhen the REST deadline is changed; an explicittransfer_timeoutis 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 Telnyxcommand_id; the engine's store replays the recorded result for a repeated call id. - Result:
{status: "initiated", mode, destination, summary_delivered, instruction}. Afterinitiateda 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/actiononly when configured). Twilio ends the call after the<Dial>; the dial outcome reaches only theactionURL of the user's HTTP app. - Telnyx:
POST .../actions/transferwithto, optionalfrom,timeout_secs,command_id. If refused with90045the binding issuesstreaming_stopand 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 = falsedisables the whole retry). A retry that fails after the stop succeeded isError::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 failingstreaming_stopitself 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_stopfor the end of the call.TelnyxConfig::on_stream_stopis a hook (StreamStopHook) called with the call right before the binding sendsstreaming_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 indocs/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"):
- 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 orconference_wait_url); the bot's stream ends (UNVERIFIED how, see 7). - Create a call to the destination (
From=caller_id, required) whose TwiML is only a silent<Pause>ofagent_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.StatusCallbackeventsinitiated ringing answered completedwhen configured, and AMD parameters whenagent_leg_amdis set (the sync verdict is readable withTwilioCallControl::call_statusonce the leg is answered,answered_by; withasync_amdit arrives at the callback URL). complete_warmis the accept gate. When the application has seen the agent leg answered by a person (AMD verdict, status callback),complete_warmupdates 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.abort_warm(no answer, declined, voicemail) ends the agent leg (Status=completed) and then resumes the caller throughResumeStream; it stops at the first error so a retry repeats it safely, and returns where the caller was left (Resumed, orParkedwithout 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 withStatus=canceled(the status Twilio documents for it) aftercompletedwas 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.- If step 2 fails definitively (4xx), the caller is put back on the bot through
ResumeStream(resume = warm_failed);Error::WarmFailedsays 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 reportsParkedand carries a recovery handle (WarmFailed::transfer: the conference, no agent leg) thatBoundCallkeeps forabort_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):
POST /conferenceswith the caller's leg (andhold_audio_url; without it the caller hears silence while waiting): needsdata.idin the response (the response shape is not on the fetched pages; a missing id isError::Protocol). A90045here 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 isWarmFailed { Parked }wrappingError::StreamStopped, without a recovery handle (no conference id is known): the caller's stream is gone and the application must act.POST /calls(dial) withlink_tothe caller's leg overconnection_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) thatBoundCallkeeps.- When the application sees
call.answeredfor the agent leg and the AMD verdict says a person, it callscomplete_warm, which joins the agent leg to the conference. Until then the agent leg is not in the conference. abort_warmhangs up the agent leg (if there is one) and reportsCallerState::Parkedalways: 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 anOk(())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.- The summary cannot be delivered (the
speak/playback_startcommands were not fetched): the report saysBriefing::NotSupportedand the tool resultsummary_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
Startedinfo (TransportSession::call_started); the tools bind to the carrier call the first time they act (Twiliocall_sid, Telnyxcall_control_id, chosen by thetransportmetadata). A call on a listener without a configured carrier (generic websocket, RTVI, SIP) has the same tools:transfer_callandsend_dtmfanswerUnsupported("not available on this call"),end_callends the call through the transport. So a definition's tool set does not depend on the listener. settle_speechfollows 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'ssettle_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 theend_callcall 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 silentend_callhangs up right away.send_dtmfis 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'sStreamResumer(a late-bound cell, filled when the listener binds) and the stream URL, the listener'sBRISVOX_TWILIO_PUBLIC_URLaswss://. The Twilio binding then has aResumeStream(resumeparameterdtmforwarm_failed),adapter_resumes_session = true(sosend_dtmfis offered) andon_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 astop, 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-incleardropping it. A stream of the samecallSidwhosestartcarries theresumeparameter 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; aresumestream 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 inBRISVOX_MAX_SESSIONS). - Telnyx.
on_stream_stopannounces thestreaming_stopto the Telnyx listener'sStreamStopper(Wiring the Telnyx stream stop, section 7): the close is not a hang-up, the session stays up (at mostSTOPPED_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 callsBoundCall::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_warmends 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,enortr); 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:
| carrier | carrier_call_id | from / to |
|---|---|---|
| Twilio | CallInfo.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) |
| Telnyx | call_control_id | start.from and start.to |
| SIP | the 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)
- Provider AMD when brisvox originates the call. Twilio:
MachineDetection=DetectMessageEnd(the mode for leaving a message;Enablereturns early withmachine_start), sync (the call waits for the verdict, about 4 s typical) orAsyncAmdwith a callback. Telnyx:answering_machine_detectiongreeting_end(ordetect_beep,premium), results as webhooks. Leaving a message needs the end of the greeting: only TwilioDetectMessageEndand Telnyxgreeting_end/premiumsend it (AmdConfig::twilio_provides_greeting_end(),telnyx_provides_greeting_end()). TwilioEnableand Telnyxdetectend atmachine_start/machine; with them useVoicemailPolicy::detect_only()(hang up on machines; withspeak_when_greeting_end_uncertaina fixedhint_waitof 6 s instead of waiting out the 30 s greeting timeout). A test feeds a Telnyxdetectevent to both policies.AmdConfigis the neutral configuration with documented ranges;twilio_params()andtelnyx_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 ontelnyx_fields). - Results to the session:
parse_twilio_amd_callback(form body)andparse_telnyx_amd_event(json body)return anAnsweredBy; Twilio synchronous AMD can instead putAnsweredByinto a stream<Parameter>(CallInfo.metadata["custom.answered_by"]) so no plumbing is needed in core. Telnyx detectionmachinemaps toMachineStart(greeting still playing); the greeting result (beep_detected/ended) maps toMachineEnd*. - In-engine hint
voicemail_hint(text, speech_duration)returns a strength:HintStrength::Phrasefor voicemail phrases in English and Turkish ("leave a message", "bip sesinden sonra", "mesaj bırakın", "sesli mesaj", ...) andHintStrength::Lengthfor a greeting longer than 3.5 s with more than 5 words (Telnyx's documented thresholds;looks_like_voicemailis the plain yes/no of the two). It is fed toVoicemailDriver::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
Unknownverdict settles as a machine if a hint of either strength came before it. - A
Humanverdict is overridden only by a phrase: when the phrase arrives withinVoicemailPolicy::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; TwilioDetectMessageEndanswershumanat once for a short greeting that goes on with "leave a message after the beep"). A length hint never changes aHumanverdict, 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.
- An
- Fallback
VoicemailPolicy::on_unknown(TreatAsHumanby default, as Telnyx advises fornot_sure;TreatAsMachine;HangUp) andspeak_when_greeting_end_uncertain.TreatAsMachine(anUnknownverdict or the classify timeout) does not speak at once: anUnknownusually is a detection timeout, so the greeting may still be playing. Like the hint path it waitshint_wait(6 s, ends early on aMachineEnd*verdict), then speaks, or hangs up whenspeak_when_greeting_end_uncertainis off. - 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:
- Stream parameter. Twilio's synchronous
MachineDetectionanswers 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 TwilioAnsweredByvalue). It is read at the call's start (CallInfo.metadata["custom.answered_by"]). - Callback.
POST /v1/amd/twilio(AsyncAmdStatusCallback:CallSid,AnsweredBy) orPOST /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, answered202 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.
- Twilio: effect of a REST update on an active
<Connect><Stream>(order ofstop, 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 handlesstop, 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. UpdateTwimllength limit: the 4000 characters of the Create page are applied. - Twilio:
<Play digits>tone duration and level at the far end; error codes when updating an ended call (a 404 is mapped toCallGone, anything else isRejectedwith the code);AnsweredByfield name in the sync-AMD webhook; whether async AMD may run beside a bidirectional stream; AMD in Turkey. - Telnyx: which commands
90045blocks while streaming; effect oftransferon the stream (stopframe?); the response shape ofPOST /conferencesandPOST /calls(data.id,data.call_control_idare assumed and validated);command_idaccepted by Dial;hold_audio_urlbehavior for a lone participant; premium AMD payload schemas. - Both: rate limits, billing of parent and child legs, REST error body shapes beyond the
documented samples (both Twilio
code/messageanderror_code/error_messageare 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_SECRETmakes 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=1is 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 itsexp(at most 1 h; 120 s for URLs the server mints). Migrating from the fixed URL: see "Migrating from the fixed URL" indocs/providers/twilio.md.start.customParametersare not signed; do not put authority in them. - Telnyx. Telnyx signs nothing. The path and the optional
stream_auth_tokenare the only checks, so a Telnyx listener bound to a non-loopback address refuses to start withoutBRISVOX_TELNYX_STREAM_TOKEN(set the same value asstream_auth_tokenin the stream request);BRISVOX_INSECURE_LISTENERS=1overrides 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
Authorizationprotects 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_ADDRwith a certificate and key, andBRISVOX_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/orBRISVOX_SIP_AUTH_USERwithBRISVOX_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) inagent.confirm_toolsso 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, thenBRISVOX_SIP_TLS_MAX_CONNECTIONS(1024) andBRISVOX_SIP_TLS_MAX_PER_IP(32), thenBRISVOX_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 issip:brisvox@ip:5061;transport=tls(sips:when the INVITE's Request-URI wassips:), 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 forBRISVOX_SIP_TLS_IDLE_TIMEOUTseconds (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 anAsyncRead/AsyncWriteadapter 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_80andAES_CM_128_HMAC_SHA1_32(RFC 3711), keys exchanged in the SDP. The first usablea=cryptoline 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 fromring, 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 (zeroizefeatures ofaesandctr), 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 thanBRISVOX_SIP_SRTP_AUTH_FAIL_LIMIT(default 50,0= off) ends the call with a log line andSipStats::srtp_flood_hangups(a_32tag 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 independentwebrtc-srtpcrate in both directions across the sequence-number wrap. - Policy.
off: plain RTP only; anRTP/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 butoffwithout 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 aVia(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_32tag is the prefix of the_80tag, so a receiver started over on the same key would take every earlier_80packet 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.toofsips:...or...;transport=tlsis called over TLS, the callee's certificate verified againstBRISVOX_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). Withoptionalorrequireda TLS call offersRTP/SAVPwithAES_CM_128_HMAC_SHA1_80; an answer that is not SRTP with the offered tag and suite ends the call (BYE,DialError::BadAnswer).requiredrefuses a non-TLS target before anything is sent. WithDialRequest::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 followsBRISVOX_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.
rsipstacklogs every SIP message atinfo, which on a secure call includes the SDP and with it the SRTP master keys. The server binary therefore holds the targetrsipstackatwarnwhateverRUST_LOGsays (like the TURN client crates), andDEFAULT_FILTERnames it too.BRISVOX_LOG_SIP_STACK=1lifts that for debugging only. A host that installs its own subscriber must applybrisvox_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 writeVia: SIP/2.0/TLSfor requests to TLS callees); it is expected. Brisvox's own events,Debugoutput and errors never contain key material (testkey_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):
| Peer | What its documentation says | Source |
|---|---|---|
| Twilio Elastic SIP Trunking | TLS 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 rejected | https://www.twilio.com/docs/sip-trunking (Secure Trunking) |
| Telnyx | TLS 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 fallback | https://issues.asterisk.org/jira/browse/ASTERISK-26190, https://community.asterisk.org/t/sdes-support-in-asterisk-18-and-pjsip/107792 |
| FreeSWITCH | rtp_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 |
| Vonage | not checked | |
| RFC 4568 (SDES), RFC 3711 (SRTP) | `a=crypto: |
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/AVPwitha=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
.mdrendition of that page (.../websocket-messages.md), where the plain page fetch would only summarize them. Values likeMZXXXX...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 theX-Twilio-Signatureheader 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.
- There is no API key for the stream. Twilio connects to the
- Endpoint(s) / regions:
- Brisvox hosts a
wss://endpoint.wssis 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 isUS1(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).
- Brisvox hosts a
- 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):
Brisvox example (derived, not a doc sample):<Response> <Start> <Stream url="wss://mystream.ngrok.io/example"> <Parameter name="FirstName" value="Jane" /> <Parameter name="LastName" value="Doe" /> </Stream> </Start> </Response><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_trackdefault |outbound_track|both_tracks; unidirectional only, a bidirectional stream "can receive onlyinbound_track"),statusCallback(absolute URL),statusCallbackMethod(GET|POST, defaultPOST).<Parameter name= value=>: the combined length of each<Parameter>nameandvaluemust be under 500 characters (stream TwiML page). No maximum parameter count is stated (UNVERIFIED). Values arrive instart.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 anactionURL). The Stream resource (REST) cannot start a bidirectional stream.
- With custom parameters (docs example is for
- Protocol (messages in/out, with field names). All frames are JSON text frames (binary frames are not
mentioned: UNVERIFIED).
- Twilio -> server, in order:
connected, thenstart, thenmedia(repeating) and optionallydtmf/mark, finallystop.{ "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, example1.0.0). First message.start: sent once, immediately afterconnected.start.tracksarray ofinbound/outbound;start.mediaFormat.encodingis alwaysaudio/x-mulaw,sampleRatealways8000,channelsalways1;start.customParameters= the<Parameter>s;streamSid(also repeated at top level of every message).sequenceNumberstarts at 1 and increases per message.media.track:inboundoroutbound(one of the page'smediaexamples 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.chunkandmedia.timestampare JSON STRINGS ("1","2","5"), whilesampleRateandchannelsare 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.trackis alwaysinbound_track(note: differs from theinboundvalue used inmedia.track),dtmf.digitthe 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 themark.namethe 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>). Onlymedia,markandclearare accepted (errors 31951/31924); anything else, bad JSON, missing or unexpected fields or astreamSidthat 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" } }
Use the{ "event": "clear", "streamSid": "MZXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" }streamSidfrom thestartmessage (31951 page: never reuse a SID from another call or stream).
- Twilio -> server, in order:
- Outbound audio format and chunking:
media.payloadmust be (messages page) "encodedaudio/x-mulawwith a sample rate of8000and 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-encodedaudio/x-mulawat 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 tochunk"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
mediafaster than it plays, nomarktracking. Docs' remedies: pace outbound media, send amarkafter eachmediamessage and wait for the matchingmarkevent before sending large amounts of more audio, andclearto 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
markafter themediait wants to track. Twilio returns amarkevent with the samemark.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 anymarkmessages to be sent back to your WebSocket server", i.e. Twilio sends back amarkfor 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 sentclearand treat any mark arriving after its ownclear(until the next audio it sent after the clear) as "dropped" rather than "played". Order and timing of those returned marks relative to theclearare 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
markafter 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 ownclear) advance the confirmed-played position; at barge-in the played position isconfirmed_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.
- Server sends
- 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) sendclearthe moment the turn epoch advances (engine rule "Clear-on-epoch-advance"), before any new-epoch audio; (2) stop sending old epochmedia; (3) media sent after theclearplays 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
cleararrives 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-basicanddialogflow-integrationare named there.
- Send
- 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
startafter upgrade, and a stall timer on inboundmedia(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-Signatureheader" (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-Signatureheader. 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 barewss://host/pathas written in the TwiML. Whether Twilio signs the URL withwss://orhttps://scheme, and whether the port:443is 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/httpsscheme) 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/PHPRequestValidator; RubyTwilio::Security::RequestValidator; Goclient.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 (RustCryptohmac+sha1, check crates.io for versions/licenses before adding; not researched here).
- Media Streams page: "validate the
- Timestamps / interim results / tool-call deltas: not applicable. Relevant timing data: inbound
media.timestamp(ms since stream start, string) andchunkcounter (gaps = lost inbound audio, 31930).sequenceNumberalso counts every message (gaps detectable). Outboundmediahas 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):
statusCallbackon<Stream>gets an HTTP request (methodstatusCallbackMethod, default POST) withAccountSid,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 outputpcm_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(onlytrack == "inbound") -> G.711 mu-law bytes -> decode viabrisvox_audio::codec-> PCM16 8 kHz -> resample (rubato) to the pipeline rate. Drop messages whosestreamSiddiffers fromstart.streamSid. Handledtmf-> DTMF event. Handlestop-> end of session. Ignore unknowneventvalues (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 reusableString/Vecbuffer for base64 and the JSON frame (hot-path no-allocation rule). - Barge-in ->
clear; marks -> playback position for played-text truncation (see Mark semantics).
- Inbound: base64-decode
- 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
- 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 aswss://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, ..) } } - 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, isexamples/phone-receptionist/twiml_server.py, setBRISVOX_TWILIO_STREAM_SECRETon both sides). The TwiML has this shape:
Legacy (static TwiML Bin, needs<?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>BRISVOX_TWILIO_ALLOW_STATIC_URL=1on the server, replayable signature): the same TwiML withurl="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 withTwilioConfig::public_urland must not contain a query string (use<Parameter>). The values arrive inCallInfo.metadataascustom.<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 message | Brisvox |
|---|---|
connected | remembered (twilio_protocol, twilio_version metadata); optional |
start | CallInfo (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 |
dtmf | WireEvent::Dtmf (single key 0-9 * # A-D; else a fault) |
mark | e<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 frame | hang-up (StopReason::PeerHangup); a vanished socket is PeerDisconnected |
unknown event | ignored |
| malformed JSON, bad base64, missing fields | non-fatal fault (TransportEvent::Error); more than 16 per call close it (1008) |
| Engine | Twilio |
|---|---|
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 |
Hangup | close 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
markboth when audio played and when aclearflushed 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 sendsclear. 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 asMarkClearedat 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) andmax_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 asWireEvent::Lead, forwarded asTransportEvent::PlaybackLead { lead }(a level; a new report replaces a queued one). For a barge-in in the middle of an utterance the heard position isPlaybackPosition.played - PlaybackLead.lead, never below zero; it replaces the fixedlead_marginguess 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-enginestores it (PlayoutBook::set_client_lead) andPlayoutTruncationreads it:heard = min(released, total_audio) - lead, where a reported lead REPLACES the fixedlead_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 aClearcovered 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 ismax(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_hookmust 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) andconnected.protocol/versionare read softly: any other type ("1.0","", a float, an object) reads as absent and never costs the message that carries it. Aconnectedmessage that cannot be read is skipped.sampleRateandchannelsare checked, so a malformed one is a protocol error onstart.customParametersvalues that are not strings are kept as JSON text. - Binary frames from Twilio: ignored. Unknown events and fields: ignored. A missing
connected: accepted. A missingtrackonmedia: treated as inbound. - Keepalive: Twilio is not documented to ping or answer pings. The server pings every
ping_intervaland closes afterping_timeoutwithout any frame; inboundmedia(every 20 ms while the call is up, UNVERIFIED) counts as life, so setping_timeoutwell 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_byteschanges it. - Inbound audio loss: skipped
media.chunknumbers are counted (inbound_chunks_lost), not reported to the engine. - A
clearwhile 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'sCallbackfor the upgrade returns the full HTTP response as the error (clippyresult_large_err); the adapter implementsCallbackon 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);TwilioServerwraps aWebSocketServer, so accept, shutdown and the active-call count are the same code. - A
clearmust 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/clearfrom 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)
- Exact URL form Twilio signs for the WSS upgrade (scheme, port, trailing slash) and that header name casing is
X-Twilio-Signature. - Inbound
mediacadence and frame size (ms per message); behaviour under backpressure (31930). - Residual audio heard after
clear(ms), order/timing of the marks returned byclear, and whether a mark for the audio currently playing is returned immediately. - Whether media sent right after
clearplays immediately and whether mark names may repeat or have a length limit. - 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).
- Whether numeric fields are ever sent as JSON numbers; whether binary frames ever occur.
- Whether
<Parameter>inside<Connect><Stream>works exactly as in<Start><Stream>(expected yes). - Playback latency Twilio adds between our
mediaand the caller's ear (feeds the LATENCY.md budget). - Whether Twilio plays audio it buffered after brisvox closed the WebSocket (decides whether the "wait for the final
MarkPlayedbeforeStop" rule is needed or merely safe), and whether a bounded drain in the adapter is worth adding. - Whether
PlaybackPosition - PlaybackLeadtracks what the caller heard (compare with the barge-in point reported by a test caller) and how stalePlaybackLeadis 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-streamsnode/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>onPOST 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: thestream_auth_tokenfield ofstreaming_startand ofdial(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 headerx-telnyx-streaming-auth-tokenAND in the first frame asconnected.x-telnyx-streaming-auth-token(spec: "When a streaming auth token is configured, Telnyx includes it in theconnected.x-telnyx-streaming-auth-tokenfield and sends it as thex-telnyx-streaming-auth-tokenWebSocket header"). Brisvox should compare it in constant time and refuse the upgrade (HTTP 401/403) on mismatch. Never log it. stream_auth_tokenis listed on the Call Controlstreaming_startreference and on thedial(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 theanswerrequest 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 withansweror TeXML you cannot set it; start the stream withstreaming_startafter 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 instart.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-levelcustom_parameterswas not found onansweror in the part ofdialthat was read (UNVERIFIED there; it exists onstreaming_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-ed25519andtelnyx-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 towebhook-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.
- Starting a stream (REST):
- Endpoint(s) / regions:
- Brisvox hosts a
wss://endpoint (the spec's server iswss, host/path chosen by the customer, address/). TLS certificate requirements, supported TLS versions, accepted ports and whetherws://is accepted: NOT stated (UNVERIFIED; use a publicly trusted certificate on 443). Query strings instream_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.
- Brisvox hosts a
- How a call is set up to stream. Four ways (all documented):
- Call Control
dial(POST /v2/calls) withstream_url,stream_track, optionallystream_bidirectional_mode,stream_bidirectional_codec(guide's examples). Thedialreference also listsstream_codec,stream_bidirectional_target_legs,stream_bidirectional_sampling_rate,stream_auth_tokenandstream_establish_before_call_originate(boolean, default false; meaning not read: UNVERIFIED). - Call Control
answer(POST /v2/calls/{call_control_id}/actions/answer) with the samestream_*fields (answer reference listsstream_url,stream_track,stream_codec,stream_bidirectional_mode,stream_bidirectional_codec,stream_bidirectional_target_legs; it does NOT liststream_bidirectional_sampling_rateorstream_auth_token, whole page read). Settingstream_urlalso triggersstreaming.started/streaming.stopped/streaming.failedwebhooks (streaming.failedis not named in the pages re-read on 2026-10-08; the guide and streaming-stop page namestreaming.startedandstreaming.stopped:streaming.failedUNVERIFIED). - Call Control
streaming_starton a live call (POST https://api.telnyx.com/v2/calls/{call_control_id}/actions/streaming_start). - 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 hasPOST /v2/texml/Accounts/{account_sid}/Calls/{call_sid}/Streams.json(form-encodedUrl,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_startreference 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_startreference):
The same fields exist as top-levelPOST 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>" }stream_*fields ondial(withstream_auth_token) and, exceptstream_bidirectional_sampling_rate/stream_auth_token, onanswer. 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_startparameters (exact, reference page; no field is marked required):stream_url(string, e.g.wss://www.example.com/websocket);stream_track(inbound_trackdefault |outbound_track|both_tracks);stream_codec(PCMU|PCMA|G722|OPUS|AMR-WB|L16|default, defaultdefault; "When set to 'default' or when transcoding is not possible, the codec from the call will be used");stream_bidirectional_mode(mp3DEFAULT |rtp);stream_bidirectional_codec(PCMUdefault |PCMA|G722|OPUS|AMR-WB|L16; used only with modertp; case sensitive);stream_bidirectional_target_legs(both|self|opposite, defaultopposite);stream_bidirectional_sampling_rate(integer8000default |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/defaulterrors[]withcode,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(mp3default|rtp),bidirectionalCodec(PCMUdefault|PCMA|G722|OPUS|AMR-WB),bidirectionalSamplingRate(8000default|16000|24000),statusCallback,statusCallbackMethod(GET|POST, defaultPOST),enableReconnect(defaulttrue). 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 astatusCallback+<Parameter>variant. Docs discrepancy: Call Control allows rates22050/48000, TeXML only8000/16000/24000, and the TeXML<Stream>codec list has noL16; use Call Control for L16.
- Call Control
- 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), notstreamSid.- Telnyx -> server, in order (spec "Connection Flow"):
connected,start, thenmedia(repeating) and optionallydtmf/mark/error, finallystop.
With a configured token:{ "event": "connected", "version": "1.0.0" }{ "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: requiredevent,version(string, protocol version, example1.0.0); optionalconnectedobject with optionalx-telnyx-streaming-auth-token. NOTE:connectedhas nosequence_number/stream_id.start: requiredevent,sequence_number,stream_id(UUID),start. Required insidestart:user_id(UUID),call_control_id,media_format. Optional:call_session_id(UUID),from,to("Caller number or SIP URI when available" forfrom; "Callee number or SIP URI when available" forto),tags(strings),client_state(base64 string),custom_parameters(string -> string).media_format.encodingenumPCMU|PCMA|G722|OPUS|AMR-WB|L16;sample_rateinteger enum8000|16000;channelsinteger, constant1. The spec has notracklist instart(Twilio hastracks).media: requiredevent,sequence_number,stream_id,media{track, chunk, timestamp, payload}.trackenuminbound|outbound("Call leg track that produced the media frame");chunkstring, "Per-stream media chunk number. Use to reorder media frames if needed.";timestampstring, "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);payloadbase64 "RTP payload without RTP headers".sequence_number: string, "Monotonic sequence number for frames sent by Telnyx on this WebSocket connection."connectedanderrorframes carry none.- TYPES:
sequence_number,chunk,timestampare JSON STRINGS in schema and examples;sample_rate,channelsand errorcodeare numbers. Parse strings; accept numbers too, defensively (as for Twilio). dtmf: requiredevent,sequence_number,stream_id,dtmf.digit; optionaloccurred_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. Notrackfield (Twilio hasdtmf.track).stop: sent "when Telnyx stops media streaming for the call" (spec). The guide says it is sent when the call ends, after thestreaming.stoppedwebhook; whether it is also sent when the stream is stopped via REST while the call stays up: not stated in the pages (UNVERIFIED). Carriesstop.user_idandstop.call_control_id.error: requiredevent,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.timestampandsequence_number. This is a real difference from Twilio: brisvox needs a small bounded reorder buffer keyed onmedia.chunk(and tolerate gaps), and DTMF should be ordered byoccurred_at. How far out of order frames get, and the reorder window to use: UNVERIFIED (measure live). Whether thestartframe can arrive after amediaframe: not stated. - Server -> Telnyx (bidirectional; client schemas also have
additionalProperties: false):{ "event": "media", "media": { "payload": "eW91ciBiYXNlNjQgZW5jb2RlZCBhdWRpbyBwYXlsb2Fk" } }{ "event": "mark", "mark": { "name": "some_mark_name" } }
These carry NO{ "event": "clear" }stream_id(Twilio requiresstreamSidon every message). Because the schemas forbid extra properties, sending astream_idmay be rejected asmalformed_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 storesstream_idbut never sends it), which is consistent. There is no server->Telnyxstop, and no documented way to send DTMF or an error to Telnyx over the socket.
- Telnyx -> server, in order (spec "Connection Flow"):
- Outbound audio format and chunking:
- Bidirectional mode
rtp: payload = base64 of the RTP payload (no RTP header) in the codec chosen bystream_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; thestart.media_formattells 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
mediaframes (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_modeis not set):media.payloadis a base64 MP3 FILE, queued and played in order. Not suitable for streaming TTS: always setstream_bidirectional_modetortp. 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 showsrtpexplicitly.) 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 ofoppositefor a single-leg inbound call is not explained in the pages read: UNVERIFIED. Test whether the caller hears the audio with defaults, else setself.- Tracks:
stream_trackselectsinbound_track(caller audio; default),outbound_track(audio Telnyx plays to the caller, i.e. includes brisvox's own bidirectional audio, UNVERIFIED), orboth_tracks. For the agent useinbound_trackonly;media.trackisinboundfor that. Drop frames whosetrackis notinbound. - Buffer capacity (Twilio: 10 minutes): not documented for Telnyx (UNVERIFIED). Whether brisvox may send ahead of real
time and rely on
clear: implied by theclear/marktext below but the queue size limit is unknown.
- Bidirectional mode
- Mark semantics (guide + spec):
- Server sends
markafter themediato 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 sentclearand drop the echoes (exactly as the Twilio adapter forgets in-flight marks before sendingclear). 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
rtpmode. - 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".
- Server sends
- Interruption (barge-in): send
{"event":"clear"}the moment the turn epoch advances, before any new-epoch audio; stop sending old-epochmedia; drop mark echoes that follow the clear (they refer to cleared audio). Whether media sent right afterclearplays 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
startafter upgrade, stall timer on inboundmedia, ping/pong from our side as in the generic WebSocket server). Inboundmediacadence and frame duration: not documented (UNVERIFIED; the spec says amediaframe carries one RTP payload, plausibly 20 ms). - Reconnect: TeXML
<Stream enableReconnect>(defaulttrue): "Whether the platform should automatically attempt to reconnect the WebSocket stream if disconnected." The TeXML page suggestsenableReconnect="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 freshconnected/startfor the same call; for the engine this means the samecall_control_idcould reach the server twice (inference, UNVERIFIED). Brisvox must (a) recommendenableReconnect="false"for<Stream>, (b) key sessions bycall_control_idand refuse/close a second connection for a live or ended call. Whether Call Control streams have an equivalent setting (none instreaming_start) and whetherstream_idstays the same after a reconnect: UNVERIFIED. streaming.failedwebhook (and TeXMLstream-failed) = "Stream failed to start or was interrupted". The TeXMLstream-failedwording ("failed to start or was interrupted") is confirmed on the TeXML stream page; the Call Controlstreaming.failedwebhook 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.
- No ping interval, idle timeout, maximum stream duration or close codes are documented (UNVERIFIED). Brisvox applies its
own timeouts (time to first
- Ending the call / stopping the stream (differs from Twilio):
- There is no server->Telnyx
stopmessage and (unlike Twilio) closing the socket is not documented as a hang-up. To hang up, call RESTPOST /v2/calls/{call_control_id}/actions/hangup(body optional fieldsclient_state,command_id,custom_headers(SIP BYE headers); 200{"data":{"result":"ok"}}; 422 codes90015invalid call control ID,90018call already ended; webhookscall.hangup,call.recording.saved). Pipecat's Telnyx serializer does exactly this (auto_hang_updefault on, needscall_control_idand API key) on itsEndFrame/CancelFrame. - To stop only the stream:
POST /v2/calls/{call_control_id}/actions/streaming_stopwith optionalstream_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 thestreaming.stoppedwebhook; 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
Hangupneeds a REST client (API key +call_control_idfromstart), 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. aHanguphook 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.
- There is no server->Telnyx
- Webhooks around the stream (Call Control; field names from the guide as key lists):
streaming.startedandstreaming.stoppedhave top-levelevent_type,id,occurred_at,payload,record_type;payloadhascall_control_id,stream_url(the top-level key list was not re-confirmed on 2026-10-08, only thepayloadkeys: UNVERIFIED).call.answeredcarriesstream_url,stream_track(not re-confirmed: UNVERIFIED). TeXMLstatusCallbackpayload 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.timestampis an RTP timestamp string (not ms),chunkcounter (gaps = lost or reordered inbound audio),sequence_numbercounts every Telnyx-sent frame; outboundmediahas 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.)
| Topic | Twilio Media Streams | Telnyx Media Streaming |
|---|---|---|
| Start of call | TwiML <Connect><Stream url> | Call Control dial/answer/streaming_start with stream_url; or TeXML <Connect><Stream> / <Start><Stream> |
| Bidirectional enable | implicit with <Connect> | explicit stream_bidirectional_mode: rtp (default is mp3) |
| Key naming | camelCase (streamSid, callSid, mediaFormat, sequenceNumber) | snake_case (stream_id, call_control_id, media_format, sequence_number) |
| Stream id | streamSid (MZ...), required in every server message | stream_id UUID in Telnyx frames; NOT sent by the server |
connected | has protocol, version | has version only (+ optional token) |
start | streamSid, callSid, accountSid, tracks, mediaFormat, customParameters | stream_id, start{user_id, call_control_id, call_session_id, from, to, tags, client_state, custom_parameters, media_format} |
| Codec | always audio/x-mulaw 8000 mono | PCMU/PCMA/G722/OPUS/AMR-WB/L16; 8000 or 16000 in start; bidirectional codec set separately (default PCMU) |
media.timestamp | ms since stream start | "RTP timestamp" (unit unverified) |
| Frame order | in order | NOT guaranteed; reorder by chunk |
| DTMF | dtmf.track, dtmf.digit | dtmf.digit, occurred_at (no track) |
| Mark on empty queue | implied | explicit: returned immediately |
| Clear | empties buffer, returns marks | "immediately stop[s] the media playing" and clears queue, returns marks |
| Chunk size | any | 20 ms to 30 s (RTP) |
| Send-rate rule | none stated (10-minute buffer cap) | "once per second" for media-file (MP3) payloads per the guide; RTP scope unverified; rate_limit_reached |
| Errors to server | none; only logs | error frame codes 100002..100005 |
| Hang-up | close the WS | REST hangup (WS close not documented as hang-up) |
stop from server | not accepted | not accepted |
| Request authenticity | X-Twilio-Signature HMAC-SHA1 over URL | none on WS; optional stream_auth_token header/field (streaming_start/dial only); webhooks signed (telnyx-signature-ed25519) |
| Reconnect | none documented | TeXML 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; metadatatransport=telnyx,stream_id,call_control_id,call_session_id,user_id,from,to,tags,client_state,encoding,sample_rate,custom.<name>),media(trackinbound) -> reorder buffer (bychunk) -> decode bystart.media_format.encoding: PCMU/PCMA viabrisvox_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 instream_codec) -> PCM16AudioFrame.dtmf-> DTMF event (ordered byoccurred_at),mark->MarkAckor drop (post-clear),stop-> hang-up (PeerHangup),error-> fault (code in message), unknowneventignored. Drop frames whosestream_iddiffers fromstart. - Outbound: PCM16 at the stream rate -> encode in the SAME codec set as
stream_bidirectional_codec(document that the host must setstream_bidirectional_codecequal to the inbound encoding to avoid transcoding; start-time config is the host's job) -> base64 ->{"event":"media","media":{"payload":..}}; markse<n>/p<n>as in the Twilio adapter;clearper rule above. The adapter must know the outbound codec: it is NOT announced instart(that is the inboundmedia_format). Make it aTelnyxConfigfield (default PCMU 8 kHz) and assert it equalsstart.media_formatwhen inbound==outbound is expected. - Auth: constant-time compare of
x-telnyx-streaming-auth-token(upgrade header; fall back toconnected.*) 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 viastreaming_startordial, notanswer/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_reorderedcounts 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 bystartopen 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 atransfer/conference command was refused with90045) must not look like the caller hanging up. Telnyx then closes the socket; whether it sendsstopfirst 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 cloneableStreamStopper;expect_stop(call_control_id)is called BEFOREstreaming_stopand returns whether the call streams here. ForSTOP_EXPECTED_WITHIN(30 s; a conservative window that covers the REST call plus the close, and makes a forgotten announcement harmless) astopframe, 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 afterSTOPPED_LINGER(120 s, conservative upper bound for a retried transfer; the engine then sees the peer disconnect). Intended caller: the call-control binding's90045retry path, which must callexpect_stopbefore it issuesstreaming_stop(the API is additive; the binding itself is a separate change). max_control_bytes(default 16 KiB) bounds control messages only. Amediaframe is bounded bymax_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 notmedia, or does not parse, is still bounded bymax_control_bytes.
- A stream the HOST stops on purpose (
- 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 byoccurred_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 aHangupHook), 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)
- Exact header casing of
x-telnyx-streaming-auth-tokenon the upgrade; whetheranswer/TeXML have an undocumented way to set it (docs: onlystreaming_startanddialliststream_auth_token). - Whether
mediaframes inrtpmode are subject to the "once per second" rule (expected not; the guide puts the rule under media-file sending), and the behavior on 100005. - How out-of-order inbound frames really are (window size for the reorder buffer); unit of
media.timestamp;mediacadence. - L16 byte order and exact sample format; effect of
stream_bidirectional_sampling_ratevs codec (PCMU at 16000?) and which sample ratestart.media_formatreports for each combination; whether 24000/48000 work with L16/OPUS. - Meaning of
stream_bidirectional_target_legsfor a single-leg inbound call (doesoppositereach the caller?). - Residual audio after
clear; whether marks echoed bycleararrive before or after other frames; whether media afterclearplays at once. - Whether closing the WS ends the call, stops the stream, or triggers reconnect (and
enableReconnectbehavior); close codes; keepalive. - Whether a
stream_idin server frames is rejected (malformed_frame) or ignored; whether extra properties are tolerated. - Whether Telnyx plays queued audio after the server closes the socket;
errorframe effect on the connection. - Latency Telnyx adds between our
mediaand the caller's ear (feeds the LATENCY.md budget). - 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.jsonfile.
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-Tokenheader), 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.
| Stack | Latest (crates.io) | License | Last release / push | Popularity | Scope |
|---|---|---|---|---|---|
| rsipstack (restsend) | 0.7.3 (2026-10-08) | MIT | releases 0.7.0..0.7.3 on 2026-10-06/07/08, 0.6.13 on 2026-09-29; push 2026-10-08 | 212 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.0 | push 2026-05-08 (5 months old at research time) | 3 stars, 3 forks, 1 open issue, 261 downloads | Full 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.12 | MIT | releases 0.3.6 (2026-08-03) .. 0.3.12 (2026-10-01), push 2026-10-08 | 175 stars, 28 forks, 79 open issues; rvoip 8.8k downloads, rvoip-sip 1.4k | Large workspace (46 crates): SIP, RTP, WebRTC, voice-AI helpers. |
ezk (kbalt): ezk-sip-core, ezk-sip-ua, ezk-rtc | ezk-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) | MIT | push 2026-09-27 | 50 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) | MIT | push 2026-08-06 | 5 stars, 5 forks, 11 open issues, 1.4k downloads | High-level softphone lib: SIP + RTP + codecs; decoded PCM via crossbeam channels. |
| wavekat-sip | 0.3.1 (2026-10-06) | Apache-2.0 | push 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.0 | push 2026-10-05 | 4 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-15 | 108 stars, 161k downloads | Parser/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 FFI | pjproject-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 2023 | 1.5k downloads | Note only, see below. |
| RTP-only crates | rtp-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)
| Feature | rsipstack 0.7.3 | rsiprtp 0.4.1 | rvoip 0.3.12 | ezk (sip-ua 0.9.1) | xphone 0.6.0 | wavekat-sip 0.3.1 |
|---|---|---|---|---|---|---|
| REGISTER (+digest, refresh) | yes (Registration, Credential); client example re-registers every max(expires,50) s | yes, digest incl. SHA-256 (RFC 7616), periodic refresh | yes (beta-qualified) | yes (Registration, RegistrarConfig) | yes, auto-reconnect and keepalive | yes, digest + keepalive |
| INVITE / ACK / BYE | yes (dialog layer: do_invite, server accept/reject/ringing, bye) | yes (CallManager) | yes | yes (OutboundCall, InboundCall, Call) | yes, in+out, 302 follow, early media 183 | yes (Caller, IncomingCall) |
| CANCEL | yes (cancel; hangup_with_headers = BYE if confirmed else CANCEL) | tested (tests/integration_advanced.rs covers CANCEL per tests.md); not named on docs.rs/README | yes (beta-qualified) | - | - | - |
| re-INVITE / UPDATE | yes (reinvite, update on dialogs) | yes (hold/resume via re-INVITE; UPDATE RFC 3311) | - | - | hold/resume via re-INVITE | hold/resume |
| PRACK (100rel) | yes | yes (RFC 3262) | - | - | - | - |
| Session timers RFC 4028 | - (no session-timer API in the README or dialog pages: UNVERIFIED) | yes (refresher + non-refresher; tick/next_deadline) | - | - | yes | yes |
| Digest auth (UAC) | yes ("built-in") | yes | yes (beta-qualified) | yes (ezk-sip-auth dependency) | yes | yes |
| UDP / TCP / TLS | UDP, TCP, TLS (rustls), WebSocket (TLS and WebSocket via default-on features rustls/websocket) | UDP, TCP, TLS (rustls); no SIP-over-WebSocket | UDP, 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, TLS | UDP; TLS behind tls feature (off by default) |
| SDP offer/answer | helper only: InviteOption.offer body; example parses with sdp-rs; no negotiation | yes (RFC 3264) | yes (negotiation) | yes (SDP-based media backend, ezk-rtc SdpSession) | yes (not documented in detail) | yes, Opus + G.711 only |
| RTP / RTCP | none 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/PCMA | none | yes (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-event | none (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 buffer | none | yes, adaptive (reorder, duplicate, late) | yes, listed under OS audio devices as developer preview | - | yes, built in | - |
| SRTP | none | SDES (RFC 4568); no DTLS-SRTP | SDES beta-qualified; DTLS developer preview | ezk-rtc has ICE/SRTP (depends on openssl) | SDES (AES_CM_128_HMAC_SHA1_80, UNVERIFIED re-check); no DTLS | - |
| NAT helpers | accept_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 exist | STUN, TURN, ICE-Lite | - |
| Async runtime | tokio (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-agnostic | tokio ("full" in examples); sans-IO status unstated | tokio ^1 (RtpReceiver wraps tokio mpsc); ezk-rtc SdpSession is sans-IO, tokio feature optional | mixed: listen().await for server mode, one dedicated std::thread per call media pipeline, crossbeam channels | async/await with tokio_util CancellationToken in examples; runtime unconfirmed |
| Sans-IO | no (tokio tasks, transport layer) | yes (transactions, dialogs, CallManager; time passed as now: std::time::Instant) | unstated | partly (SdpSession) | not mentioned | not mentioned |
| MSRV | UNVERIFIED | 1.88 | 1.91 | UNVERIFIED | 1.87 | UNVERIFIED |
| Production users | README 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 listed | none listed | none listed | README says it is "used in production alongside xbridge" (self-claim) | none listed |
| Interop evidence | not stated in README | only 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 mentioned | none stated | claims Telnyx, Twilio SIP, VoIP.ms, Vonage (trunks), Asterisk, FreeSWITCH, 3CX (PBXes) ("not a comprehensive compatibility matrix") | none stated |
| Examples | proxy, client (REGISTER, invite, answer, file playback, echo), bench_ua | basic_call (REGISTER+INVITE+BYE vs Asterisk), voicemail, ai_bridge (SIMULATED: no real SIP/RTP/WebSocket code, re-verified 2026-10-08), ice_call, session_timers | 01-quickstart-p2p..14-vapi-agent (14 examples); endpoint_local_call UNVERIFIED | examples/ (need a remote endpoint; not runnable as is) | examples/sipcli | Quick Start snippets only |
| Stated maturity | none | pre-1.0, "pin an exact version for production"; CONTRIBUTING: personal project, no commitment to triage/merge | SIP "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:
ClientInviteDialogandServerInviteDialogare marked deprecated in favour ofinvite_dialog::InviteDialog(docs.rs struct pages, latest; thedialogmodule index page itself shows no deprecation note). OnInviteDialog,hangup()delegates tohangup_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 thathangup()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) -> CallIddoes no DNS or socket I/O (resolve withSipResolver, RFC 3263),handle_incoming_invite(dialog, offer_sdp) -> Option<(CallId, SessionDescription, u16)>(theu16is undocumented),answer_call,reject_call,terminate_call(BYE; only forEstablishedcalls),handle_bye,tick(now)/next_deadline()for session timers,drain_events()anddrain_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.nowisstd::time::Instant(docs.rs signaturetick(&mut self, now: Instant); the docs passnext_deadline()totokio::time::sleep_until), so the injectedClockmust convert to/fromstd::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 (rsipis a dev-dependency only). Version, license and MSRV are workspace-inherited (not in the crate Cargo.toml). Nounsafelint 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-rtcsays its goal is use cases beyond WebRTC "including SIP, which does not always require ICE or even SRTP"; it depends onopenssl(build burden) plusezk-ice,ezk-srtp,rtp-types,rtcp-types.ezk-rtcis at 0.0.1 with 298 downloads; the fetched part of theezkREADME has no maturity statement. - rvoip: workspace of 46 crates, MSRV 1.91 (README badge), 79 open issues; audio API is
SessionHandle::audio()withAudioFrame/AudioSender/AudioReceiver/AudioStream(api::audio), fields not documented on the page fetched;send_dtmfis 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/Clockstyle 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
rsipfor message types. Contradictory, UNVERIFIED which is true. - PJSIP via FFI (
pjproject-sys1.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).
| Criterion | rsipstack +own media | rsiprtp | rvoip | ezk | xphone | wavekat-sip | PJSIP FFI |
|---|---|---|---|---|---|---|---|
| Maturity / maintenance | 4 (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 calls | 5 | 4 (APIs present; end-to-end example is simulated) | 5 | 4 | 5 | 4 | 5 |
| RTP / G.711 | 2 (nothing in crate) | 5 | 4 | 3 | 5 | 3 | 5 |
| DTMF (RFC 4733) | 2 (INFO send exists; 4733 ours) | 5 | 4 | 1 (UNVERIFIED) | 5 | 4 | 5 |
| tokio fit | 5 | 4 | 4 | 4 | 1 | 3 | 1 |
| License | 5 (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) | 3 | 4 (sans-IO SdpSession; SIP core UNVERIFIED) | 3 | 3 | 1 |
| Unweighted total (max 35) | 26 | 30 | 28 | 24 | 27 | 24 | 23 |
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).
- One SIP UA endpoint per server (
SipServer). Owns the UDP socket (and optional TCP/TLS listeners), the rsipstackEndpointand theDialogLayer, one accept task (tracked by aTaskTracker), and the registration tasks for REGISTER-based accounts (not for Twilio trunks, see interop). Config: bind addr,public_ip/advertised host (SDPc=and Contact/Via), RTP port range, allowed source CIDRs, digest credentials for inbound auth, call limit, accept queue size, timeouts. - Inbound INVITE ->
CallLink. (a) Check source allowlist / digest (401/407). (b) Over the call limit or accept queue full ->503 Service Unavailable(plusRetry-After) like the Twilio server's 503; bad SDP/no common codec ->488. (c)100 Trying, bind the per-call RTP socket,180 Ringingoptional, answer200 OKwith SDP (ourc=/port, PCMU/PCMA in the offerer's preference order restricted to what we support,telephone-eventwith the offerer's dynamic payload type,a=ptime:20,sendrecv). (d) On ACK:call_pair(), sendWireEvent::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 theCallLinkto the accept queue (the slot is reserved when the INVITE arrives andpermit.sendfills 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, orCallEnds::ended. - One RTP session per call (
rtp_sessiontask). Onetokio::net::UdpSocketper 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-typesMIT 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 withAudioFramesilence or an interpolated frame (policy TBD with the engine's PLC), then G.711 -> PCM16 withulaw_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::AudioPCM16 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 existingOutputPacer, so the RTP task sends whenWireOut::Audioarrives (no second timer). Preallocate packet buffers (hot-path rule); never block the runtime thread. - DTMF. Receive RFC 4733
telephone-eventpackets (payload: event, E bit, R bit, volume, duration; events 0-9 = digits, 10 =*, 11 =#, 12-15 = A-D per RFC 4733 Table 3). Emit oneWireEvent::Dtmfper 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. - Mapping hang-up. Remote BYE -> reply
200 OK,WireEvent::Hangup, cancel the RTP task, free the port. EngineWireOut::Hangup->BYE(confirmed dialog) orCANCEL(pending outbound; rsipstack'sInviteDialog::hangup()delegates tohangup_with_headers, which picks BYE/CANCEL by state for either role: corrected in section 9) with a transaction timeout, then cancelended. Remote CANCEL before the 200 ->487, never create a link. - Barge-in / marks (no SIP equivalent). There is no wire-level
clearor mark echo in RTP.WireOut::Clearonly 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). SynthesizeWireEvent::MarkAckwhen the packets ahead of aWireOut::Markhave been sent (the lead is then the network/jitter-buffer delay, which brisvox cannot observe), and reportWireEvent::Leadfrom 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). - Outbound call API (new). Not part of
Transport::accept. AddSipServer::dial(DialRequest) -> Result<CallLink, DialError>whereDialRequest { 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 withWireEvent::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). - Timeouts, cancellation, limits. Every await has a timeout (INVITE ring timeout, ACK wait, BYE
transaction, RTP silence e.g. 30-60 s without packets ->
Faultthen hang up; session-timer expiry if negotiated). Each call = child tasks of the server'sTaskTracker, all cancellable viaCancellationToken; a panic or error in one call must not affect others (isolate per-call task; signaling state per dialog). Bounded channels as incall_pair(). - Session timers and keepalive. rsipstack has
reinvite/update; a session-timer layer (RFC 4028Session-Expires/Min-SE, refresher role) would be ours (rsipstack has none: confirmed in the 0.7.3 source, section 9; brisvox does not offertimer, 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). - 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
Diversionheader carrying the dialed Twilio number). UDP default;;transport=tcpor;transport=tls(TLS default port 5061, other port allowed); thesipsscheme 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 returns400 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_80and_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.com192.76.120.10 and 64.16.250.10; Europesip.telnyx.eu185.246.41.140/.141; Australiasip.telnyx.com.au103.115.244.145/.146; Canadasip.telnyx.ca192.76.120.31 and 64.16.250.13; Middle Eastsip.telnyx.me185.246.42.128/.129; Asia (beta)sip.telnyx.asia103.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/27media subnet; the livesip.telnyx.compage 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), alsoAES_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), TURNturn.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 outsidelocal_net),external_signaling_address; endpoint optionsdirect_media=no,rtp_symmetric,force_rport,rewrite_contactfor 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'sbasic_callexample 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 (rsipstackaccept_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 -nsizing. (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_latchingis 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 asSipConfig::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:
rportVia 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)
rsiprtpai_bridge.rslooks 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
licensefields can differ from crates.io (rsiprtp: GitHub shows Apache-2.0 only, crates.io says MIT OR Apache-2.0; rsip: NOASSERTION). Runcargo denyon the chosen set before merging. rsipstackversions move daily at the moment:Cargo.lockplus exact pin, and re-read the changelog on every bump.- Twilio does not accept
sips:; usesip: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 againstInviteDialog.
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
rsipdependency); the only extra dependencies withplatform-tokioare 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 withDialogLayer::get_or_create_server_invite, andInviteDialog::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 intoTerminated(UacCancel)and answers 487. In-dialog requests needDialogLayer::match_dialog+handle. - BYE, INFO, UPDATE, re-INVITE, OPTIONS, REFER, NOTIFY and MESSAGE inside a dialog reach the application as
DialogStateevents carrying aTransactionHandle; 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_invitesends the INVITE, answers a 401/407 once when aCredentialis 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 callsremove_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_whitelistdrops datagrams and TCP connections from other source IPs before parsing.- rsipstack logs whole SIP messages at
infolevel (udp received/udp sent). They contain digest responses and caller data, never a password; run production withrsipstack=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/ConnectionRefusedon its RTP socket.
Behaviour of the stack that the review of the SIP transport relied on (0.7.3 source):
- Dropping a server
Transactionafter 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.
DialogStateReceiveris 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
Requireonly for 100rel; Brisvox answers any INVITE with another or unknown required tag with 420 and anUnsupportedheader (RFC 3261 section 8.2.2.3). TransportLayer::set_whitelistapplies to everything received, so it also drops the answers of outbound calls from hosts outsideallowed_sources.- Contact: a call set up over TCP gets
;transport=TCPin 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::headerscan carry an authentication token (X-Telnyx-Token). ItsDebugshows header names only, but rsipstack logs whole INVITEs atinfo: the token appears in logs unlessrsipstack=warn. - A held call (the peer re-INVITEs with
a=sendonly, or withc=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 to0.0.0.0as the address of the first non-loopback interface (SipConnection::resolve_bind_address). Brisvox therefore always (re)creates its UDP and TCP sockets on the configuredbind_addrwith the chosen port and uses the reported address only for the port and the comparison withpublic_ip. Before this a server bound to0.0.0.0with apublic_iplistened only on the first interface (not on loopback, not on a second NIC).tests/sip.rsruns a0.0.0.0bind advertised as127.0.0.2and checksc=,o=, Contact and a BYE sent to the Contact. - Digest
qop: for a challenge withqop="auth"rsipstack answersqop="auth"(quoted),nc=00000001and acnonce; RFC 2617 writes the qop value as a bare token. The hash is verified intests/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.rsunit test). - Marks: the session cuts a mark on a clear (
MarkCleared) and ignores its later echo, so the engine never seesMarkPlayedfor cut audio whether the adapter drops or echoes a mark onClear. 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_portsmust contain at least one even port (validaterejects(20001, 20001)); the rotation cursor wraps inside the even portsfirst..=lastwherelastishighrounded down to even, so an oddhighis 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 thanaccept_queuegets 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. NoFaultevent 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)
- 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. - rsiprtp: exact RTP/transport API for driving real sockets; real-world interop beyond Asterisk; any
unsafe. (nowtype resolved:std::time::Instant; CANCEL is covered by its integration tests.) - rvoip: sans-IO status, audio frame fields, compile weight, session timers, MSRV claims beyond README badge,
endpoint_local_callexample. - 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.
- wavekat-sip: tokio dependency, whether it uses rsipstack, interop tested, NAT support.
- xphone: runtime used by server mode, SDES cipher suite detail; "used in production alongside xbridge" is a self-claim.
sip-core(xmppjingle/sipr) capabilities.- 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.
- 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 (
.10on the fetched page vs.106in a search snippet). - FreeSWITCH NAT parameters; SIPp usage; SIP INFO DTMF content type; 64*T1 ACK timeout (RFC 3261 not re-read); SIP ALG behaviour.
- Production readiness of RustPBX (rsipstack's flagship user); THE-PJPROJECT status.
- 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 namesulaw_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
nowisstd::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 bytests.md, not by the README; rsiprtpterminate_callonly acts onEstablishedcalls; 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 inhangup_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)
| Capability | Twilio (Media Streams, <Connect><Stream>) | Telnyx (Call Control + media streaming) |
|---|---|---|
| DTMF from caller to bot | Yes: dtmf WebSocket event, bidirectional streams only, inbound direction only | Yes: 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 stream | NO (documented: not supported outbound) | NO (only media/mark/clear are accepted; no DTMF frame documented) |
| DTMF from bot to far end, other route | REST 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 transfer | REST update call with <Dial> TwiML (or <Connect action> fallthrough); <Refer> only for SIP legs | POST .../actions/transfer (documented description: original leg stays active if the transfer fails) |
| Warm transfer | Conference + Participants API, or <Dial><Number url=...> whisper screening | Conference commands, or Dial with link_to + Bridge; refer for SIP REFER |
| AMD | Calls API / Participants API / <Dial><Number> / <Dial><Sip>; AnsweredBy incl. machine_end_beep | answering_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 commands | none 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 astwilio.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-urlencodedormultipart/form-data("Twilio APIs expect ..."). Repeated parameters (e.g. severalStatusCallbackEvent): 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_codeanderror_messagefields 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. RequiredTo,From. Exactly the documented example:
Create parameters relevant here (page table):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_SECRETUrl(absolute URL returning TwiML),Twiml("Max 4000 characters."; page wording: "If bothtwimlandurlare provided thentwimlparameter will be ignored", i.e.Urlwins on Create),ApplicationSid,Method(GET|POST, default POST),FallbackUrl/FallbackMethod,StatusCallback/StatusCallbackMethod(default POST; "Without events, onlycompletedis sent"),StatusCallbackEvent(initiated,ringing,answered,completed; defaultcompleted),Timeout(default 60, max 600; a 5-second buffer may be added),TimeLimit,SendDigits(up to 32 characters; page wording: "If bothSendDigitsandMachineDetectionparameters are provided, thenMachineDetectionwill be ignored"),Recordetc., 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 withCA). 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):Param Meaning UrlAbsolute URL returning TwiML; called with Methodwhen the call connectsMethodGET or POST, default POST (ignored if application_sidpresent)TwimlTwiML used without fetching Url; mutually exclusive withUrl(page wording: "Twiml and url parameters are mutually exclusive")StatuscanceledorcompletedFallbackUrl,FallbackMethodused on error fetching/executing the TwiML at UrlStatusCallback,StatusCallbackMethodstatus updates; to change StatusCallbackof a call "it is required to set the Url in the same statement"TimeLimitmax call duration in seconds The maximum length of Twimlon UPDATE is not stated (only the Create page says 4000 characters): UNVERIFIED, keepupdate 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):
Response: the updated Call resource (fields: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_TOKENaccount_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).statusvalues: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 withStatus=canceled(the update table listscanceledorcompleted). - 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 acompletedstatus 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 useStatus=completedto 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 intwilio.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 anactionURL,<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
stopframe, 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 Streamnamemust be unique per Call). - Safe sequencing rules derived from the documented facts (Brisvox design):
- 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. - 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. - Pre-planned documented alternative: original TwiML
<Connect action="https://app/after-stream"><Stream .../></Connect>; brisvox closes the socket, Twilio requests theactionURL (UNVERIFIED that closing a Stream triggersaction), the user's app returns<Dial>TwiML chosen from state brisvox has stored (needs shared state keyed byCallSid). Rejected as the default: requires the user's HTTP app and extra state; REST update is self-contained.
- Never close the WebSocket first when a hand-off TwiML must follow, unless the original TwiML has a
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):digitsallowed characters:0-9,A,B,C,D,W,w,#,*. Lowercasew= 0.5 s pause, uppercaseW= 1 s pause (page prose:www3= 1.5 s before tone 3; the page's own samples are inconsistent betweenwww3andwwww3). 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 alphabet0-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:
Consequences (design, UNVERIFIED in practice): a new WebSocket upgrade, a new<Response> <Play digits="ww1234#"/> <Connect> <Stream url="wss://agent.example.com/twilio/stream" name="brisvox"> <Parameter name="resume" value="SESSION_ID"/> </Stream> </Connect> </Response>streamSid, a gap in both audio directions, the newstart.customParameters.resumemust 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
mediaframes 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 ownsend_dtmfdocumentsduration_millis100-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::Dtmfalready exists (seetwilio.mdtable, "dtmf -> WireEvent::Dtmf"); brisvox delivers it asData::Dtmfto the turn state machine/flows and exposes it to tools (e.g. adtmfmessage in the LLM context: "[caller pressed 5]"). Outbound: a new transport commandSendDtmf { 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,timeoutdefault 30 (5-600, +5 s buffer),timeLimitdefault 14400,hangupOnStar,record). The caller's call becomes the parent;<Dial>creates a child call with its ownCallSid; both legs bill (billing statement not fetched: UNVERIFIED). A plain-text number is the same as nested<Number>; E.164 recommended. callerIdrules (<Dial>page): the dialed party sees the inbound caller's number by default; with<Number>callerIdmay be theToorFromof Twilio's request, a purchased Twilio number or a verified number; for inbound SIP calls it cannot be theTo/From.- Outcome reporting: with
action, Twilio sendsDialCallStatus(completed,answered(conference),busy,no-answer,failed,canceled),DialCallSid,DialCallDuration,DialBridged. Withoutaction, 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:GETthe 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 theactionURL says; to put the bot back, theactionhandler must return<Connect><Stream>again (new session withresumeparameter, 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 parametersReferCallStatus,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): attributesmuted(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 nowaitUrl: 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 hasstartConferenceOnEntertrue); 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 addCallSid, Muted, Hold, Coaching, EndConferenceOnExit, StartConferenceOnEnter, ParticipantLabel;participant-leaveaddsParticipantCallStatus,ReasonParticipantLeft. - Conference resource (https://www.twilio.com/docs/voice/api/conference-resource):
GET .../Conferences.json?FriendlyName=NAME&Status=in-progressfinds theConferenceSid(CF...);statusisinit|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}.jsonwithStatus=completedends it orAnnounceUrl=announces to everyone. The page says "Starting from September 30th, 2026, the default [of theStatuslist filter] isin-progress" (already past on 2026-10-08): always passStatusexplicitly. Docs recommend thestatusCallbackover 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; requiredFrom,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(defaultstart 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; participantstatus: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 updatestatus: completed). - List/fetch: list filters
Muted, Hold, Coaching, PageSize (default 50, max 1000); fetch accepts a Call SID or a label.
- Create:
- 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 inboundCallSid), 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 conferencejoinstatus callback triggers removal of the AI leg by listing participants and updating that leg withstatus: 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:- 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 withwaitUrl); (b) create a second outbound call to the agent via Calls create withTwiml=<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>(agentendConferenceOnExit, callerendConferenceOnExit), the consult stream ends; (d) if declined, update the caller back to<Connect><Stream>withresume. Needsanswered_byhandling on the agent leg (voicemail of the agent's phone must not "accept": use AMD from 1.7). - Whisper screening (1.5.2): no conference, simplest, Twilio TTS reads the summary, agent accepts by DTMF. Good default for "warm".
- Bot as a conference participant from the start (Twilio blog shape): needs the bot reachable as a SIP/PSTN endpoint; not researched further.
- Consult-leg pattern (composed from documented primitives only; end-to-end UNVERIFIED): (a) update the caller's call with
- 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}; afterinitiatedthe 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 intwilio.md"Ending the call"), (3) only then call the REST update.
1.6 Status callbacks (Twilio)
- Calls API:
StatusCallback+StatusCallbackEvent(initiated|ringing|answered|completed, defaultcompleted); 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 listsqueued, ringing, in-progress, completed, busy, failed, no-answer, canceled;initiatedis NOT in that list although it is a validStatusCallbackEventname),ApiVersion, Direction(inbound|outbound-api|outbound-dial),ForwardedFrom, CallerName, ParentCallSid, CallToken; status callbacks addCallDuration,RecordingUrl/Sid/Duration. <Dial><Number statusCallback ...>(page 'number'): addsParentCallSid,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 (
statusCallbackon<Stream>): intwilio.md. - Authenticity: same
X-Twilio-SignatureHMAC-SHA1 scheme astwilio.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) orDetectMessageEnd(returnshumanimmediately; for a machine only when the greeting ends, "usually indicated by a beep"; the mode for leaving voicemail).AnsweredByvalues: withEnable:machine_start,human,fax,unknown; withDetectMessageEnd:human,machine_end_beep,machine_end_silence,machine_end_other(also a fallback when a machine was detected butMachineDetectionTimeoutexpired before the greeting ended),fax,unknown(timeout or initial-silence timeout).machine_startis not defined further on the page. FAQ: treat anymachine_end_*as "machine, greeting finished" unless you must tell them apart.- Timing parameters (Create Call / AMD pages):
MachineDetectionTimeoutdefault 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 forDetectMessageEndper the FAQ;MachineDetectionSpeechThreshold1000-6000 ms default 2400 (higher = fewer false machine, slower machine detection);MachineDetectionSpeechEndThreshold500-5000 ms default 1200 (higher helps short voicemails, delays human detection; lower risks false human);MachineDetectionSilenceTimeout2000-10000 ms default 5000 (initial silence beforeunknown). 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); lowMachineDetectionTimeoutincreasesunknown. - 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
Urlit sends a webhook containing the decision (exact form-field name in that webhook: described asAnsweredByin 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 toAsyncAmdStatusCallback(+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),MachineDetectionSilenceTimeoutdefault,Url=https://app/voicewhose handler readsAnsweredByand returns<Connect><Stream url=...><Parameter name="answered_by" value="{AnsweredBy}"/></Stream></Connect>; brisvox getsCallInfo.metadata["custom.answered_by"]atstart, so no AMD plumbing is needed inside brisvox core. Humans: conversation starts about 0-4 s after pickup (sync-silence cost). Machines: wait formachine_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 exampleshttps://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 onclient_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/jsonin the docs' curl). call_control_ididentifies 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 optionalclient_state(Base64, echoed in all later webhooks) andcommand_id(send-dtmf page: Telnyx ignores any command with the samecommand_idfor the samecall_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:422with an error list (transfer page examples showcode,title,detail; thesource/metamembers are UNVERIFIED); samples:90015invalid Call Control ID,90018call already ended,90034call not answered yet,10004missing parameter,90045"command can't be issued when media streaming is used"; generic failure10007. Dial lists400, 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 commandshold,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)", example1www2WABCDw9),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).
Whether this command is rejected (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"}'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; atomay end with,digits(+18004247767,200); an explicitsend_digits_on_answeroverrides digits appended toto. - Receive over the stream:
{"event":"dtmf","stream_id":..,"occurred_at":..,"sequence_number":"5","dtmf":{"digit":"1"}}(digits0-9 * # A B C D; may arrive out of order; order byoccurred_at;occurred_atis optional in the schema; full schema intelnyx.md). - Receive as webhook:
call.dtmf.receivedexists withdata.event_type = "call.dtmf.received",data.payload.digit(string), pluscall_control_id, call_leg_id, call_session_id, client_state, connection_id, from, to(source: official SDK modeltelnyx-pythontypes/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 thedtmfstream 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). Onlytorequired (DID or SIP URI;;secure=true|srtp|dtls;,digitssuffix allowed). Selected body fields:from(default the original call'sto; 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(defaultdisabled; enum below) +answering_machine_detection_config,custom_headers,sip_headers(onlyUser-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) toto. Webhooks:call.initiated,call.bridged(to Leg B),call.answeredorcall.hangup, and AMD events when requested. On a failed transfer the docs say acall.hangupfor Leg B is sent; the original call remains active and can receive further commands (incl. a transfer to an alternate destination). Timeouts/limits producecall.hangupwithhangup_causetimeoutortime_limit. After the bridge ends, the leg hangs up unlesspark_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). Iftransfer/bridgeis rejected with90045, issuePOST .../actions/streaming_stopfirst (stream_idoptional; omitted = all streams of the call; webhookstreaming.stopped), wait for it, then transfer; on failure,streaming_startagain. Whether the WebSocket gets astopframe afterstreaming_stop: the streaming_stop page does not describe it; the WebSocket reference says Telnyx sendsstop"when it stops media streaming for the call" (the media-streaming guide: when the call ends, after thestreaming.stoppedwebhook): 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/bridgewith bodycall_control_id(the other leg; mutually exclusive withqueue/video_room_id),park_after_unbridge,hold_after_unbridge,play_ringtone,ringtone,mute_dtmf,prevent_double_bridge,record*,client_state,command_id. Webhookscall.bridgedfor both legs. Errors incl.90034not answered. - Dial a second leg linked to the first:
POST https://api.telnyx.com/v2/callswith requiredconnection_id,to,from;link_to(call control id to share a session with),bridge_intent(requireslink_to; overwritesfromwith the linked call'sfrom),bridge_on_answer(requireslink_to),prevent_double_bridge,park_after_unbridge(requireslink_to); responsedata: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,namerequired;start_conference_on_createdefault true;hold_audio_url/hold_media_name;max_participants2-800 default 250 (the page's own error examples conflict on this range, UNVERIFIED);duration_minutes; conferences expire after all leave or 4 hours; statusinit|in_progress|completed;end_reasonall_left|ended_via_api|host_left|time_exceeded),POST /v2/conferences/{id}/actions/join(call_control_idrequired;hold,hold_audio_url,mute,start_conference_on_enter(default false),end_conference_on_exit,soft_end_conference_on_exit,supervisor_rolebarge|monitor|none|whisper,whisper_call_control_ids,beep_enabled),POST /v2/conferences/{id}/actions/hold(call_control_idsempty = 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/referwithsip_address(required SIP URI),client_state,command_id,custom_headers,sip_auth_username/password,sip_headers(User-to-Useronly). Webhookscall.refer.started,call.refer.completed,call.refer.failed(the earlier-noted payload fieldsip_notify_responsewas NOT found on the refer page: UNVERIFIED). Whether the call must be answered: not stated (90034is 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 requiredDiversionheader: 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_transfertool, 2-minute decision timeout,private|sharedcontext): 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_detectionon 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.Mode Webhooks resultvaluesdetectcall.machine.detection.endedhuman,machine,not_sure("Recommended to treat as if human answered")detect_beepdetection.ended; call.machine.greeting.endedonly if a beep is detectedgreeting: beep_detecteddetect_wordsdetection.ended; greeting.ended on beep or after a 30 s beep-detection window greeting: beep_detectedornot_sure(timeout)greeting_endboth; Transfer page: greeting.ended when the greeting ends "with a beep or silence" greeting: endedpremiumcall.machine.premium.detection.ended,call.machine.premium.greeting.endeddetection: human_residence,human_business,machine,silence,fax_detected,not_sure; greeting:beep_detected,no_beep_detectedpremium_ios_call_screening_detectionadds call.machine.premium.call_screening.detectedscreening; greetingprompt_endedPayload (SDK models call_machine_detection_ended.pyandcall_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);resultLiteral for detection.ended: `humanmachine not_sure ; for greeting.ended:beep_detectedOrder: call.initiated->call.answered-> detection result -> greeting result; callee hang-up givescall.hangupat any point. Premium ordering: if a beep is heard beforedetection.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_millis3500,after_greeting_silence_millis800,between_words_silence_millis50,greeting_duration_millis3500,initial_silence_millis3500,maximum_number_of_words5,maximum_word_length_millis3500,silence_threshold256,greeting_total_analysis_time_millis5000,greeting_silence_duration_millis1500, beep tuning (beep_detection_profileboth|freq_only,beep_min_frequency_hz200-4000,beep_max_frequency_hz,beep_min_tone_duration_millis50-5000,beep_spectral_*),prompt_end_timeout_millis(Dial/Transfer pages: default 5000, 1000-120000; the guide says 30000 default; iOS screening only). Forpremiumonlytotal_analysis_time_millisandgreeting_duration_millisapply. The AMD guide quotes premium default analysis timeout 30 s (resultnot_sureon 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 overinitial_silence_millis(3500 ms) = machine; a greeting longer thangreeting_duration_millis(3500 ms) = machine; more thanmaximum_number_of_words(5) words = machine; a single word longer thanmaximum_word_length_millis(3500 ms) = machine; silence overafter_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}andmeta.{attempt, delivered_to}. - Signature headers:
Telnyx-Signature-Ed25519(ED25519 signature) andTelnyx-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_secs0-30; failover URLwebhook_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 viawebhook_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_idgroups 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 byoccurred_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 (RESTsend_dtmfwithcommand_id); Twiliofalseover the stream, with two opt-in fallbacks (REST update + reconnect, or in-band synthesis); loopback/WebSocket transports: custom message.send_dtmftool = SideEffect, idempotency key per (call, digits, sequence), digits validated against[0-9A-D*#wW]and a length limit (TwilioSendDigits32, Telnyxsend_digits_on_answer64;digitsmax 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):dtmf0.1.5 (MIT, decoder, last release 2022-11-04, 9820 total downloads: stale),dtmf_tones1.0.2 anddtmf_table1.1.4 (MIT, frequency tables only, no audio generation per the repo README;dtmf_tonesrepo shows 21 commits, 0 stars, no releases; new, tiny, not adopted).twiml-rust0.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:
CallControltrait (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_idon Telnyx), and credentials inSecret. - 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) andbridge, or conference; rememberstreaming_stopbefore commands that fail with90045. - Safety (engine invariant: SideEffect never speculative, never twice):
transfer_callruns 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:
- Provider AMD when brisvox originates the call (Twilio
DetectMessageEndsync via the user'sUrlwebhook ->answered_bycustom parameter; Telnyxgreeting_endorpremiumvia the Dial request + webhooks). The result must reach the session (Twilio: custom parameter atstart; Telnyx: webhook orclient_state). - 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). - 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.WaitGreetingEndends on Twiliomachine_end_beep|machine_end_silence|machine_end_otheror Telnyxcall.machine.greeting.ended(beep_detectedorended); on a TwilioEnableverdictmachine_startdo NOT speak yet. On Telnyxdetect_words,not_sureafter 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>; Telnyxspeak/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)
- Twilio: effect of REST Update (new TwiML) on an active
<Connect><Stream>: does Twilio sendstop, 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. - 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). - Twilio: Update Call
Twimllength limit; error codes/status for updating an ended call; whetherUrlupdate triggers a signed request with the usualX-Twilio-Signature. - Telnyx: whether
send_dtmf,transfer,bridgeare blocked (90045) while a media stream is active; which exact commands; effect of transfer on the stream (stopframe?); whetherstreaming_stop+transfer+streaming_startis quick enough. - Twilio consult-leg and conference warm transfer end to end (hold/unhold,
Holdparticipant update on a leg created by<Dial><Conference>, drop of the bot leg, ConferenceSid discovery byFriendlyName). - AMD: sync
DetectMessageEndlatency distribution on Turkish carriers and Turkish voicemail greetings (Twilio and Telnyx), false-human/false-machine rate,machine_end_otherfrequency, 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. - Twilio
AnsweredByform-field name in theUrlwebhook and theAsyncAmdStatusCallbackMethodGET support (docs disagree). - Telnyx: premium AMD payload schemas and default
total_analysis_time_millis;call.dtmf.receivedoutside Gather. - Billing: Twilio parent+child leg billing for
<Dial>transfers; Telnyx "Call transfer $0.10 per invocation" scope; conference participant minutes. - 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:
- Twilio Create Call:
Twimlis the parameter ignored when bothTwimlandUrlare given (file previously saidUrlwas ignored). SendDigitsvsMachineDetection: page saysMachineDetection"will be ignored"; wording fixed.- Update Call description quote "Modifies an active call..." not verbatim on the page; replaced by the actual wording.
- 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"). <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.CallStatusvalues:initiatedis not in the page's list.- Twilio AMD page has no example async payload; marked as constructed.
- "Participants / Dial Number / Dial Sip are async only" not stated on the AMD page; marked UNVERIFIED (changelog says "run in the background").
- Conference resource quote corrected ("Conference rooms are not directly created from the Programmable Voice API").
- Twilio blog (2025-09-08): only "no unit tests" is stated; the claims of no briefing/summary and fragile error handling removed.
- Participants announce URL verb list (
<Play>,<Say>,<Pause>,<Redirect>) not confirmed; marked UNVERIFIED. - Telnyx 2.1:
client_state/command_id/error codes are not on the sending-commands page (re-attributed to command pages); node-routing claim andsource/metaerror members marked UNVERIFIED; 60-secondcommand_idwindow added. - Telnyx "compatible with the Standard Webhooks specification" not found on the webhooks page; marked UNVERIFIED.
- Telnyx AMD "outbound call creation or inbound call transfer" wording not found; marked UNVERIFIED.
- crates.io
dtmfdownloads: 3108 corrected to 9820. call.dtmf.receivedverbatim quote replaced by a paraphrase of the webhooks page.- Telnyx refer
sip_notify_responsepayload field not on the refer page; marked UNVERIFIED. - Twilio FAQ threshold hints: 1800-2000/1400-1500 are for residential/mobile callees (business: SpeechThreshold 1800-3000); clarified.
- TaskRouter warm-transfer step 2 reworded (new TaskRouter task, then TwiML into conference); Twilio
MachineDetectionTimeoutrange note (Create page lists none) added. - Telnyx:
stopframe afterstreaming_stopordering marked UNVERIFIED; premium event-ordering claim marked UNVERIFIED; added conferencemax_participantsconflict,conference.recording.saved, join default,prompt_end_timeout_millisdefault 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-1after>>3) and STL (~x >> 4) agree for all 16-bit inputs; VERIFIED exhaustively bybrisvox-audiotests (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).
| Case | Value | Status |
|---|---|---|
| mu enc 0 | 0xFF | SRC (Wikipedia mu-law: "0 encodes as 0xFF"; silence = 0xFF) + DER |
| mu enc -1 (14-bit input, ones' complement ITU table) | 0x7F | SRC; Sun 16-bit code gives 0x7E (DER) |
| mu enc +32767 / -32768 | 0x80 / 0x00 | DER |
| mu dec 0xFF / 0x7F | 0 / 0 | DER |
| mu dec 0x80 / 0x00 | +32124 / -32124 | DER; 14-bit peak 8031 (x4 = 32124) SRC Wikipedia G.711 ("0 to +-8031") |
| mu dec 0xFE / 0x7E | +8 / -8 | DER |
| mu dec 0x02 | -30076 (-7519 in 14-bit) | DER; -7519 SRC (G.711 text above) |
| A enc 0 | 0xD5 | SRC (Wikipedia: silent channel 0xD5) + DER |
| A enc -1 | 0x55 | DER |
| A enc +32767 / -32768 | 0xAA / 0x2A | DER |
| A dec 0xD5 / 0x55 | +8 / -8 | DER (13-bit +-1, x8) |
| A dec 0xAA / 0x2A | +32256 / -32256 | DER; 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 "always8000", 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.
| Variable | Default | Meaning |
|---|---|---|
BRISVOX_MAX_SESSIONS | 256 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_THREAD | 4 | Jobs queued per inference-pool thread at or above which new calls are refused (overloaded). 0 ignores the queue depth. |
BRISVOX_OVERLOAD_RESUME_QUEUE_PER_THREAD | half of the above | Queued jobs per thread at or below which the server may recover (must be below the above). |
BRISVOX_OVERLOAD_CEILING | true | Learn 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_CONFIRM | 100ms | How 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_WAIT | 50ms | Mean 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_WINDOW | 2s | The window of the wait average, and the calm time needed to recover. |
BRISVOX_PROVIDER_LIMITS | none | name=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_ADDR | 0.0.0.0:8081 | Address 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_TOKEN | unset | Bearer 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_LISTENERS | false | 1/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_SECRET | unset | Signs 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_PREV | unset | The previous session secret, still verified while old tokens expire (rotation). Needs BRISVOX_SESSION_SECRET. |
BRISVOX_TRUSTED_PROXIES | unset | Comma-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_HEADER | x-forwarded-for | The 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_IP | 8 | Signaling connections one client address (an IPv6 /64) may hold at once; 0 = no limit. Connections from a trusted proxy are exempt. |
BRISVOX_DRAIN_TIMEOUT | 30s | How long a drain waits for calls to end by themselves. 0s cancels at once. |
BRISVOX_CANCEL_GRACE | 5s | After cancelling, how long sessions get to return. |
BRISVOX_ABORT_WAIT | 1s | After aborting, how long the task cleanup gets. |
BRISVOX_LOG_FORMAT | text | text or json (docs/TRACING.md). |
RUST_LOG | info | Log filter. |
BRISVOX_AGENT_FILE | unset | The agent definition every call runs, as a file path. Required when any listener is on, unless BRISVOX_AGENT_DEFINITION is set. |
BRISVOX_AGENT_DEFINITION | unset | The 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_FORMAT | toml | The 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_ADDR | 127.0.0.1:8080 when no other listener is set, otherwise off | Generic 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_FILE | unset | An 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_TOKEN | unset | Static token of the generic websocket listener (development): Authorization: Bearer, the subprotocol brisvox-token.<token> or ?token=. |
BRISVOX_TWILIO_ADDR | off | Twilio Media Streams listener (path /twilio/stream). |
TWILIO_AUTH_TOKEN | unset | The account's Auth Token; required with the Twilio listener (every stream's X-Twilio-Signature is checked). |
BRISVOX_TWILIO_PUBLIC_URL | unset | The 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_SECRET | unset | At 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_URL | off | 1 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_ADDR | off | Telnyx media streaming listener (path /telnyx/stream). |
BRISVOX_TELNYX_STREAM_TOKEN | unset | The 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_ADDR | off | RTVI listener for Pipecat client SDKs (path /rtvi, docs/RTVI.md). |
BRISVOX_RTVI_TOKEN | unset | Static 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_ORIGINS | unset | Comma-separated browser Origins allowed (unset: any). |
BRISVOX_SIP_ADDR | off | SIP trunk listener, UDP (feature sip; a binary without it refuses the setting). |
BRISVOX_SIP_PUBLIC_IP | unset | Address advertised in SDP, Contact and Via; required when BRISVOX_SIP_ADDR is unspecified (0.0.0.0). |
BRISVOX_SIP_TCP | false | Also take SIP over TCP on the same port. |
BRISVOX_SIP_RTP_PORTS | unset | UDP port range for RTP, low-high (unset: the OS picks a port per call). Forward the whole range. |
BRISVOX_SIP_ALLOWED_SOURCES | unset | Comma-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_PASSWORD | unset | Digest credentials inbound INVITEs must present (both or neither). |
BRISVOX_SIP_AUTH_REALM | brisvox | The digest realm. |
BRISVOX_SIP_TLS_ADDR | off | SIPS 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_KEY | unset | Paths 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_CA | unset | PEM 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_IP | 1024 / 32 | TLS connections at once, and from one source address. Over either the connection is closed before the handshake (tls_rejected). |
BRISVOX_SIP_TLS_HANDSHAKE_MS | 10000 | The longest a client may take for the TLS handshake. |
BRISVOX_SIP_TLS_IDLE_TIMEOUT | 300 | Seconds 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_LIMIT | 50 | SRTP 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_SRTP | optional with TLS, else off | SDES-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_STACK | false | Let 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_ADDR | off | WebRTC 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_ADDR | the signaling address | The one UDP socket every call's media shares (forward this port). |
BRISVOX_WEBRTC_CANDIDATE_IP | unset | The 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_TOKEN | unset | Static 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_IP | 8 | Calls one client address may hold at once (429 over it), or off. |
BRISVOX_WEBRTC_MAX_HANDSHAKING | a quarter of BRISVOX_MAX_SESSIONS | Calls 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_MINUTE | 5 | Offers one client address may make per minute, burst of as many (429 with Retry-After), or off. |
BRISVOX_WEBRTC_WS_PATH | /ws | The 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_ORIGIN | unset | Access-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_ADDR | off | ICE-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_PORT | the listener's port | The 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 / _QUEUE | 256 / 16 / 10s / 2048 / 64 | ICE-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_PROVIDER | off | coturn, 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 / /rtvi | The websocket path of each listener (Twilio: the path of BRISVOX_TWILIO_PUBLIC_URL). |
BRISVOX_LISTENER_HANDSHAKE_TIMEOUT | 5s | Every websocket listener: TCP accept to the end of the upgrade. |
BRISVOX_LISTENER_START_TIMEOUT | 5s | Upgrade to the protocol's start message. |
BRISVOX_LISTENER_PING_INTERVAL / BRISVOX_LISTENER_PING_TIMEOUT | 10s / 30s | Server pings; a client silent for the timeout (longer than the interval) ends its call. |
BRISVOX_LISTENER_WRITE_TIMEOUT | 5s | Longest single write to a client. |
BRISVOX_LISTENER_SEND_TIMEOUT | 5s | Longest wait for the engine's queues (a control event, the hand-over of a new call). |
BRISVOX_LISTENER_CLOSE_GRACE | 2s | How long the two sides of a closing connection wait for each other. |
BRISVOX_LISTENER_ACCEPT_QUEUE | 16 | Calls waiting to be handed to a session (1 to 1000000). |
BRISVOX_LISTENER_MAX_AUDIO_BYTES / BRISVOX_LISTENER_MAX_CONTROL_BYTES | 65536 / 16384 | Largest binary / text frame accepted (2 / 512 to 16 MiB). |
BRISVOX_RTVI_BOT_SPEAKING_EVENTS | true | The RTVI bridge sends bot-started/stopped-speaking and bot-interrupted from the engine. |
BRISVOX_RTVI_METRICS | true | The RTVI bridge sends metrics once per turn. |
BRISVOX_RTVI_REPLY_TIMEOUT | 5s | How long an RTVI client message's handler may take. |
BRISVOX_RTVI_EVENT_CAPACITY / BRISVOX_RTVI_TEXT_CAPACITY | 1024 / 8 | An RTVI call's engine event ring / typed messages waiting for the turn driver. |
BRISVOX_CALL_CONTROL | off | Carriers whose REST call control the telephony tools use: twilio, telnyx or both, comma separated (feature call-control). |
BRISVOX_TWILIO_ACCOUNT_SID | unset | The Twilio account (AC...); required with Twilio call control (with TWILIO_AUTH_TOKEN). |
BRISVOX_TWILIO_CALLER_ID | unset | E.164 caller id of Twilio transfers (the From of a warm transfer's receiving leg; required for warm transfers). |
BRISVOX_TWILIO_API_BASE | https://api.twilio.com/2010-04-01 | Twilio REST base URL (a regional endpoint, or a test double on loopback). |
BRISVOX_TWILIO_HOLD_URL | unset | waitUrl of the conference a warm transfer's caller waits in (hold music). |
BRISVOX_TWILIO_STATUS_CALLBACK | unset | Where Twilio posts the status events of a warm transfer's receiving leg; the application behind it calls the callback listener. |
TELNYX_API_KEY | unset | The Telnyx API key (v2); required with Telnyx call control. |
BRISVOX_TELNYX_CALLER_ID | unset | E.164 caller id of Telnyx transfers (required for warm transfers). |
BRISVOX_TELNYX_CONNECTION_ID | unset | The Telnyx Call Control application of a warm transfer's receiving leg (required for warm transfers). |
BRISVOX_TELNYX_HOLD_URL | unset | Audio a warm transfer's caller hears while waiting (without it: silence). |
BRISVOX_TELNYX_API_BASE | https://api.telnyx.com/v2 | Telnyx REST base URL (a test double on loopback). |
BRISVOX_CALLBACK_ADDR | off | Where the callback listener binds (host:port); needs BRISVOX_CALL_CONTROL (feature call-control). See "Callback listener". |
BRISVOX_CALLBACK_TOKEN | unset | The 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.onnx | The Smart Turn model of adaptive turns (feature smart-turn-ort), or off. |
BRISVOX_MODELS_DIR | models | Directory of the model files. |
BRISVOX_EXTRA_CA_FILE | unset | Tests 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_THREADS | the 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 ascall 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 inAuthorization: Bearer) orBRISVOX_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.DELETEis authorized by the session URL when session tokens are in use. A session token'smax_scaps 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 ofBRISVOX_MAX_SESSIONS(BRISVOX_WEBRTC_MAX_HANDSHAKING,503). Behind a proxy setBRISVOX_TRUSTED_PROXIESso the address is the visitor's, not the proxy's. The counters are inWebRtcServer::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):
| Request | Effect |
|---|---|
POST /v1/calls/{twilio|telnyx}/{call}/warm/complete | The 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/abort | They 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/twilio | A 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/telnyx | A 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
| Variable | Default | Meaning |
|---|---|---|
BRISVOX_AGENT_URL | unset | The agent process every call links to (ws:// or wss://, docs/AGENT_PROTOCOL.md). Unset: calls run on the agent file alone. |
BRISVOX_AGENT_TOKEN | unset | Sent as Authorization: Bearer <token> to the agent; never logged. |
BRISVOX_AGENT_ALLOW_INSECURE | off | 1 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_IDENTITY | off | 1 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_CA | unset | PEM bundle of a private CA, trusted besides the public roots for wss:// agent URLs. |
BRISVOX_AGENT_CONNECT_TIMEOUT_MS | 2000 | Bound on connecting to the agent. |
BRISVOX_AGENT_HANDSHAKE_TIMEOUT_MS | 2000 | Bound on the session.start write and the agent's session.accept, together. |
BRISVOX_AGENT_RECONNECT_ATTEMPTS | 5 | Reconnection attempts per series for an agent that declared resume; 0 never reconnects. |
BRISVOX_AGENT_RECONNECT_WINDOW_MS | 15000 | No 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_SESSIONScalls live or reserved, counting every listener): HTTP 503 withRetry-After: 1. Draining: HTTP 503 withoutRetry-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
503withRetry-After, draining a503without it. The reservation is held through the200 OKand 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.
/readyzand the listeners agree:fullwhen every slot is taken,overloadedorprovider_budgetwhile admission control refuses (below),drainingonce 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
/readyzprobe: queued jobs per pool thread, jobs refused or timed out, and the mean time jobs waited in the queue overBRISVOX_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 forBRISVOX_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/readyzalso 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, setBRISVOX_MAX_SESSIONSto 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/readyzreportsoverloaded, 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
503andRetry-After:1s at the session limit,5s overloaded,2s provider budget, none when draining; the body names the reason (server busy,server overloaded,provider capacity reached,server shutting down). SIP answers503withRetry-Afterfor all three, none when draining. - Metrics.
/readyzcarries the counters and the numbers behind the verdict (below), andSessionManager::statshasrejected_at_capacity,rejected_overloaded,rejected_provider_budgetandrejected_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.
| Path | 200 when | 503 when | Body |
|---|---|---|---|
/healthz | the process serves the listener (also while draining) | never | {"status":"ok"} |
/readyz | sessions are admitted | draining, every slot is taken, the pool is overloaded or a provider budget is exhausted | see below |
/metrics | BRISVOX_METRICS is not off | never (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.
| Variable | Default | Meaning |
|---|---|---|
BRISVOX_METRICS | true | Serve GET /metrics on the health listener. The counters are kept either way. |
BRISVOX_TRACES | off | otlp ships one trace per turn to an OpenTelemetry collector (needs the otlp build feature). |
BRISVOX_OTLP_ENDPOINT | http://127.0.0.1:4318/v1/traces | http://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_QUEUE | 1024 | Traces waiting for the exporter task; when full the newest is dropped and counted. |
BRISVOX_OTLP_BATCH | 64 | Most traces per request. |
BRISVOX_OTLP_FLUSH | 2s | Longest wait before a partial batch is sent. |
BRISVOX_OTLP_TIMEOUT | 5s | Deadline of one request (connect, send, answer). A failed batch is dropped and counted, never retried. |
BRISVOX_OTLP_SERVICE | brisvox-server | The 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.
| Family | Type | Labels | Meaning |
|---|---|---|---|
brisvox_stage_latency_seconds | histogram | stage | One 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_total | counter | outcome (completed, interrupted) | Turns finished. |
brisvox_sessions_active, _reserved, _max, _peak | gauge | Session manager. | |
brisvox_sessions_started_total, brisvox_sessions_ended_total{reason} | counter | reason | Sessions started and ended (completed, failed, panicked, cancelled, aborted). |
brisvox_session_admissions_rejected_total | counter | reason (full, overloaded, provider_budget, shutting_down) | Admissions refused. |
brisvox_pool_overloaded, _queue_depth, _mean_wait_seconds, brisvox_pool_overload_entered_total | gauge/counter | Inference 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/counter | The 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_total | gauge/counter | provider | Per-provider concurrency budgets (BRISVOX_PROVIDER_LIMITS). |
brisvox_provider_errors_total | counter | stage, kind | Provider 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_total | counter | event | Engine trouble of finished calls: queue and stale drops, pool trouble, finals-gate, settle and mark timeouts, truncations. |
brisvox_calls_ended_total | counter | Calls whose end-of-call statistics were added. | |
brisvox_breaker_state | gauge | stage, role, provider | Circuit breaker: 0 closed, 1 half-open, 2 open. |
brisvox_breaker_events_total | counter | stage, role, provider, event | Attempts admitted, refused, failed, probes, openings, closings. |
brisvox_retry_budget_available, brisvox_retry_budget_total{outcome} | gauge/counter | outcome (granted, refused) | The process retry budget. |
brisvox_resilience_events_total | counter | stage, event | Per-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_total | counter | outcome | Traces 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.
| Variable | Default | Meaning |
|---|---|---|
BRISVOX_RECORDING_DIR | unset | Directory for call-*.transcript.jsonl, call-*.caller.wav, call-*.bot.wav; created at start. |
BRISVOX_RECORDING_TRANSCRIPTS | true | Write each call's transcript (older name BRISVOX_RECORD_TRANSCRIPTS still works). |
BRISVOX_RECORDING_AUDIO | false | Write the caller's and the bot's audio (never redacted; older name BRISVOX_RECORD_AUDIO). |
BRISVOX_RECORDING_REDACT_PII | true | Replace emails, IBANs, national IDs, card and phone numbers in transcripts before they are written (older name BRISVOX_REDACT_PII). |
BRISVOX_RECORDING_REDACT_KINDS | all | Which kinds: email,iban,id,card,phone,number. |
BRISVOX_RECORDING_RETENTION | 30d | Delete recordings this old (15m, 36h, 30d, at least 1h); forever keeps them. |
BRISVOX_RECORDING_SWEEP_INTERVAL | 1h | Time between retention sweeps (at least 1m). |
BRISVOX_RECORDING_MAX_MINUTES | 120 | Longest audio kept per call and track (1 to 600). |
BRISVOX_RECORDING_AGE_RECIPIENTS | unset | Comma-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_PII | false | Also scrub emails, IBANs, national IDs, card and phone numbers from log lines (docs/PRIVACY.md, "Logs"). |
BRISVOX_LOG_REDACT_KINDS | email,iban,id,card,phone | The 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.
| Variable | Default | Meaning |
|---|---|---|
BRISVOX_BREAKER_FAILURES | 5 | Consecutive failures that open a provider's breaker (1 to 1000). |
BRISVOX_BREAKER_OPEN_FOR | 30s | How long an open breaker refuses attempts before one probe is let through. |
BRISVOX_CALL_RETRY_BUDGET | 3 | Retries (failovers, recoveries) one call may make. |
BRISVOX_CALL_RETRY_REFILL | 20s | One retry token comes back to a call per this long; 0s keeps the budget full. |
BRISVOX_PROCESS_RETRY_BUDGET | 50 | Retries the whole process may make. |
BRISVOX_PROCESS_RETRY_REFILL | 100ms | One token comes back to the process budget per this long; 0s keeps it full. |
BRISVOX_PROVIDER_ATTEMPT_TIMEOUT | 2s | Deadline of a provider attempt that may be followed by another. |
BRISVOX_LLM_FIRST_EVENT | 4s | With a secondary LLM: silence after which a response is re-issued to the secondary. |
BRISVOX_PROVIDER_HEALTHY_AFTER | 10s | How 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.
- Drain starts at once. The session manager stops admitting:
/readyzanswers 503draining, 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./healthzstays 200. An admission a listener reserved before the signal is honoured: that call runs and is drained like the others. - 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). - 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'sstop/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. - Abort. Sessions still running are aborted (their task is dropped; drop guards clean the
registry) and get
BRISVOX_ABORT_WAIT. - 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 indocs/DEPLOY.mduse).
Exit status:
| Status | Meaning |
|---|---|
| 0 | Every session ended by itself (or none was running). |
| 1 | Startup 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. |
| 3 | Some sessions were cancelled (drain timeout or second signal) and stopped in time. |
| 4 | Some sessions ignored cancellation and were aborted. |
| 5 | Some 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).
| Stage | Base | Does |
|---|---|---|
chef | rust:1.99.0-slim-trixie | compiler, build-essential, cmake, cargo-chef |
planner | chef | cargo chef prepare: the dependency recipe (recipe.json) |
builder | chef | cargo chef cook (dependencies only, cached until a Cargo.toml/Cargo.lock changes), then cargo build --release --locked -p brisvox-server, stripped |
models | python:3.12-slim-trixie | scripts/models/fetch_models.py: pinned downloads, SHA-256 verified, the build fails on a mismatch |
runtime | debian:trixie-slim | ca-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:
| Service | Listener | Agent | Publishes (loopback) |
|---|---|---|---|
agent-ws | generic websocket, 0.0.0.0:8080 | deploy/agents/web.toml: Deepgram + OpenAI + Cartesia | 8080 (calls), 8081 (health) |
agent-twilio | Twilio Media Streams, 0.0.0.0:8082, path /twilio/stream | deploy/agents/phone.toml: AssemblyAI + Groq + Deepgram Aura, tools transfer_call and end_call over Twilio call control | 8082 (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, socpus: 4gives a pool of two threads. Load-test with your agent (docs/LATENCY.md) and setBRISVOX_MAX_SESSIONSandBRISVOX_PROVIDER_LIMITSfrom 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_NOFILEof 1024. A process that keeps that limit stops accepting at about 1017 calls (accept()fails withEMFILE, 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-serverraises its own soft limit to the hard limit at startup (anopen-file limitline atinfowith the before and after, awarnwhen it cannot; it never fails the start; Windows has no such limit and does nothing;src/fdlimit.rs, therlimitcrate, MIT, Unix only). That only helps up to the hard limit, which you still set: keep--ulimit nofile=65536:65536(compose:ulimits.nofile, already indeploy/compose.yaml; Kubernetes: the container runtime's default or aLimitNOFILEdrop-in on the node) to at least2 x BRISVOX_MAX_SESSIONSplus 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: publishBRISVOX_SIP_ADDR's port and the wholeBRISVOX_SIP_RTP_PORTSrange, setBRISVOX_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 network | What gets a call through |
|---|---|
| Open internet, any NAT, server with public UDP | nothing extra (the default) |
| UDP blocked, TCP out allowed to the server's port | ICE-TCP (BRISVOX_WEBRTC_TCP_ADDR), or TURN over TLS/443 |
| Strict firewall (443 only, TLS inspection) or symmetric NAT | TURN 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 (audiencebrisvox-webrtc, single use), the sameOrigin/CORS rule, offer rate, per-address call cap, handshake pool andBRISVOX_MAX_SESSIONSadmission:docs/SECURITY.md. A session token that opened a WebRTC session which then never connected (the page hangs it up withDELETEbefore falling back) is given back, once, before theDELETEis 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 gets403, 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 withproxy_set_header Upgrade, Cloudflare all do) and keep an idle connection for longer than 30 s (the server pings every 10 s). SetBRISVOX_TRUSTED_PROXIESso per-address limits see visitors, and keep theHostheader (the defaultOriginrule compares it with the page's origin; if your proxy rewrites it, setBRISVOX_WEBRTC_CORS_ORIGINto 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 includeturns: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_SIDplus its secret as the auth token). Twilio offersturn:on 443/TCP but noturns:(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/demopage 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 onwss://<your-domain>/wswith a second session token.
The media options, best first:
- 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:0is 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 withBRISVOX_WEBRTC_*on a UDP host (VPS, Fly.io) for WebRTC media with lower latency. - 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. - 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>andBRISVOX_WEBRTC_CANDIDATE_IP=<the IP the proxy domain resolves to>(an IP, not a name; check it stays stable), andBRISVOX_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 nowebrtc, and a binary without it refusesBRISVOX_WEBRTC_ADDRat start. Build withBRISVOX_FEATURES=all-providers,smart-turn-ort,webrtc(the browser row of "What to build with" in the README);webrtccompiles libopus (cmake and a C compiler are in the build stage). Addcall-controlif the same service also takes phone calls (transfer, DTMF, hang-up); addsiponly on a UDP host. A full Docker build withwebrtchas not been run by the project (the documented pass usedsip): watch the first build. Whether platform variables reach the build depends on the platform: a DockerfileARGis the portable way, and ours declaresBRISVOX_FEATURES,BUILD_JOBSandSTRIP_BINARYin its build stage. BUILD_JOBS. Optional cap on rustc jobs (for example2); a full build peaks near 1 GB of RAM per job and a platform's builder size is often not documented.- Why
webrtcfor a WebSocket call. The page,POST /whipand the/wsfallback all live in the WebRTC signaling listener. If you only need your own clients to speak the WebSocket protocol (docs/WEBSOCKET_PROTOCOL.md), the genericBRISVOX_WS_ADDRlistener needs nowebrtcfeature (no libopus, shorter build), but it has no bundled page, noOriginrule, no per-address limits and does not enforce a token'smax_s(docs/SECURITY.md); useBRISVOX_WEBRTC_ADDRfor 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 leaveBRISVOX_AGENT_FILEunset;BRISVOX_AGENT_FORMAT=yamlfor a YAML definition. Setting both variables is an error. Check it first withBRISVOX_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 setBRISVOX_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.tomlWith
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 ofPORT; ifPORTis 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 bind0.0.0.0(or::), never loopback. -
Credentials. Off loopback the signaling listener refuses to start without
BRISVOX_SESSION_SECRETor 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:
/healthzand/readyzare onBRISVOX_HEALTH_ADDR(port 8081), which the probe cannot reach, and the image'sHEALTHCHECKis not used. Set the platform's health check path to/demo: the signaling listener answersGET /demowith 200 and no credentials (the bundled page;BRISVOX_WEBRTC_*as above). For liveness and readiness during operation keepBRISVOX_HEALTH_ADDRon the private network and scrape or probe it from another service (/readyzis 503 while draining or full);/metricsneedsBRISVOX_METRICS_TOKENthere. -
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-fororforwarded) only on connections from addresses listed inBRISVOX_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:- Safe default (the proxy range is not published). Leave
BRISVOX_TRUSTED_PROXIESunset 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 andBRISVOX_PROVIDER_LIMITS; add rate limiting where you mint tokens. - Per-visitor limits through the header. Set
BRISVOX_CLIENT_IP_HEADERto the header the edge writes and list the proxy addresses you observe in your own logs and nothing wider inBRISVOX_TRUSTED_PROXIES. Never0.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.
- Safe default (the proxy range is not published). Leave
-
WebSockets through the proxy. The proxy must pass the HTTP/1.1
Upgradeand keep an idle connection for longer than 30 s (the server pings every 10 s); keep theHostheader or setBRISVOX_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
SIGTERMto the old instance andSIGKILLafter 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 isBRISVOX_DRAIN_TIMEOUT30 s +BRISVOX_CANCEL_GRACE+BRISVOX_ABORT_WAIT+ 7 s = 43 s), more if you raiseBRISVOX_DRAIN_TIMEOUTfor 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 bestturn:/turns:URL over TCP from the provider's answer,turns:on port 443 first (Cloudflare liststurns:turn.cloudflare.com:443?transport=tcp). A provider that offers only UDP URLs (Twilio liststurn:on 443/TCP only) works for the server through thatturn:URL, unencrypted at the TURN layer (the media is still DTLS-SRTP end to end). BRISVOX_WEBRTC_TURN_RELAY=onkeeps the direct candidates (setBRISVOX_WEBRTC_CANDIDATE_IP) and adds the relayed one at a lower priority: a fallback for servers that are probably reachable.onlyis 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 with503 Retry-After(only), andrelay_setup_failurescounts 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
traceover 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, attrace, print whole STUN messages (nonce, integrity HMAC), so the server drops them belowwarneven when you setRUST_LOG(BRISVOX_LOG_TURN_CLIENT=1lifts that, for debugging). If you embed the library and install your owntracingsubscriber you must apply the same filter:filter_fn(brisvox_transport::webrtc::turn_client_log_allowed)next to your level filter (FilterExt::and); aRUST_LOGdirective 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_slowandrelay_slow_writescount 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, otherwise0.0.0.0is enough (the library notes recommend::; not tested here). - TypeScript:
-
Token. Set
BRISVOX_AGENT_TOKENon both services (a shared variable or a reference); the SDKs read it, and the server sends it asAuthorization: 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 brisvoxandnpm i brisvoxare the same SDKs under the short name), or vendor it from a checkout of this repository (pip install ./sdk/pythonafter copying the directory;npm installa builtsdk/typescript), as inbook/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 installof the copiedsdk/python, the booking agent) andsdk/typescript/examples/Dockerfile(TypeScript:npm ci && npm run buildof the copiedsdk/typescriptin a build stage, compileddistand productionnode_modulesin the final one). Both listen on0.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 theCOPYlines 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
| Service | Setting | Meaning |
|---|---|---|
| brisvox | BRISVOX_FEATURES (build) | Cargo features of the image; must include webrtc for browsers. |
| brisvox | BUILD_JOBS (build) | Optional cap on rustc jobs. |
| brisvox | PORT | The port the platform routes its domain to; equals the domain's target port. |
| brisvox | BRISVOX_AGENT_DEFINITION or BRISVOX_AGENT_FILE | The agent definition's text, or the path of a baked-in file (not both). |
| brisvox | BRISVOX_WEBRTC_ADDR | 0.0.0.0:${PORT}: page, POST /whip and the /ws fallback on the public port. |
| brisvox | BRISVOX_WEBRTC_UDP_ADDR | 127.0.0.1:0: a media socket nobody can reach. |
| brisvox | BRISVOX_SESSION_SECRET or BRISVOX_WEBRTC_TOKEN | Credentials for starting a call (required on a non-loopback address). |
| brisvox | BRISVOX_WEBRTC_CORS_ORIGIN | The page's origin when it is not served by this service. |
| brisvox | BRISVOX_TRUSTED_PROXIES, BRISVOX_CLIENT_IP_HEADER | See "Client addresses and the proxy"; unset by default. |
| brisvox | BRISVOX_WEBRTC_OFFERS_PER_MINUTE, BRISVOX_WEBRTC_MAX_CALLS_PER_IP, BRISVOX_WEBRTC_HTTP_MAX_PER_IP | Per-address limits; relaxed when the proxy's header cannot be trusted. |
| brisvox | BRISVOX_MAX_SESSIONS, BRISVOX_PROVIDER_LIMITS | Admission and per-provider concurrency. |
| brisvox | provider keys | The variables the agent file's api_key_env names. |
| brisvox | BRISVOX_AGENT_URL, BRISVOX_AGENT_TOKEN | The SDK agent (second service) and its bearer token. |
| brisvox | BRISVOX_METRICS_TOKEN | Only if you scrape /metrics on the health address. |
| platform | drain time | At least 45 s between SIGTERM and SIGKILL. |
| tools | BRISVOX_AGENT_TOKEN | The same token. |
| tools | your own | Database 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
Dockerfilein the repository root when there is one (the build log says "Using detected Dockerfile"; another path: the variableRAILWAY_DOCKERFILE_PATH). Service variables reach a Docker build only when the Dockerfile declares them withARG, soBRISVOX_FEATURESandBUILD_JOBSare 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'sRAILWAY_RUN_UID=0is the documented workaround for the uid 10001 of the image) and makes a redeploy interrupt the service. PORT. Railway injectsPORTand 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 setPORTyourself when you use a domain target port. SetPORT=8080and 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:8080written out works too).- Health check. Railway probes the service's
PORT, only at deploy time, from the hosthealthcheck.railway.app; set the path to/demo. Not run against Railway's probe: unverified. - Client addresses. Railway documents
X-Real-IPas the client-address header and overwrites a client-sent one; it does not listX-Forwarded-For, publishes no address range for its proxies and says the100.xpeer addresses seen in practice are not a contract (staff answer, 2026-10). So option 1 above is the safe default; for option 2 useBRISVOX_CLIENT_IP_HEADER=x-real-ipand, for example,BRISVOX_TRUSTED_PROXIES=100.64.0.0/10(the CGNAT block the reported100.xedge peers fall in; unverified as a contract: re-check after Railway changes). Requests over*.railway.internaldo not pass the edge, and the edge peers seen so far were100.xaddresses, 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
SIGTERMandSIGKILL. SetRAILWAY_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}}:8765andBRISVOX_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 to0.0.0.0alone can be unreachable in an old environment: bind::. - Checklist. Domain target port 8080; health check path
/demoon brisvox and none on tools; one replica; draining seconds set; the agent URL uses.railway.internalof 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"):
| check | result |
|---|---|
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 Dockerfile | clean after the two intended DL3008 (unpinned apt packages) were annotated with # hadolint ignore=DL3008 |
docker compose -f deploy/compose.yaml config | valid; both services resolve |
up agent-ws | starts, (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 open | the 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 call | 0.67 s, exit code 0 |
up agent-twilio with empty keys | fails fast with TWILIO_AUTH_TOKEN is required (the restart policy then retries: set the env file first) |
up agent-twilio with placeholder keys | starts, healthy, Twilio listener on :8082, CallControls { twilio: true }, health on host port 8083 |
--cpus=4 | the inference pool takes 2 threads (/readyz pressure.threads), unlimited: 16 on the 32-core host |
| open-file limit | Docker'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 mounted | clean 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.
SideEffecttools 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
| Listener | Plain or TLS | Protection |
|---|---|---|
WebRTC signaling (BRISVOX_WEBRTC_ADDR), including its WebSocket fallback at BRISVOX_WEBRTC_WS_PATH | plain 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 HTTP | probes carry counts only; /metrics behind a token off loopback |
Callback (BRISVOX_CALLBACK_ADDR) | plain HTTP | bearer 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:
| Listener | What satisfies the check |
|---|---|
| Generic WebSocket | BRISVOX_SESSION_SECRET, or BRISVOX_WS_TOKEN |
| RTVI | BRISVOX_SESSION_SECRET, or BRISVOX_RTVI_TOKEN |
| WebRTC signaling | BRISVOX_SESSION_SECRET, or BRISVOX_WEBRTC_TOKEN |
| Telnyx | BRISVOX_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:
| Field | Type | Meaning |
|---|---|---|
aud | string | brisvox-webrtc, brisvox-rtvi or brisvox-ws: which listener the token is for. A token for one is refused by the others. |
exp | integer | Unix 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). |
nbf | integer, optional | Unix seconds. Refused before it (default 0). Must be before exp. |
sid | string | Your 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. |
agent | string, optional | The agent you intend; reaches the call's metadata (session.agent). |
max_s | integer, optional | Longest 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. |
meta | object, optional | At 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
| Listener | How the client presents it |
|---|---|
| WebRTC | Authorization: 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 WebSocket | the 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
- Generate a new secret. Set it as
BRISVOX_SESSION_SECRETand the old one asBRISVOX_SESSION_SECRET_PREV; restart. Tokens signed with either verify. - Point your backend at the new secret.
- 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:
- Offers per address per minute: a token bucket, default 5 (burst 5, one more every 12 s;
BRISVOX_WEBRTC_OFFERS_PER_MINUTE,offdisables). Over it:429withRetry-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 inrate_table_full), because failing closed there would let an attacker lock everyone out; the other limits still apply. - Calls in total (
BRISVOX_MAX_SESSIONS, twice that at the transport):503. - Calls still handshaking: a smaller pool, default a quarter of
BRISVOX_MAX_SESSIONS(a library user of the transport: a quarter ofmax_calls) (BRISVOX_WEBRTC_MAX_HANDSHAKING). A call leaves it when ICE and DTLS complete, or whenconnect_timeoutabandons it. Offers that never connect cannot take more than that share. Over it:503withRetry-After. - 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 withaudbrisvox-webrtc(the same token a page would send to/whip; no new audience). It travels inAuthorization: Bearer, the subprotocolbrisvox-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 asPOST /whipis, 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 the200is 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 aDELETEof 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 (untilexp+ 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
Originitself, to the same effect as the CORS header ofPOST: withBRISVOX_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'sHost(compared without the scheme). A request withoutOrigin(not a browser) is judged by its token alone. A proxy that rewritesHostmakes the default rule fail: setBRISVOX_WEBRTC_CORS_ORIGINto 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 thestartmessage), the call limit, andBRISVOX_MAX_SESSIONSwhen 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'smax_sends the call (stop{ended}); this is the one WebSocket path that enforces it (the standaloneBRISVOX_WS_ADDRlistener only reports it). - Bounds. HTTP head 8 KiB and 64 fields,
Sec-WebSocket-Keya 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),startin 5 s, pings, bounded queues (docs/WEBSOCKET_PROTOCOL.md).BRISVOX_WEBRTC_WS_PATH=offcloses 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 theUpgradepass-through above). Media is UDP onBRISVOX_WEBRTC_UDP_ADDR; forward it and setBRISVOX_WEBRTC_CANDIDATE_IP. Serve the page from the proxied origin, or setBRISVOX_WEBRTC_CORS_ORIGINto the page's exact origin (never*without credentials: the server refuses that). - Pass the client address in
X-Forwarded-Forand list the proxy inBRISVOX_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
| Variable | Default | Meaning |
|---|---|---|
BRISVOX_SESSION_SECRET | unset | Signs session tokens (at least 32 bytes). Enables them on the generic WebSocket, RTVI and WebRTC listeners. |
BRISVOX_SESSION_SECRET_PREV | unset | The previous secret, still verified during a rotation. Needs BRISVOX_SESSION_SECRET. |
BRISVOX_WS_TOKEN | unset | Static token of the generic WebSocket listener (development). |
BRISVOX_INSECURE_LISTENERS | false | Allow non-loopback listeners without authentication, and an open /metrics there. |
BRISVOX_TRUSTED_PROXIES | empty | CIDR list of reverse proxies whose forwarding header is believed (WebRTC). Never 0.0.0.0/0. |
BRISVOX_CLIENT_IP_HEADER | x-forwarded-for | The only forwarding header believed behind a trusted proxy: x-forwarded-for, forwarded or x-real-ip (one address). |
BRISVOX_WEBRTC_HTTP_MAX_PER_IP | 8 | Signaling connections per client address at once (0 = no limit); trusted proxies are exempt. |
BRISVOX_WEBRTC_MAX_CALLS_PER_IP | 8 | Calls per client address at once, or off. |
BRISVOX_WEBRTC_MAX_HANDSHAKING | a quarter of BRISVOX_MAX_SESSIONS | Calls in their ICE/DTLS handshake at once. |
BRISVOX_WEBRTC_OFFERS_PER_MINUTE | 5 | Offers per client address per minute, or off. |
BRISVOX_METRICS_TOKEN | unset | Bearer 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"):
| File | Holds | On by default once the directory is set |
|---|---|---|
call-<unix start>-<session>.transcript.jsonl | A header line, one line per user turn and per bot response, an end line. | yes |
call-<unix start>-<session>.caller.wav | The caller's audio, mono PCM16, at the call's own rate (8 kHz telephony, 16 kHz websocket, ...). | no |
call-<unix start>-<session>.bot.wav | The 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_msis milliseconds since the recording of the call started, on the server's clock.userlines hold the whole text the LLM answered for that turn (spoken finals and typed text); interim hypotheses are not recorded.botlines 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_lostcounts conversation events the writer fell too far behind for (a stalled disk). Normally0.
What a bot line holds
heard | Written when | text |
|---|---|---|
full | the transport reported the response played to its end (BotStoppedSpeaking), or the engine's answer says the whole round was played | the response's text ("cut": true if a very long one was truncated at 32 KiB) |
partial | the response was interrupted ("interrupted": true) and the engine settled what was played | the 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 |
unknown | the 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 settled | empty. 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.wavwrites 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
.agefile 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.ageexactly likeX: the sweeper deletescall-*.transcript.jsonl.age,call-*.caller.wav.ageandcall-*.bot.wav.ageby 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:
- Environment variables (
docs/OPERATIONS.mdhas the full table). An invalid value, or a recording variable set withoutBRISVOX_RECORDING_DIR, stops the start with a message naming the variable: a privacy setting is never silently ignored. - 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 noBRISVOX_RECORDING_DIRis an error at startup.
| Variable | File key | Default | Meaning |
|---|---|---|---|
BRISVOX_RECORDING_DIR | - | unset (nothing recorded) | Directory for the files; created at startup. Use a directory dedicated to the recordings. |
BRISVOX_RECORDING_TRANSCRIPTS | transcript | true | Write the transcript. |
BRISVOX_RECORDING_AUDIO | audio | false | Write the two WAV files. Audio cannot be redacted. |
BRISVOX_RECORDING_REDACT_PII | redact | true | Redact the transcript text before it is written. |
BRISVOX_RECORDING_REDACT_KINDS | redact_kinds | all six | Which kinds the redaction replaces: email,iban,id,card,phone,number. |
BRISVOX_RECORDING_RETENTION | retention_days | 30d | Delete 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 | - | 1h | How often the sweeper runs (at least 1m). |
BRISVOX_RECORDING_MAX_MINUTES | - | 120 | Longest 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:
| What | Rule | Placeholder |
|---|---|---|
| Email address | local@domain.tld | [EMAIL] |
| IBAN | two 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 number | 13 to 19 digits (spaces, dashes, dots between groups), Luhn checksum | [CARD] |
| Phone number | 7 to 15 digits (leading +, spaces, dashes, dots, parentheses) | [PHONE] |
| Any other long number | 16 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.wavandbot.wavcontain 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):
| Variable | Default | Meaning |
|---|---|---|
BRISVOX_LOG_REDACT_PII | false | Also 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_KINDS | email,iban,id,card,phone | Narrow 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
Byteshandle 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 asunknown. - 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.
Checklist for an operator (a prompt for your counsel, not legal advice)
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.
- 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.
- 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. - 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.
- 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).
- 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.
- 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).
- 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).
- Retention. Set
BRISVOX_RECORDING_RETENTIONto what you can justify.foreveris 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.rswith one#[non_exhaustive] pub enum Errorbuilt withthiserror, pluspub type Result<T, E = Error>. Both are re-exported at the crate root. brisvox_core::Errorholds the cross-cutting cases (Cancelled,Timeout { what, after },ChannelClosed). Every other crate wraps it asError::Core(#[from] brisvox_core::Error)(#[error(transparent)]), so?converts it without extra code.- No
anyhow/eyrein library crates. Binaries report errors witheprintln!+ExitCode. - Error messages never contain credentials. Provider error
detailstrings are written by our adapters and must not echo request headers or URLs that carry keys. Cancelledis 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:
| Span | Constructor | Level | Fields | Lifetime |
|---|---|---|---|---|
session | session_span(id) | INFO | id | whole session (root of all session work) |
turn | turn_span(epoch) | INFO | epoch | one turn; the epoch is the interrupt epoch |
stage | stage_span(name) | DEBUG | name ("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>. ItsDebugandDisplayprint[REDACTED], so logging a config struct with?cfgis safe. - Read the value only with
Secret::expose()where it is sent to its destination. Grep forexpose()when auditing. Secrethas noPartialEq/Deref/AsRef/Hash:==on credentials is not constant-time, and implicit access would bypassexpose(). When serde arrives: implementDeserializeonly; anySerializemust write[REDACTED].- Test
telemetry::tests::secret_is_redacted_in_tracing_outputproves secrets do not leak through%,?, nestedDebugand format arguments.
Subscriber
Binaries call brisvox_server::telemetry::init(format) once at startup:
- Filter from
RUST_LOG(defaultinfo; unset or blank both mean the default). An invalid or non-unicodeRUST_LOGis a startup error, not a silent fallback. - The TURN client crates' events and spans (
turn_client_proto,stun_proto,turn_types) belowwarnare dropped by a per-layer filter in front of the output, whateverRUST_LOGsays (they print whole STUN messages attrace);BRISVOX_LOG_TURN_CLIENT=1lifts it. Embedders with their own subscriber applybrisvox_transport::webrtc::turn_client_log_allowed(orbrisvox_core::telemetry::turn_client_log_allowed) themselves. - The SIP stack's events and spans (
rsipstack) belowwarnare held back the same way, whateverRUST_LOGsays: the stack logs every SIP message atinfo, whole, which on a secure call includes the SDES SRTP master keys.BRISVOX_LOG_SIP_STACK=1lifts it (debugging only); embedders applybrisvox_core::telemetry::sip_stack_log_allowed. - Format from
BRISVOX_LOG_FORMAT:text(default) orjson(one object per line). - A second
init/installreturnsError::Telemetryand 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
tracingspans. The per-stage marks (brisvox_core::timeline) already hold every stage's start and end on the call'sClock. When a turn finishes, theTimelineSinkof the call's recorder turns itsTurnRecordinto one OpenTelemetry trace (aturnroot and a child span per stage) and the stage latencies into Prometheus histograms. Thesession/turn/stagespans 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_microsecondsmeasures the whole observed turn, 11 marks included. - Exporters implement
TraceExporterand must not block.MemoryExporter(bounded) is for tests and embedders;OtlpExporter(featureotlp) queues to a bounded channel (drops the newest when full, counted) and posts OTLP/HTTP JSON in batches under a timeout. - Redaction.
telemetry::initinstalls the subscriber with aRedactingMakeWriter: 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.Secretremains the first line;observability::tests::secrets_never_appear_in_{text,json}_logsrun the real subscriber. A new place that writes logs by another route (a custom writer) must use the same wrapper. WithBRISVOX_LOG_REDACT_PII=truethe 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-lunawith reasoning effortnonepinned 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:
| Stage | p50 | p95 | Budget at p95 |
|---|---|---|---|
end of turn to LLM request (turn_end_to_llm_request) | 0.051 ms | 0.089 ms | 3 ms, met |
first TTS audio to transport (tts_audio_to_transport) | 0.035 ms | 0.064 ms | 2 ms, met |
barge-in decision to Clear applied (interrupt_to_clear) | 0.012 ms | 0.022 ms | 1 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.
| scope | turns | p50 | p90 | p95 | p99 |
|---|---|---|---|---|---|
| all fixtures | 60 | 1009.6 ms | 1030.2 ms | 1034.3 ms | 1057.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.
| calls | answered | failed | overhead p50 | overhead p99 | overhead max | CPU cores | peak RSS | RSS per call |
|---|---|---|---|---|---|---|---|---|
| 100 | 300/300 | 0 | 0.077 ms | 0.842 ms | 1.231 ms | 0.28 | 22.4 MiB | 142 KiB |
| 300 | 900/900 | 0 | 0.130 ms | 1.965 ms | 2.321 ms | 0.47 | 48.8 MiB | 133 KiB |
| 500 | 1500/1500 | 0 | 0.173 ms | 1.675 ms | 6.038 ms | 0.37 | 75.8 MiB | 132 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.
| speculation | turns | V2V p50 | extra LLM requests per turn |
|---|---|---|---|
| off | 120/120 | 923 ms | 0.00 |
| on, STT interims that already hold the full text | 120/120 | 782 ms | 0.00 |
| on, STT interims a word short of the final | 120/120 | 925 ms | 1.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
| File | What |
|---|---|
v2v-sim.md, v2v-sim.json | V2V and engine stage times over the websocket, mock providers |
v2v-mock-bot.md, v2v-mock-bot.json | The harness against a mock bot: harness and transport self-check |
sim-baseline.md, sim-500-1worker.md | In-process load, N simulated calls |
fixtures-sim.md | Whole simulated calls on real speech, server-side |
speculation-fixtures.md, v2v-speculation.md, v2v-speculation-gate.md | Speculative LLM start |
inference-pool.md, resampler.md, smart-turn.md | Component 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,VadStopandSttFinalize(latest beforeTurnEnd) andSttFinal(latest beforeLlmRequest): 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 marksInterruptedonoldand startsnewatomically (seeding itsUserSpeechStartwith 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 isNone, never negative. VADs backdateUserSpeechEndto the last speech frame (the fixtures' ground truth is compared against that).
| Event | Marked by | Exact moment |
|---|---|---|
UserSpeechStart | VAD | first VAD speech-start of the turn |
UserSpeechEnd | VAD | last speech frame before the turn-end decision (latest wins) |
SttInterim | STT adapter | first interim transcript received |
SttFinal | STT adapter | last final received before the LLM request (latest wins) |
TurnEnd | turn state machine | end-of-turn decision taken |
LlmRequest | LLM adapter | first 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) |
LlmFirstToken | LLM adapter | first text or tool-call delta received |
LlmFirstSentence | TTS segmenter | first speakable chunk emitted |
TtsRequest | TTS adapter | first text of the turn sent to TTS |
TtsFirstByte | TTS adapter | first audio byte received |
FirstAudioOut | output pacing/transport | first bot audio frame handed to the transport |
Interrupted | turn state machine (interrupt) | barge-in accepted |
AudioOutStopped | engine: the turn driver when its fast-path Clear is applied (else TransportOut at the Interrupt); reference: output pacing | bot playout flushed after the barge-in (first wins) |
VadStop | engine VAD worker (mark_open_turn) | the VAD confirmed the stop: the decision, not backdated |
SttFinalize | engine STT stage (mark_open_turn) | the Finalize for that stop handed to the provider session |
FirstChunk | engine segmenter stage | the 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 |
TtsFirstText | engine TTS stage | the 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 - UserSpeechEndstt_final_delay=SttFinal - UserSpeechEndllm_ttft=LlmFirstToken - LlmRequest;llm_first_sentence=LlmFirstSentence - LlmRequesttts_ttfb=TtsFirstByte - TtsRequestpost_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")
| Stage | Typical | Levers (task) |
|---|---|---|
| End-of-turn decision | 200–800 ms | adaptive policy, Smart Turn, STT signals |
| STT final | 100–300 ms | finalize on VAD stop |
| LLM first token | 200–500 ms | speculative start, warm connections, prompt caching |
| First speakable chunk | 20–150 ms | early-clause segmenter |
| TTS first byte | 100–250 ms | pre-opened WS contexts, cached phrases |
| Transport + buffers | 50–100 ms | 20 ms frames, minimal buffering, no extra resample |
Principles
- Overlap stages instead of waiting (speculation, streaming everywhere).
- Nothing is set up on the critical path (connections, TLS, model sessions are warm).
- Measure every stage of every turn (TurnTimeline) — no unmeasured claims.
- 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 hopsTurnEnd -> LlmRequestandTtsFirstByte -> 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 beforeTurnEnd(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).
| calls | answered | overhead p99 | wake p99 | CPU cores | peak RSS |
|---|---|---|---|---|---|
| 10 | 30/30 | 0.105 ms | 0.128 ms | 0.02 | 8.0 MiB |
| 100 | 300/300 | 0.075 ms | 0.117 ms | 0.06 | 20.2 MiB |
| 300 | 900/900 | 0.116 ms | 0.100 ms | 0.14 | 49.3 MiB |
| 500 | 1500/1500 | 0.185 ms | 0.133 ms | 0.27 | 75.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):
| calls | overhead p99 | wake p99 | wake max | CPU cores | peak RSS MiB |
|---|---|---|---|---|---|
| 1000 | 0.276 / 0.235 | 0.121 / 0.104 | 0.265 / 0.631 | 0.49 / 0.44 | 135 / 135 |
| 2000 | 0.442 / 0.348 | 0.148 / 0.204 | 0.598 / 5.98 | 0.81 / 0.66 | 263 / 263 |
| 4000 | 0.665 / 0.724 | 0.536 / 0.574 | 1.51 / 15.3 | 1.25 / 1.22 | 562 / 599 |
| 6000 | 1.040 / 0.910 | 1.326 / 1.002 | 9.37 / 4.59 | 1.73 / 1.64 | 804 / 823 |
| 8000 | 1.300 / 1.119 | 6.235 / 2.008 | 13.4 / 4.61 | 2.25 / 2.17 | 1230 / 1043 |
| 10000 | 1.497 / 1.492 | 17.848 / 19.551 | 26.7 / 36.4 | 2.76 / 2.75 | 1485 / 1298 |
| 12000 | 1.863 / 1.592 | 29.479 / 24.387 | 38.7 / 31.2 | 3.10 / 3.09 | 1740 / 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, notspawn_blockingor 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 block | delay | 2.4 kHz | 3.0 kHz | 3.4 kHz | 3.6 kHz |
|---|---|---|---|---|---|
| 5 ms (40 samples) | 2.5 ms | -2.1 dB | -18 dB | -45 dB | -66 dB |
| 10 ms (80) | 5 ms | 0.0 | -0.1 dB | -10 dB | -29 dB |
| 20 ms (160) | 10 ms | 0.0 | 0.0 | 0.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.
| source | sinc length | delay (FFT: 10 ms) | gain 3.4 kHz | gain 3.6 kHz | 4.4 kHz alias | CPU per 20 ms |
|---|---|---|---|---|---|---|
| 16 kHz | 192 | 6.0 ms | -0.05 dB | -1.70 dB | -140 dB | 5.5 µs |
| 22.05 kHz | 224 | 5.0 ms | -0.07 dB | -1.39 dB | -127 dB | 7.2 µs |
| 24 kHz | 224 | 4.6 ms | -0.12 dB | -1.61 dB | -102 dB | 6.2 µs |
| 44.1 kHz | 384 | 4.25 ms | -0.03 dB | -0.63 dB | -64 dB | 10.2 µs |
| 48 kHz | 512 | 5.25 ms | -0.00 dB | -0.17 dB | -82 dB | 12.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::newdocs): 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. Beyondmax_lagthe pacer resyncs to the clock, counts one underrun and restarts the grid (300 ms stall: exactly one underrun, then consecutive 20 ms frames).PacerMetricsreports 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 exceedmax_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_untilcalls on an already-due deadline, including the turn-end hold (aTtsEndedthat 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 firstnow()). - One timer per session: the 10 000-frame test counts all
sleep_untilcalls: 2 in total, the pacer's single pinnedSleep(re-armed withreset) 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 instale_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
TtsEndedis 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 reportsTtsEndedupstream; 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 releasedphase_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
waitalso checks the interrupt queue, which mirrors the framework's own check inpush. Removing the fast-path check changes no observable output, sincepushdrops 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 cost | offered load | paced wait p99 | timers wait p99 | outcome |
|---|---|---|---|---|
| 100 µs | 0.39 | 0.11 ms | 4.2 ms | all served |
| 200 µs | 0.78 | 0.62 ms | 11.4 ms | all served |
| 250 µs | 0.98 | 1.14 ms | 14.4 ms | all served |
| 280 µs | 1.09 | 73 ms | 34 ms | paced: 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
timersrows 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 it1. Buy milk\n2. Call mombecomes 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 milkpauses afterlist:); a line that ends in a terminator is left to the terminator rules, so1.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.
| Lang | First chunk | Boundary token | Emitted at |
|---|---|---|---|
| EN | Sure, I can help you with that. | . (token 8) | token 9 (+30 ms) |
| EN | The forecast is rain, | , (token 4) | token 5 (+30 ms) |
| EN | Hi! How can I help today? | ? (token 7) | token 8 (+30 ms) |
| TR | Merhaba, bugün size nasıl yardımcı olabilirim? | ? (token 7) | token 8 (+30 ms) |
| TR | Tabii ki, hemen bakıyorum. | . (token 5) | token 6 (+30 ms) |
| TR | Toplam 1.000 kişi geldi. | . (token 6) | token 7 (+30 ms) |
| TR | Oğ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):
| Input | Median | p99 | Worst |
|---|---|---|---|
| 120 sentences (EN / TR config) | 0.2 us | 0.7-1.1 us | 1-4 us |
| 150 repetitions of a long clause without punctuation | 0.3-0.4 us | 0.9-1.1 us | 2-10 us |
| 12,000 chars of space-newline pairs between two letters | 0.6 us | 0.6-1.3 us | 1-7 us |
| 12,000 chars without any whitespace (one token) | 1.1-1.2 us | 2.6-2.8 us | 15-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):
| Test | Bound | Catches |
|---|---|---|
a_typical_chunk_costs_microseconds_not_milliseconds | 328-char mixed chunk, mean under 2 ms in release (200 times the measurement), 20 ms in debug | a constant-factor blow-up (a Vec<char> rebuilt per token) |
a_digit_dense_inline_chunk_stays_within_the_worker_budget | 256-char digit-dense chunks of five shapes, mean under 500 us in release (10 times the 50 us rule), 10 ms in debug | a 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_input | 16 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 threads | median | p90 | CPU per call (upper bound) |
|---|---|---|---|
| 1 | 25.9 ms | 27.1 ms | ~26 ms |
| 2 | 16.8 ms | 18.8 ms | ~34 ms |
| 4 | 12.6 ms | 13.6 ms | ~50 ms |
| 8 | 9.9 ms | 10.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_silence | hint delay | falls | stops | paired | same verdict | max abs dp | hint-to-stop | wasted |
|---|---|---|---|---|---|---|---|---|
| 100 ms | 0 / 32 ms | 7 | 6 | 6 | 6 | 0.007 | 96 ms | 1 |
| 100 ms | 64 ms | 6 | 6 | 6 | 6 | 0.050 | 60 ms | 0 |
| 200 ms | 64 ms | 6 | 6 | 6 | 6 | 0.022 | 160 ms | 0 |
| 300 ms | 64 ms | 6 | 6 | 6 | 6 | 0.065 | 260 ms | 0 |
| 500 ms | 64 ms | 6 | 6 | 6 | 6 | 0.056 | 443 ms | 0 |
(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):
| mode | mean stop-to-verdict | analyses started | early hints / started / reused / ready / wasted |
|---|---|---|---|
| base | 23.8 ms (22.9-25.1) | 6 | - |
| early | 0.0 ms | 6 (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 VADmin_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 datedlast 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) andVadSetup::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 Responsesgpt-6-luna, Cartesiasonic-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--fakemode (no keys) is the Pipecat-side self-check: 423-457 ms over a ~400 ms floor (Windows, 2026-10-13). Its--mock-providersmode 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_callbehind the real websocket transport, in process, with its heavy work on a real inference pool; only the providers arebrisvox_simmocks 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:MSif 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-botkeeps the original mock bot (energy end-of-turn afterendpointof silence, thendelay, floorendpoint + 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):target floor p50 p90 p99 (= max) excess over the floor p50 / p90 / p99 engine over mock providers 950 ms 1010 ms 1030 ms 1058 ms 60 / 80 / 108 ms mock bot (harness + transport) 800 ms 828 ms 846 ms 858 ms 28 / 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.
- end of turn = VAD stop + STT final (the turn driver holds the end for the final), or the
fixed silence of
-
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) | turns | p50 | p90 | p95 | max |
|---|---|---|---|---|---|
| brisvox, first 3 calls (as specified) | 6 | 1580 | 2102 | 2102 | 2102 |
| Pipecat, first 3 calls (as specified) | 6 | 2173 | 2588 | 2588 | 2588 |
| brisvox, 15 calls pooled | 30 | 1686 | 2203 | 2927 | 4287 |
| Pipecat, 15 calls pooled | 30 | 2029 | 2370 | 2544 | 2588 |
| brisvox, Turkish, 3 calls | 6 | 1994 | 3031 | 3031 | 3031 |
| Pipecat, Turkish, 3 calls | 6 | 2519 | 3477 | 3477 | 3477 |
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):
| stage | brisvox | Pipecat |
|---|---|---|
| STT final after the end of speech | 285 / 319 / 320 | 420 / 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 / 250 | 211 / 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
Finalizeat 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
OpenAILLMServiceclient, 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-eyeballsrestores Pipecat as shipped); the numbers above are with it on. On a host with working IPv6 this does not matter. - Reasoning.
openai/gpt-6-lunathinks by default (about +1 s to the first token, ~20 reasoning tokens for a one-line answer); both systems sendreasoning_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), OpenRouteropenai/gpt-6-luna(Chat Completions, streaming,max_tokens120,reasoning_effortnone, the lowest this model accepts: itssupported_effortsare max, xhigh, high, medium, low, none), upstream pinned with{"provider": {"order": ["openai"], "allow_fallbacks": false}}(openaiis the model vendor's own standard-tier endpoint), Deepgram Auraaura-2-thalia-en(TTS). Agent filebench/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_auraadapter,wss://api.deepgram.com/v1/speak,encoding=linear16&sample_rate=24000&model=aura-2-thalia-en, aSpeakper text chunk and oneFlushper turn. Pipecat:DeepgramTTSService, the same URL and parameters, aSpeakper sentence and aFlushwhen the LLM response ends. The reply is one sentence, so both send oneSpeakand oneFlushand Aura starts synthesising at the flush. Differences that remain: brisvox's text chunking and its stall deadline; Pipecat's TTS TTFB timer starts at itsSpeak. Neither sendsspeed. - The upstream was checked per response. brisvox logs
llm upstream(theproviderandmodelfields of the first stream chunk), the Pipecat baselinePCUPSTREAM. brisvox: 100 of 100 requests reported upstream OpenAI, modelopenai/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 calls | turns | p50 | p90 | p95 | p99 | max | mean |
|---|---|---|---|---|---|---|---|
| brisvox | 100 | 1846 | 2209 | 2876 | 5199 | 5235 | 1937 |
| Pipecat | 100 | 1972 | 2980 | 4348 | 6141 | 7529 | 2343 |
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):
| stage | brisvox | Pipecat |
|---|---|---|
| STT final after the end of speech | 300 / 337 / 363 / 376 | 455 / 499 / 505 / 521 (TTFB 355 / 399 / 405 / 421 from the VAD stop, plus its 100 ms) |
| LLM first token (OpenRouter included) | 931 / 1311 / 1984 / 4282 | 889 / 1381 / 3483 / 3892 |
| TTS first audio (Deepgram Aura) | 220 / 395 / 416 / 432 | 251 / 259 / 265 / 270 |
| V2V minus (STT final + LLM first token + TTS first audio; Pipecat: plus the 100 ms VAD stop) | 300 / 556 / 681 | 398 / 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
Finalizeat 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.mdasks 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-enis 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 Nplays the plan's calls N at a time (live mode only; reports carrysettings.concurrency).- Pinned LLM route on both systems. Agent file keys
[llm.openai_compatible] openrouter_orderandopenrouter_allow_fallbackssend OpenRouter'sproviderrouting object ({"order": ["openai"], "allow_fallbacks": false}); the Pipecat baseline maps the same keys intoextra_bodyof its OpenAI client. Modelopenai/gpt-6-luna,reasoning_effortnone(the lowest the model accepts: max, xhigh, high, medium, low, none), upstreamopenai(the model vendor's own standard-tier endpoint). Verified on the wire: both systems log which upstream the first stream chunk names (brisvoxllm upstreamwithupstream_provider, the baselinePCUPSTREAM), 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):
| stage | from -> to | p50 | p95 | max | provider time in it | budget p95 |
|---|---|---|---|---|---|---|
vad_stop_to_finalize | VadStop -> SttFinalize | 0.006 ms | 0.015 ms | 0.017 ms | 0 | - |
turn_end_to_llm_request | TurnEnd -> LlmRequest | 0.051 ms | 0.089 ms | 0.212 ms | 0 | 3 ms: met |
llm_request_to_first_token | LlmRequest -> LlmFirstToken | 358.4 ms | 366.6 ms | 367.7 ms | 350 ms | - |
first_token_to_tts_text | LlmFirstToken -> TtsRequest | 0.025 ms | 0.051 ms | 0.089 ms | 0 | - |
tts_request_to_first_byte | TtsRequest -> TtsFirstByte | 158.4 ms | 166.7 ms | 167.1 ms | 150 ms | - |
tts_audio_to_transport | TtsFirstByte -> FirstAudioOut | 0.035 ms | 0.064 ms | 0.103 ms | 0 | 2 ms: met |
interrupt_to_clear | Interrupted -> AudioOutStopped | 0.012 ms | 0.022 ms | 0.669 ms | 0 | 1 ms: met |
turn_end_to_first_audio | TurnEnd -> FirstAudioOut | 517.7 ms | 533.2 ms | 533.4 ms | 500 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
Clearis 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.
VadStopis the VAD worker's decision (not the backdatedUserSpeechEnd),SttFinalizetheFinalizehanded to the provider session (both marked withTimelineRecorder::mark_open_turn);AudioOutStoppedis marked by the turn driver when its fast-pathClearreturns, 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):
| check | reference (Exact) | measured, production + hybrids | Budget |
|---|---|---|---|
| one turn: first bot audio | 1480 ms | 1480, 1495 (adaptive, prod.), 1650, 1665 (fixed 450 ms, prod.); since the phase was turned off, prod. is 1480 and 1650 | 1480 to 1700 ms |
| speech start / end marks | 200 / 1200 ms | 200 / 1200 ms | within 20 ms |
| end-of-turn delay | 280 ms | 280 (adaptive), 450 (fixed) | 280 to 470 ms |
| response latency | 280 ms | 280 (adaptive), 450 (fixed) | 280 to 470 ms |
| s01 first audio after the end of speech | 580 ms (min) | 1008 to 1079 ms | at least 580 ms |
| barge-in: interrupt at the output | 2420 ms | 2420 ms | 2400 to 2440 ms |
| barge-in / s02: time to silence | 0 | 0 | 0 to 50 ms (kept for a slow consumer) |
| s05 LLM first token behind a 2 s stall | 2000 ms | 2000 ms | 2000 to 2020 ms |
| playout log vs frames at the output | equal | 0 (677 checks) | within 20 ms |
| fixtures: first audio after each utterance end | at most 600 ms | 280 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).
| Case | Median | p99 | Worst | On a worker |
|---|---|---|---|---|
Segmenter::push_spans (4-12 byte delta) | 0.1 us | 0.4 us | 2.4 us | yes |
ResponseAssembler::push (text delta) | 0.0 us | 0.1 us | 0.4 us | yes |
ResponseAssembler::finish (2 KB, one call) | 1.6 us | 1.6 us | 3.6 us | yes |
ResponseAssembler::finish (4 KB = inline_finish_bytes, one call) | 2.5 us | 2.6 us | 4.7 us | yes |
PlayedText::heard (200 words) | 4.5 us | 4.7 us | 11.5 us | yes |
TurnMachine::handle (a turn's inputs) | 0.0 us | 0.1 us | 0.3 us | yes |
StreamResampler 8 kHz -> 16 kHz, 20 ms frame | 0.6 us | 0.7 us | 8.4 us | yes |
StreamResampler 48 kHz -> 16 kHz, 20 ms frame | 1.8 us | 1.8 us | 4.3 us | yes |
OutputFramer 24 kHz -> 16 kHz, 20 ms chunk | 1.2 us | 1.3 us | 2.8 us | yes |
OutputFramer 24 kHz -> 16 kHz, 100 ms chunk | 5.8 us | 6.0 us | 8.5 us | yes |
OutputFramer 24 kHz -> 8 kHz, 100 ms chunk (the stage's piece) | 30.5 us | 33.0 us | 40.5 us | yes |
OutputFramer 24 kHz -> 16 kHz, 500 ms chunk (the stage cuts it in 100 ms pieces) | 28.8 us | 31.3 us | 42.8 us | no |
RequestBuilder::build (build_window), 20 turns | 1.3 us | 1.4 us | 1.4 us | yes |
same, 380 messages (below INLINE_MAX_MESSAGES) | 2.3 us | 2.5 us | 4.8 us | yes |
same, 4 x 3.7 KB results (below INLINE_MAX_TURN_BYTES) | 2.8 us | 3.7 us | 4.9 us | yes |
Context::clone, 380 messages | 4.8 us | 4.9 us | 6.8 us | yes |
is_heavy, 380 messages | 0.3 us | 0.3 us | 0.3 us | yes |
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 frame | Median |
|---|---|
| resample + frame | 6.65 us |
| mu-law encode | 0.03 us |
| together | 6.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 after | p50 | p95 | max | turns over the 1.5 s SLO |
|---|---|---|---|---|
| 1 word | 969 ms | 1262 ms | 1366 ms | 0 of 200 |
| 2 words | 1137 ms | 1485 ms | 1592 ms | 9 of 200 |
| 3 words | 1137 ms | 1485 ms | 1592 ms | 9 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.
Agent link hop (measured 2026-10-13)
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):
| pattern | mean | p50 | p95 | p99 | max |
|---|---|---|---|---|---|
| back to back (the next event leaves when the agent answered) | 0.08-0.11 ms | <= 0.1 ms | <= 0.3 ms | <= 0.3 ms | 0.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 ms | 0.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):
| build | mean | p50 | p95 | p99 | max |
|---|---|---|---|---|---|
| release | 0.033 ms | <= 0.05 ms | <= 0.1 ms | <= 0.2 ms | 0.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 inbench/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_partialand 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_partialoff: 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 previousFinalize(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):
| policy | speculation | V2V p50 | p90 | p99 | delta p50 | turns committed | run hit rate | head start ms | extra LLM requests per turn |
|---|---|---|---|---|---|---|---|---|---|
| adaptive | off | 923 | 1106 | 1168 | - | - | - | - | 0.00 |
| adaptive | on, lagging interims | 925 | 1113 | 1244 | +2 | 0% | 0% | - | 1.00 |
| adaptive | on, converged interims | 782 | 911 | 1013 | -141 | 100% | 100% | 154 | 0.00 |
| adaptive | on, final only | 923 | 1106 | 1168 | +0 | - | - | - | 0.00 |
| fixed 450 ms | off | 963 | 1106 | 1183 | - | - | - | - | 0.00 |
| fixed 450 ms | on, lagging interims | 925 | 1113 | 1244 | -38 | 72% | 42% | 38 | 1.00 |
| fixed 450 ms | on, converged interims | 782 | 911 | 1013 | -181 | 100% | 100% | 181 | 0.00 |
| fixed 450 ms | on, final only | 923 | 1106 | 1168 | -40 | 72% | 100% | 38 | 0.00 |
| fixed 700 ms | off | 1202 | 1331 | 1433 | - | - | - | - | 0.00 |
| fixed 700 ms | on, lagging interims | 925 | 1113 | 1244 | -277 | 100% | 50% | 266 | 1.00 |
| fixed 700 ms | on, converged interims | 857 | 954 | 1013 | -345 | 100% | 100% | 420 | 0.00 |
| fixed 700 ms | on, final only | 925 | 1106 | 1168 | -277 | 100% | 100% | 266 | 0.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).
| policy | speculation | V2V p50 | delta p50 | extra LLM requests per turn | wasted output tokens per turn | wasted input tokens per turn (reported) |
|---|---|---|---|---|---|---|
| adaptive | off | 677 | - | 0.00 | 0.00 | 0.0 |
| adaptive | on, lagging interims | 678 | +1 | 1.00 | 1.95 | 17.4 |
| adaptive | on, converged interims | 572 | -105 | 0.00 | 0.00 | 0.0 |
| fixed 700 ms | off | 950 | - | 0.00 | 0.00 | 0.0 |
| fixed 700 ms | on, lagging interims | 844 | -106 | 1.00 | 1.95 | 17.4 |
| fixed 700 ms | on, final only | 844 | -106 | 0.00 | 0.00 | 0.0 |
Real time over the websocket (32 turns per row, V2V in ms, client side).
| policy | speculation | V2V p50 | p90 | p99 | delta p50 | run hit rate | head start ms | extra LLM requests per turn |
|---|---|---|---|---|---|---|---|---|
| adaptive | off | 1005 | 1026 | 1047 | - | - | - | - |
| adaptive | on, lagging interims | 1003 | 1020 | 1036 | -3 | 0% | - | 1.00 |
| adaptive | on, converged interims | 849 | 875 | 887 | -156 | 100% | 155 | 0.00 |
| fixed 700 ms | off | 1263 | 1282 | 1297 | - | - | - | - |
| fixed 700 ms | on, lagging interims | 1011 | 1038 | 1055 | -252 | 50% | 253 | 1.00 |
| fixed 700 ms | on, converged interims | 903 | 921 | 938 | -361 | 100% | 410 | 0.00 |
| fixed 700 ms | on, final only | 1009 | 1032 | 1061 | -255 | 100% | 261 | 0.00 |
| fixed 450 ms | off | 1017 | 1042 | 1046 | - | - | - | - |
| fixed 450 ms | on, lagging interims | 1006 | 1020 | 1030 | -11 | 44% | 16 | 1.00 |
| fixed 450 ms | on, converged interims | 845 | 869 | 882 | -172 | 100% | 168 | 0.00 |
| fixed 450 ms | on, final only | 1002 | 1028 | 1043 | -14 | 100% | 15 | 0.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 onlynever 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.
- 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). - 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.
- 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.
- 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 = falsein the server configuration). It costs no request when the caller does not resume and returns the silence beyond the final. - Adaptive: enable
on_partialper 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
VadStartcancels 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):
| pause | converged: no gate / 100 ms / 200 ms | lagging: no gate / 100 ms / 200 ms | final only |
|---|---|---|---|
| 350 ms | 1.00 / 0.50 / 0.04 | 2.04 / 1.48 / 0.32 | 0.04 |
| 400 ms | 1.00 / 1.00 / 0.30 | 2.28 / 2.21 / 0.58 | 0.28 |
| 600 ms | 1.00 / 1.00 / 1.00 | 2.98 / 2.90 / 1.48 | 0.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):
| policy | converged: no gate / 100 / 200 ms | lagging: no gate / 100 / 200 ms | final 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 juston_partial = falsewith 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_partialonce the live hit rate is known); a deployment that wantson_partialand has callers who pause mid-sentence can setpartial_stable_msto 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 SLO | median | first step that failed | p95 V2V at the max | server CPU at the max | CPU per call | RSS peak at the max |
|---|---|---|---|---|---|---|---|
| brisvox | 2100, 2100, 2200 | 2100 | 2200 or 2300 (p95 1250-1278 ms) | 1142-1188 ms | 2.9-3.0 cores | 1.4 mcores | 1.84-1.88 GB |
| Pipecat 1.12.0, 4 processes, defaults | 70, 70, 70 | 70 | 80 (p95 1256-1376 ms; once 32 turns talked over) | 1064-1097 ms | 2.86-2.88 cores | 41 mcores | 1.58-1.60 GB |
Pipecat 1.12.0, 4 processes, OMP_NUM_THREADS=OPENBLAS_NUM_THREADS=MKL_NUM_THREADS=1 ("tuned") | 70, 90, 90 | 90 | 80 or 100 (p95 2.6 s and 9.4 s, or 163 of 400 turns without an answer) | 1036-1060 ms | 2.0-2.45 cores | 27-29 mcores | 1.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 calls | brisvox | Pipecat, defaults | Pipecat, tuned |
|---|---|---|---|
| 1 | 996, 0.02 | 1028, 0.08 | 1027, 0.05 |
| 20 | 1041, 1.03 | 1039, 0.67 | |
| 40 | 1069, 1.95 | 1105, 1.25 | |
| 50 | 1001, 0.14 | ||
| 70 | 1097, 2.88 | 1036, 2.03 | |
| 80 | 1377, 2.90 (fail) | 2613, 2.15 (fail) | |
| 200 | 1001, 0.38 | ||
| 800 | 1002, 1.25 | ||
| 1600 | 1019, 2.43 | ||
| 2000 | 1151, 2.94 | ||
| 2100 | 1142, 2.98 | ||
| 2200 | 1278, 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.pydoes 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):
- The file-descriptor limit. Docker gives a container a soft
RLIMIT_NOFILEof 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" whileaccept()returnedEMFILE, 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 iscapacity-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). - The resource probe double-counted on Linux.
brisvox-bench capacity --pidsummed the CPU time and resident memory of every entry of the process tree, and on Linuxsysinfolists 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.rsnow skips thread entries (thread_kind()); after the fix the server reads as 1 process and its RSS agrees with the cgroup (memory.peak605 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. - 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, thenofilelimit, a repeat count and a bench build capped at 4 jobs; fixed inbench/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:
| system | runs | median | at the max |
|---|---|---|---|
| brisvox, Smart Turn executor (default) | 325, 325, 325 | 325 | p95 957-1185 ms, 2.5-2.6 cores, RSS ~425 MB |
brisvox, shared pool (--turn-threads 0) | 300, 275, 300 | 300 | p95 985-1125 ms, 2.3-2.5 cores, RSS ~380 MB |
| Pipecat 1.12.0 with its models, tuned | 20, 15, 20 | 20 | p95 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.7 | runs | median | at the max |
|---|---|---|---|
tuned (turn-detector unlikely_threshold 0.05, load_threshold 1.0) | 22, 23, 25 | 23 | p95 1171-1192 ms, 2.1-2.2 cores (about 90 mcores per call), 2.9-3.3 GB (PSS) |
| defaults | none | 0 | p95 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
VmRSSwould 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 max | PSS at the max | PSS 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 MB | 4190, 3981, 3073 MB | 210, 199, 205 MB |
| LiveKit Agents 1.8.7, tuned (2026-10-14 runs, 47-53 processes) | 22, 23, 25 | 18012, 18692, 20232 MB | 2935, 3023, 3269 MB | about 130 MB |
| brisvox cross-check (1 process, default layout, one run) | 350 | 432 MB | 429 MB | 1.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:
| system | runs (max calls within the SLO) | median | at the max | per call |
|---|---|---|---|---|
| brisvox | 250, 200, 125 | 200 | p95 831-915 ms, 1.6-2.0 cores, RSS 300-390 MB | ~8-9 mcores, ~1.5 MB |
| Pipecat (tuned) | 20, 20, 5 | 20 | p95 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 SLO | first failing step | p95 V2V at the max | server CPU at the max | RSS peak at the max |
|---|---|---|---|---|---|
| 2 pool threads (the 4-CPU default) | 200, 200, 200 | 225 (p95 1.46-1.80 s) | 1009, 885, 885 ms | 2.40, 2.26, 1.99 cores | 280 MB |
| 4 pool threads (one run, ablation) | 175 | 200 (p95 1.76 s) | 889 ms | 3.21 cores | 348 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):
| stage | brisvox | Pipecat | brisvox - Pipecat |
|---|---|---|---|
| end of speech -> VAD stop decision (audio time, 20 ms frames) | 150 ms | 110 ms | +40 |
| VAD stop -> STT final (mock 150 ms + timer) | 151.4 | 151.2 | +0.2 |
| STT final -> end of turn (the Smart Turn verdict is already in) | 0.0 | 0.2 | -0.2 |
| end of turn -> LLM first token (mock 350 ms) | 351.4 | 351.0 | +0.4 |
| LLM first token -> TTS first audio (mock 150 ms) | 151.1 | 151.1 | 0.0 |
| TTS first audio -> first frame handed to the websocket | 0.1 | 21.1 | -21.0 |
| websocket -> client arrival (V2V minus the rows above) | 1.8 | 3.0 | -1.2 |
| V2V at the client | 805.8 | 787.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:
| configuration | first turn | second turn |
|---|---|---|
Pipecat stop_secs 0.1 (as mapped: 3 windows) | 808.3 | 787.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.1 | 805.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:
- VAD hangover in windows.
VadParams::default().min_silenceof 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 aFinalizeto 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/andfixtures/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_delayfollowmin_silence. - Pacer phase. Done (below): the first frame leaves at once; the phase is an opt-in.
- The comparison. The mapping
stop_secs = 0.1is nominally equal and effectively 3 windows against 4. For a table that names an equal effective hangover runbot.py --vad-stop-ms 128or 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"):
| turn | before (phase on) mean / p50 | after (default) mean / p50 | change of the mean |
|---|---|---|---|
| first turn of a call | 1015.8 / 1011.9 ms | 1001.6 / 1003.5 ms | -14.2 ms |
| second turn (control, no phase before or after) | 1001.0 / 999.9 ms | 1002.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 client | phase off (default) | phase on (before the change) | on - off |
|---|---|---|---|
| first turn of a call: mean / p50 | 805.7 / 805.8 ms | 817.0 / 816.3 ms | +11.2 ms (standard error 1.4) |
| first turn: sd, min .. max | 0.8, 804.7 .. 807.7 | 5.4, 808.4 .. 825.0 | |
| second turn (control: no phase either way) mean | 805.5 ms | 805.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?
| run | phase | max calls | p50 / p95 / p99 at 250 calls | at 300 calls (p50 / p95) | at the max: p95, cores | first failing step (p95) |
|---|---|---|---|---|---|---|
| A1 | off | 350 | 805 / 807 / 808 ms | 808 / 818 ms | 925 ms, 2.50 | 375 (1317 ms) |
| A2 | on | 325 | 806 / 824 / 826 ms | 812 / 833 ms | 904 ms, 2.39 | 350 (1416 ms) |
| B1 | on | 350 | 806 / 824 / 826 ms | 811 / 833 ms | 922 ms, 2.48 | 375 (1551 ms) |
| B2 | off | 300 | 805 / 807 / 807 ms | 811 / 965 ms | 965 ms, 2.35 | 325 (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_lagis 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.logfiles. - 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_phasestays the switch; the capacity ramp starts its calls overspread_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.
| brisvox | Pipecat 1.12 (4 workers) | |
|---|---|---|
| V2V p95 at the fakes' floor (~860 ms) + 15 ms | up to 150 calls | never (1.4 s at 5 calls) |
| V2V p95 under 1.5 s | up to 250 calls | up to 5 calls (1.7 s at 10) |
| first sign of trouble | 300 calls: CPU used up, new dials refused (503), late turns end on the fallback silence | 10 calls: memory at the limit; 15 calls: workers restarted, calls dropped |
| container memory at that point | 0.5 GB | 3.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 threads | spinning | CPU tight | CPU paced (40 ms sleep) | wall p50 / p99 (tight) | wall p50 (paced) |
|---|---|---|---|---|---|
| 1 | on / off (no workers) | 34.3 / 34.5 | 35.1 / 34.2 | 33.1 / 46.6 | 33.6 / 33.5 |
| 2 (the default) | on | 41.3 | 72.3 | 19.3 / 25.6 | 20.3 |
| 2 | off | 40.5 | 43.4 | 20.7 / 26.6 | 22.2 |
| 3 | off | 46.9 | 49.1 | 17.7 / 39.3 | 18.8 |
| 4 | off | 52.7 | 53.9 | 16.0 / 28.4 | 16.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):
| backend | run 1 | run 2 | wall p50 / p99 |
|---|---|---|---|
| tract (the server's default) | 61 | 59 | 53 / 85 |
ONNX Runtime (1 intra-op thread, feature silero) | 106 | 100 | 94 / 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.
| calls | CPU per call on / off (millicores) | p95 V2V on (ms) | p95 V2V off (ms) | Smart Turn ms per analysis on | off | pool wait per job on | off |
|---|---|---|---|---|---|---|---|
| 150 | 12.0, 11.5 / 9.8, 9.1 | 844, 844 | 845, 846 | 21.1, 19.7 | 23.6, 22.2 | 1.2, 1.0 ms | 1.2, 1.2 ms |
| 200 | 10.9, 11.7 / 10.0, 10.0 | 847, 866 | 856, 864 | 20.4, 22.7 | 25.1, 25.0 | 2.3, 3.2 ms | 3.4, 3.8 ms |
| 250 | 10.9, 10.7 / 10.3, 11.2 | 989, 1049 | 1966, 1751 | 23.3, 23.7 | 27.7, 25.8 | 5.4, 5.6 ms | 8.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 threads | 200 calls | 250 | 300 | 350 | note |
|---|---|---|---|---|---|
| 0 (old) | 889, 1390, 856 | 3514, 2057, 1657 | 3656, 3160 | 3762, 3798 | |
| 1 | 1331 (p50 950) | one thread is 55% busy at 200 calls; verdicts queue when turns end together | |||
| 2 (default) | 845, 837, 841 | 838, 840, 841 | 840, 838, 838 | 1592, 1341, 1266 | |
| 3 | 1222 | 1734 | 4.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:
| calls | p95 V2V old / new (ms) | lost turns old / new | CPU per call old / new (millicores) | mean wait of a VAD window old / new | Smart Turn ms per analysis old / new |
|---|---|---|---|---|---|
| 200 | 1390, 856 / 837, 841 | 0 / 0 | 11.8, 11.9 / 11.8, 10.8 | 6.2, 6.3 ms / 0.94, 0.86 ms | 24.8, 25.0 / 23.1, 22.8 |
| 250 | 2057, 1657 / 840, 841 | 0 / 0 | 11.8, 11.2 / 10.4, 10.6 | 7.2, 7.1 ms / 0.96, 0.89 ms | 25.8, 25.2 / 22.0, 23.0 |
| 300 | 3656, 3160 / 838, 838 | 0 / 0 | 12.1, 11.9 / 9.9, 9.7 | 8.3, 8.4 ms / 1.03, 0.96 ms | 26.3, 26.1 / 22.5, 22.9 |
| 350 | 3762, 3798 / 1341, 1266 | 97, 109 / 0 | 11.3, 10.9 / 10.9, 10.2 | 8.0, 8.2 ms / 1.06, 1.01 ms | 27.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 / Nafter 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)roundstimes (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-callv2vrun). 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 Nbisects 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
--pidits 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) aspss_peak_bytes. CPU "cores" are CPU seconds per wall second over the step.
What runs on each side (no keys, no provider noise):
| brisvox | Pipecat 1.12.0 | |
|---|---|---|
| process | brisvox-bench engine-server (started by capacity --sim): the production engine (run_call, the transport's pacer, the inference pool) behind the real websocket server | bench/pipecat_baseline/bot.py --mock-providers: FastAPI/uvicorn, one real Pipecat pipeline per call; --workers N for N processes |
| providers | brisvox_sim mock STT / LLM / TTS at the trait boundary | mock_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, turn | the 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" |
| floor | 950 ms | 950 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).
| system | server | max concurrent calls within the SLO | first step that did not pass | p95 V2V at the max | server CPU at the max | CPU per call | RSS peak at the max |
|---|---|---|---|---|---|---|---|
| brisvox (2 pool threads, queue 1024, 1024 calls allowed) | 1 process: 4 tokio workers, 2 pool threads, production queue | 1000 | 1100: 152 calls refused (the sim server's own 1024-call limit), p95 1033 ms | 1032 ms | 1.88 cores | 1.9 mcores | 768 MB |
| brisvox (2 pool threads, queue 1024, 2048 calls allowed) | same, --max-calls 2048 | 1200 (ramp 1000, 1200, 1500) | 1500: 1093 of 6000 turns without an answer, p99 3.6 s | 1041 ms | 2.08 cores | 1.7 mcores | 912 MB |
| brisvox (2 pool threads, queue 256, ablation) | same as the first, benchmark's 256 queue | 400 | 450: 9 of 1800 turns lost | 1015 ms | 0.69 cores | 1.7 mcores | 320 MB |
| brisvox (4 pool threads, queue 256, ablation) | 4 pool threads, 256 queue | 900 | 1000: 1005 of 4000 turns lost, p95 2359 ms | 1029 ms | 1.91 cores | 2.1 mcores | 725 MB |
| Pipecat, 1 process | 1 uvicorn process (one event loop) | 50 | 55: p95 1211 ms | 1090 ms | 0.93 cores | 18.5 mcores | 248 MB |
| Pipecat, 4 processes | 4 uvicorn worker processes | 160 | 170: p95 1468 ms | 1086 ms | 3.49 cores | 21.8 mcores | 968 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 calls | brisvox (2 pool threads; 256 queue up to 200 calls, 1024 queue from 400) | Pipecat, 1 process | Pipecat, 4 processes |
|---|---|---|---|
| 1 | 1033 ms, 0.02 | 1066 ms, 0.05 | 1068 ms, 0.05 |
| 10 | 1030 ms, 0.04 | 1064 ms, 0.19 | |
| 40 | 1064 ms, 0.71 | 1061 ms, 0.78 | |
| 50 | 1032 ms, 0.07 | 1090 ms, 0.93 | |
| 60 | 2863 ms, 1.03, 5 of 240 turns lost | ||
| 100 | 1034 ms, 0.28 | ||
| 120 | 1058 ms, 2.63 | ||
| 160 | 1086 ms, 3.49 | ||
| 200 | 1025 ms, 0.48 | 1986 ms, 4.00 | |
| 400 | 1017 ms, 0.68 | ||
| 1000 | 1032 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--noteflag; 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::Highfor the VAD batch,Normalfor 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-serversizes the queuemax(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) andTtsFirstText(TTS stage), once per response; the V2V stagefirst_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_workera_saturated_pool_delays_the_batch_and_keeps_the_stream_and_the_turn(speech still starts at the first, refused frame,rebuilds0) andaudio_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_reusedslides 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-serversizes the pool queue likebrisvox-server: without--pool-queueit ismax(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
| Layer | What | Where |
|---|---|---|
| Unit | codecs, segmenter, state machine transitions, schema validation | each crate |
| Property | frame ordering, epoch dropping, tool-call assembly from random delta splits | core/agent (proptest) |
| Simulation | full sessions under paused time with mock providers and scripted users | brisvox-sim |
| Contract | every provider adapter vs shared trait tests + local protocol mock server | providers |
| Chaos | disconnects, stalls, slow consumers, panics across many sessions | brisvox-sim |
| Load | 10–500 concurrent sessions, overhead/CPU/memory (brisvox-bench sim-load; see LATENCY.md) | brisvox-bench |
| Live | real providers, BRISVOX_LIVE_TESTS=1, keys in .env | providers, bench |
| Benchmark | V2V vs Pipecat, capacity | brisvox-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 countingClock,brisvox-sim/tests/pacer.rs). - Simulated time: build the runtime with
start_paused(true)(or#[tokio::test(start_paused = true)]) and createSimClock::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; aSimClockcreated 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).
| Item | Value |
|---|---|
| Source | Google FLEURS, dev split, en_us and tr_tr (research and alternatives: docs/FIXTURES_SOURCES.md) |
| URL / pin | https://huggingface.co/datasets/google/fleurs, revision 70bb2e84b976b7e960aa89f1c648e09c59f894dd (fetched 2026-10-07) |
| License | CC BY 4.0 (audio); transcripts from FLORES (CC BY-SA 4.0) -> fixtures/ is shared under CC BY-SA 4.0, see fixtures/NOTICE |
| Access | anonymous HTTPS; the script reads only a prefix of each dev archive (en 16 MB, tr 64 MB), cached in target/fixture-cache/ |
| Checksums | SHA-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 |
| Processing | float32 -> 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 set | en_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)
cargo fmt --all -- --checkcargo clippy --workspace --all-targets --all-features -- -D warnings(workspace lints in the rootCargo.toml; test exemptions inclippy.toml)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.- tests:
cargo nextest run+ doc tests if nextest is installed, elsecargo test --workspace cargo deny check(licenses, advisories, bans, sources) if cargo-deny is installed anddeny.tomlexists
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 arust:1.99.0container (WSL2 + Docker on the Windows machine) withcargo nextest run --workspace --all-features; for racy ones add--stress-count Nand 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,1plus busy loops, and--stress-count): (1) "a client that does not read" tests with real sockets set a tinySO_RCVBUFon the client they own (TcpSocket::set_recv_buffer_sizebeforeconnect, seewebsocket.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 (nextestthreads-required = "num-test-threads") and are retried twice in theciprofile (.config/nextest.toml,scripts/verify.shuses it whenCI=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 SIPCarrierofhosting.rsacknowledged the 200 once, andrsipstackcan 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: aScenario(speak(text, duration),silence(duration)) is the caller's script.play_inputsends 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()scriptsMockStt; end scripts withwith_tail(>= hangover)or the last utterance never stops.play_inputtakes aCancellationToken.SimTransportOutis the epoch-strict output transport: it records every frame it gets (time, kind, epoch, audio length; bounded bywith_record_limit, default 100k) and marksFirstAudioOut, and on an interruptAudioOutStoppedfor 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;UtteranceFinalcontrol 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,TtsEndedeven on failure),OutputPacer(real-time playout; waits abandoned as soon as an interrupt is queued; played audio log;TtsEndedupstream 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 freshSimSessionper 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(onSessionRun):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), andfingerprint()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
GlobalAllocin 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 isbrisvox-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::Exactasserts the reference's closed-form timings (1480 ms first audio, 660 ms of audio, 280 ms end-of-turn);Profile::Budgetasserts ranges (table in thesuitemodule 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
Interruptframes recorded at an output tap (OutputLog::tap), never from client clears.OutputLogalso 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'stime to silenceand passes Budget; 150 ms per frame (260 ms to silence), a droppedInterruptat 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 toProfile::Budget, Budget range widened,check_no_stale_audio...removed,interrupts == 1weakened: 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 underAutois 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 ofnext_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 ofall_scenarios_*does not prove s06 under the default configuration. The retry itself is tested intests/llm_runner.rs, andevery_scenario_but_s06_passes_with_the_default_retry_configurationruns s01..s05 and s07..s11 withretries = 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::OFFfor 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:
- Output through a real transport. Audio goes through
TransportOut, the session's pacer and the loopback client: aCleardrops 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). - Output log on wire time. The tap in front of
TransportOutrecords every frame but audio as it passes (soInterruptcounts 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 anInterruptis 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_interruptalso walks the wire records in the client's arrival order (no audio of turneafter aClearto 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). - 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_playedapplies theBUDGET_WORD_SLACKof 1 word only to turns a barge-in ended (anInterruptto 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 withlead_margin = 0and 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). - 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_messagesmaps them). - 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_toolrequires the loop and the tool's own result (an unregistered tool'sunknown_toolerror result fails it: mutation-checked). llm.retries = 0in 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_ configurationcovers the rest with the default (one retry).- 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.
- 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.
- 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_hangoveris set from the simulator's VAD (stop_after), since its stop is backdated. - Rates.
SessionParts::input_rate/output_rateare not used: the loopback call runs at the engine's fallback rates (16 kHz both ways); the mock TTS delivers 24 kHz andTtsStageresamples.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. - VAD. The simulator's energy threshold runs as an
EnergyModelbehind the production VAD worker (batching, gap handling,Offload::Inline); speech marks measured equal to the reference's (200 ms and 1200 ms). - 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) onEngineFactory, 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_delayafter 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): thewith_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.rsbelow). - Allocation per frame (counting allocators, thread-local, current-thread runtimes):
stt_turn_alloc.rs0 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 bya_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: aBytesmade from aVecallocates 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) andtts_alloc.rs(per provider chunk, none per frame) as before. Per-chunk and per-word costs: TTS two allocations per provider chunk and oneBoxper 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'sString(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_stringof every message) adds 480 KB and fails. It replaces the wall-clock ratio ofllm_window.rsas the deterministic guard; that test now takes the best of 15 interleaved runs. - Real time under load (
realtime_load.rs, featuresilero-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 withBRISVOX_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), concurrentSmartTurnOrt::warmcalls create the target in all (smart_turn.rs). - Stats (
run_call.rs,the_calls_counters_are_visible_through_stats_and_tracing):Call::stats().stagesand thecall statsevent onbrisvox::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 plainsend), 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 indocs/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.
| Profile | Asserts | Runs on |
|---|---|---|
Profile::Exact | the reference's closed-form timings (1480 ms first audio, 280 ms end of turn, 660 ms of audio) plus every structural invariant | the reference only |
Profile::Budget | ranges (table in the suite module docs, set from measurements: docs/LATENCY.md, "Simulator Budget numbers") plus the same structural invariants | every 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.
| Gate | Engine stages | Reference stages | Test | Accommodations |
|---|---|---|---|---|
| 1 | pump, AudioIn, VAD worker | STT, turn manager, LLM, TTS, pacer, output | hybrid_input.rs | none |
| 2 | + SttStage, TurnDriver | LLM, TTS, pacer, output | hybrid_turn.rs | fixed silence and adaptive (mock model), with and without guards |
| 3 | + LlmRunner | TTS, pacer, output | hybrid_llm.rs | see "Hybrid gate 3 accommodations" |
| 4 | + SegmenterStage, TtsStage, TransportOut | the session harness only | hybrid_output.rs | none |
| all | build_session_pipeline / run_call (EngineFactory) | none | engine_suite.rs, engine_chaos.rs, run_call.rs | see "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 anyFinalize, then the final withfrom_finalizeandspeech_final);cancel(after the hang-up the events close withinCANCEL_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 asDisconnected, the stream then ends, and a new session works);error_mapping(refusals 401/429/500/503 map toRejected { status }, a protocol violation toProtocol, a drop toDisconnected);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,Doneis last withToolCalls, then the stream ends);cancel(first a control run proves thatLlmHarness::events_sentis live: a response of three deltas plusDonemust 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 mostCANCEL_SLACK_EVENTS= 2 more response events may be produced withinCANCEL_PROMPTafter it);reconnect,error_mapping,timeout,warm,no_task_leakas above. Theno_task_leakhang-up scripts the longCANCEL_RESPONSE_EVENTSresponse, so an adapter that keeps pulling the response after the drop outlivesLEAK_BOUNDand is seen there as well as incancel. -
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 itsFlushedwithin a 500 ms quiet window);flush(words continue across text pieces, offsets strictly increase, the audio covers the words, nothing afterFlushed);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 aCancelledack withinCANCEL_PROMPT, at mostCANCEL_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_leakas above. -
Timeout checks (all three kinds; a stall is
Fault::Stall/Fault::ConnectStall, "slow" is half the deadline, 1 s for the 2 sCONTRACT_TIMEOUT):- a connect stalled for an hour yields
Core(Timeout); - a slow connect inside the deadline must succeed (LLM: and its response arrive whole);
- a first event stalled for an hour yields
Core(Timeout); - a first event that is slow inside the deadline must be delivered (TTS: the whole utterance);
- an idle gap after the first event (
before_event: 1, an hour long) yieldsCore(Timeout), so a deadline armed only for the first byte is caught. EveryTimeoutis judged both ways: not later than the deadline plusTIMEOUT_SLACK(100 ms) and not earlier than the deadline minusTIMEOUT_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.
- a connect stalled for an hour yields
-
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()(defaulttrue) lets a TTS adapter whose service has no timestamps at all (Deepgram Aura) say so. The suite then checks that audio arrives,Flushedcloses the context and noWordevent is made up, and skips the comparison of words with the audio (audio_and_words,flush); every other check is unchanged.tests/deepgram_aura.rsruns the suite twice, withFlushPolicy::PerTextand with the defaultPerTurn, and both must pass in full. UnderPerTurna bare text owes nothing, so the adapter probes the connection with a WebSocketPingwhile unflushed text is on the server: a silent server is aTimeoutat the deadline (thetimeoutcheck 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::DeclaresNoWordsintests/contract.rs(a double that emits words but declares none) proves that theemits_words() == falsebranches can fail. -
Request-scoped providers.
TtsHarness::faults_at_connect()(defaulttrue) lets a TTS adapter that opens nothing atconnect(one HTTP request per text piece, the OpenAI-compatible speech adapter) say so.Fault::RejectandFault::ConnectStallthen apply to the first request of the session, anderror_mapping/timeoutdemand the same errors as events after the first text (Rejected { status },Core(Timeout)within the deadline) instead of fromconnect; the other faults and every other check are unchanged.tests/openai_speech.rsruns 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): STTcancelandno_task_leak; LLMcancel(generation goes on after the drop) andno_task_leak(the task outlives the long response's hang-up); TTSno_task_leak.MisMapped(refusals lose their status, drops become protocol errors):error_mapping.WarmFails,WarmHangs(warm()errors or never returns): exactlywarm.WarmSlows(the connect afterwarm()takes 200 ms longer): exactlywarm.ConnectHangs(connect/streamnever return): every check still reports, andtimeoutandwarmfail.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 afterDone; 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): exactlycancel.IgnoresCancel(the cancel is accepted into a channel nothing reads, so the barge-in is lost): exactlycancel.DelayedCancel(cancels reach the provider 1 s late): exactlycancel.AckThenKeepsStreaming(the ack is sent at once, the synthesizer keeps streaming the cancelled context): exactlycancel.DiesAfterCancel(the session closes with the ack, so the next context cannot be served): exactlycancel.Stragglers(n)(n audio events of the cancelled context arrive between the cancel and its ack): n = 2 passes, n = 3 fails exactlycancel. Mutation-checked: removing the follow-up context from the check makes theDiesAfterCanceltest fail, and removing the quiet window and the follow-up together makes theAckThenKeepsStreamingtest 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 itsFlushed):audio_and_wordsandflush.NoIdleTimeout(the deadline covers the first event only, a later stall is waited out for good): exactlytimeout, for all three kinds. A mock whose own deadline is 500 ms or 1.5 s instead of the configured 2 s fails exactlytimeout(too short: cuts off the slow case, or times out early).FrozenCounter(LLM harness whoseevents_sentnever advances): exactlycancel.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,cancelincluded.- The mocks without
with_timeoutfail exactlytimeout. With a connect latency above the deadline they reportCore(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:
- 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. - Implement
SttHarness/LlmHarness/TtsHarnessfor 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, andinject(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. - 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. - Add the adapter's own negative tests the way
tests/contract.rsdoes (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-serverwith your agent file (real providers) and the WebRTC listener: your own call, in a browser athttp://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
- Lower voice-to-voice latency than Pipecat with identical providers (see LATENCY.md).
- Hundreds of concurrent sessions in one process, with per-session isolation.
- 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. Onestd::sync::Mutexper inbox, never held across.await; waiting viatokio::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):
OverflowPoliciesper 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 inQueueStats. - Closing:
recvreturnsNoneonce all senders are gone and the inbox is drained; sending to a dropped inbox fails withChannelClosed(also releasing blocked senders). In a bidirectional chain neighbours hold senders into each other's inboxes, so drop-based close never fires: pipelines stop viaStop/Cancelsystem frames or theCancellationToken. - Cancellation:
run_processorholds onecancelled()waiter for the task's life plus an atomic check per frame; it interruptsprocesswherever it is parked.Processor::processmust 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
processis 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 (anInbox::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):
- 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 withFrame::is_staleis dropped beforeprocess. - Purge on advance: the task's inbox drops queued stale data-lane frames.
- Outgoing:
pushdrops frames older than the task's epoch. - Blocked push: when
pushmust wait for a full neighbour, it also watches the task's own inbox; the sender side records the newest enqueued interrupt epoch (atomicfetch_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. - Originating: a processor that pushes a newer
Interruptadvances its own context epoch; the task loop then adopts it and purges its inbox.pushalso drops frames older than the newest interrupt already waiting in the task's own inbox (fast path: one atomic load).ToolResultframes are exempt everywhere (SideEffect safety).Processor::epoch_policy()defaults toDropStale;KeepAllopts 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) -> LlmStreamofTextDelta | 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 inbrisvox-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 declaredresumewith bounded backoff and asession.startcarryingresume), an event pump (the call'sEngineEventsring, shared with the RTVI bridge) and an operations task (end,transfer;transfergoes 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 inputTurnInput::BotTurn { interrupt }. FromIdle(no candidate) the machine starts a bot turn in a new epoch (TurnAction::StartBotTurn, stateAwaitingResponse); withinterrupt, fromAwaitingResponse/BotSpeaking(no candidate), it cuts the response first (Interrupt, thenStartBotTurn). 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 anLlmTextof 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 thePlayoutBook, 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.SetToolsnarrows the offered tools (Shared::offered); a call to a tool not offered is answeredunknown_toolwithout running. endresolvesAgentLink::endedonce the farewell line was played and the bot is silent; the runner then stops the call gracefully.transfergoes through the call'sTelephonyOps.
- Bot lines (
- Remote tools are registered next to the builder's own tools (the registry is copied with
ToolRegistry::filtered, the tool engine rebuilt with the sameEngineConfig); theirTool::callis atool.call/tool.resultRPC 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)sendsStop.run_processortreats a downstreamStopspecially: it processes everything already queued (in receive order), thenStop(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 sendingStopinto a full system lane) is bounded bytimeout; on expiry the pipeline is cancelled, tasks aborted, andError::Timeout(or the root-cause failure, if any) returned. A Stop drain stops early on cancellation or a queuedCancelframe. Cancelframe: 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 orError::ProcessorPanicked(stage); the first failure cancels the rest.ChannelClosedfrom 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 aRunningPipelinecancels and aborts its tasks. - Leak checks:
brisvox_sim::leak::TaskCounter(tokionum_alive_tasks) waits on theClockuntil 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. Stopis forwarded once every branch has passed it on (then the merge drains and ends);Cancelon 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.
| Variant | Level | Dir | Producer | Consumers | Overflow policy |
|---|---|---|---|---|---|
System::Start | System | ↓ | pipeline/session | all processors | never dropped (system queue) |
System::Stop | System | ↓ | session | all processors | never dropped; drain data first |
System::Cancel | System | ↓↑ | session, fatal error | all processors | never dropped; drop queued data |
System::Interrupt{new_epoch} | System | ↓↑ | turn state machine | all processors (purge older epochs) | never dropped |
System::UserStartedSpeaking{at} | System | ↓↑ | VAD | turn SM, interruption policy, timeline | never dropped |
System::UserStoppedSpeaking{at} | System | ↓↑ | VAD | turn SM, Smart Turn, timeline | never 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 unchanged | never 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 processor | session supervisor | never dropped |
System::Wake{token} | System | self | a helper task of the stage, via ProcessorContext::self_sender | the same stage only | never dropped or purged; never forwarded (push discards it); payload travels in the stage's EventSlot |
Control{UserTurnStarted} | Control | ↓ | turn SM | STT, context, timeline | backpressure; never dropped by overflow, subject to epoch purge |
Control{UserTurnEnded} | Control | ↓ | turn SM | LLM runner, context, timeline | backpressure; epoch purge |
Control{LlmResponseStarted/Ended} | Control | ↓ | LLM stage | TTS segmenter, context | backpressure; epoch purge |
Control{TtsStarted/Ended} | Control | ↓ | TTS stage | pacing, context (played text) | backpressure; epoch purge |
Control{Flush} | Control | ↓ | turn SM, LLM stage | segmenter, TTS, pacing | backpressure; epoch purge |
Control{UtteranceFinal} | Control | ↓ | STT stage | turn SM | backpressure (user side: KeepAll) |
Control{UtteranceStarted{utterance}} | Control | ↓ | TTS stage, right before the first audio of each utterance | transport-out stage | backpressure; epoch purge; same data lane as the audio, so it stays in order with it |
Data::Audio (input) | Data | ↓ | transport.in | decode/resample, VAD, STT | drop oldest (stale input is useless) + metric |
Data::Audio (output) | Data | ↓ | TTS | pacing, transport.out | backpressure (paced at real time) |
Data::Transcript | Data | ↓ | STT | turn SM, context | backpressure |
Data::LlmText | Data | ↓ | LLM stage | segmenter, context | backpressure |
Data::ToolCallDelta(Box) | Data | ↓ | LLM stage | tool engine | backpressure |
Data::ToolResult(Box) | Data | ↓ | tool engine | context, LLM runner | backpressure; exempt from epoch purge |
Data::Dtmf | Data | ↓ | transport.in | turn SM / flows | backpressure |
Data::Word(Box) | Data | ↓ | TTS stage | played-text tracker (brisvox_agent::played) | backpressure; exempt from epoch purge |
Data::Metric | Data | ↑ | any stage | timeline / metrics sink | drop 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
| # | Stage | Epochs | Does | Heavy work |
|---|---|---|---|---|
| - | pump (task, not a stage) | retags input to epoch 0 | drains TransportSession::recv: audio into stage 1 (drop-oldest, counted), marks, positions and leads into the PlayoutBook, end marks as upstream TtsEnded, hang-up as Stop | none |
| 1 | AudioIn + VAD worker | KeepAll, epoch 0 | resamples 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 |
| 2 | SttStage | KeepAll, epoch 0 | connects 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 |
| 3 | TurnDriver | KeepAll | the 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, UserTurnEnded | Smart Turn on the pool |
| 4 | LlmRunner | DropStale | one 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 |
| 5 | SegmenterStage | DropStale | speakable chunks as raw slices of the response (the first after one word) | none |
| 6 | TtsStage | DropStale | one provider context per round (utterance), audio cut into 20 ms frames at the call's output rate, word timings, TtsEnded | none (per-chunk resampling, measured < 50 us) |
| 7 | TransportOut | KeepAll, filters itself | frames to TransportCommands: audio, marks, Clear; FirstAudioOut, upstream TtsStarted | none |
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
TurnDriveralone advances epochs: a new epoch at every user turn,Interrupt{new}only for a barge-in, always beforeUserTurnStarted{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;TransportOutdrops and counts them itself. - Helper tasks (LLM rounds, TTS readers, timers) send through a
GuardedSenderthat checks the stage's epoch right before each send, so a cancelled round cannot leak a late frame. ToolResultframes are never purged (SideEffect safety,docs/TOOLS.mditem 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):
PlayoutBook(above).- The
CommandSenderthe driver uses for the fast-pathClearat a barge-in: applied inline in the transport, bypassing the command queue, before theInterrupttravels downstream (docs/PIPELINE.mdsection 3; the book learns of the clear before the command). Arc<Mutex<Context>>, the conversation, written by the runner and read by the sim probes.- 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 (
playoutmodule 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-textwithaudio_response=falseandllm-function-callevents 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, withinmax_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 ofmax_tokens) is sent clipped: its text is cut at a character boundary, the longest prefix that fits, andCLIP_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 tocovers_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 toWindowPolicy::max_summary_tokens(default: a quarter ofmax_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 (orNone). The range is at most one round long: whole turns whose sent size fitsWindowPolicy::max_summary_input_tokens(default:max_tokens), always at least one turn.refresh_summary(...)loops over the rounds, calling aSummarizer(a trait returning aBoxFuture; 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 limitmax_summary_tokens, which it should compress to. Summaries are produced asynchronously by the caller; while a summary lags,Window::uncoverednames the dropped messages it does not cover. - Summary limit:
refresh_summarynever stores a summary whose message estimates to more thanmax_summary_tokens. It returnsWindowError::SummaryTooLargeand 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_tokenswhen 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, andWindow::over_budgetreports 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
| Item | What it is |
|---|---|
Flow | Immutable, 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). |
Node | A 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. |
ToolBinding | A 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. |
Transition | Goto(node) (fires on success), Rules(Vec<Rule>) (first Condition that holds: Always, Success, Failure, ResultEquals, ResultPresent, ArgEquals, JSON pointers), Custom(fn(&ToolOutcome) -> Option<NodeId>). |
ContextStrategy | Append, Reset, ResetWithSummary: what happens to the history when a transition enters the node. |
Action | Say(text), EndCall, Custom{name, payload}. Named by the flow, performed by the runner. |
FlowManager | The 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.
startinstalls 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, sowindow::build_windowalways keeps it. This is a deliberate difference from Pipecat, whoseAPPENDadds 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 (testthe_node_prompt_survives_the_long_call_window_...). The manager owns message 0; put global instructions in the flow role. - Strategies.
Appendkeeps the history and swaps message 0.Resetleaves 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).ResetWithSummaryasks the summarizer for the newest history (whole turns from a user message, at mostsummary_input_tokens, the latest turn always; message 0 is not included) withsummary_max_tokensas the size, and the context becomes the prompt plus a system messageFLOW_SUMMARY_PREFIX+ summary (clipped to the size). No history: plainReset. No summarizer, an error, an empty reply or no answer withinsummary_timeout(default 8 s, on the injected clock): the transition still happens and falls back toAppend, so history is never lost silently (Entered::fell_back()). The summary chains: a flow summary at message 1 (from an earlierResetWithSummaryfollowed byAppendnodes) is passed to the summarizer aspreviousand 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 respectssummary_input_tokens(plus the latest turn). - Latency of
ResetWithSummary. The summarizer is an LLM call on the voice path: up tosummary_timeoutof silence withapply. Useapply_results+resume_pendingand playpending_pre_actions()(the node'sSay, for example "One moment please.") while it runs, set a shorterFlowConfig::summary_timeout(1-3 s), or useReset/Appendfor nodes where the summary is not worth it. A timeout costs nothing but the summary: the history is kept. keep_last_user.ResetandResetWithSummaryleave[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'sconfirmanddonedo), so the context is request-shaped. Off by default, so existing flows keep their exact contexts.- A reset invalidates indices.
Resetand aResetWithSummarythat 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 windowSummary(covers_untilis an index; kept, the nextbuild_windowfails withInvalidSummaryand 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 whencontext_replacedis set (testa_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 droppingapplyat 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 (testa_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 withCustom) is not an error: it is reported inApplied::rejected, logged, and the node does not change. A failed call (anis_errorresult:timeout,tool_failed,unknown_outcome, ...) never firesGoto, so the model can retry;Rulescan route on it. Answers that are not tool results never transition: whenExecution::budget_exhaustedis set no transition is decided. An error result is routable only if the tool ran: the allow-list istool_failed,timeout,unknown_outcomeandresult_too_large(TOOL_RESULT_ERRORSinmanager.rs). Every other error is skipped before anyConditionorCustomfunction 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_resultandtool_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 aFailureorAlwaysrule 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_outcomeincluded: a SideEffect call that timed out may still be running, andAlwaysfollows it like any failure; useSuccessor aResultEqualsrule after a booking. - Refused tools.
screenanswers 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::mergerestores issue order;applydoes 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
Screenedis consumed byapply, 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, becausetransition_toneeds 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 istimeout,unknown_outcomeorinterruptedmay 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 withtool_not_availableand the detail "ended without a known outcome ... Do not call it again". The tool is released whenapply_recorded_resultreceives a real result of the call (a success, or atool_failed: the effect did not happen) or when the node changes; a repeatedtimeout/unknown_outcomekeeps it withheld and engine refusals change nothing. Conservative choices: anyinterruptedof a SideEffect tool counts (including the rare one before the tool started), and aunknown_outcomethat 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 aRulesbinding onunknown_outcome). The fifth is a hazard the flow had to close:Resetremoves tool calls from the context, andContext::next_tool_call_idscounts calls in the context, so it would hand outt<epoch>.0again 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_idscontinues 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 andMAX_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 ofcapture_argsare chosen by the model: an argument whose name is acapture_resultkey 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).Debugof the manager prints state keys, never values. - Pre and post actions are returned in
Entered, not performed.Sayis a canned utterance that is not added to the context (unlike the tool filler, which is, see TOOLS.md);EndCallas a post action means "hang up after this node's first response was played".respond_immediatelyfalse makesApplied::respondfalse: the runner ends the turn and waits for the caller. - Time. Only the summarizer wait is timed, with
Clock::sleepfrom the injected clock. NoInstant::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 toalternativeswith 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_timeoutof 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_reservationruns 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 aResetin the same turn does run). - State bounds, prompt sanitising,
refresh_prompt, and the window keeping the node prompt after anAppendtransition over 40 turns. - Review fixes:
stale_turn(real engine) andbudget_exhaustedanswers never fireFailure,AlwaysorCustom; hand-builtExecutions 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;applydropped mid-summary (decision kept, no tool offered,resume_pending, next-round andabandon_pendingpaths, one booking);apply_resultsreturns the pre actions before the summarizer is asked;apply_recorded_result;build_windowafterAppend(kept summary valid) and afterReset*(kept summaryInvalidSummary, 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_argumentsanswer (and a table ofarguments_too_large,too_many_calls,unknown_tool,store_full, malformed and unknown kinds) never firesFailure,Always,Customor 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 idst1.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; thebudget_exhaustedflag 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_tokensof history (the earlier flow summary is chained asprevious). 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/ResetWithSummarywithoutkeep_last_useryield 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) untilapply_recorded_resultgets 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. Routeunknown_outcometo a checking node with aRulesbinding. Only the runner can feed late results toapply_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_pendingafter a cancelledapplyis 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
donenode) may be rejected by providers that require tool definitions next to tool-use blocks (to verify: Anthropic);Reset/ResetWithSummarynodes 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
functionactions (a flow-invoked handler) and dynamic node factories are not provided; useAction::Custom,transition_toandset_statefrom 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:
| Action | Caller 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} / CancelTimer | one 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:VadStartstarts a turn.BotAudioEndedof the current epoch is ignored. (Idlecan 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:VadStopopens a pause (UserPaused).SttInterimis accepted with no effect.SttFinalanswers an owed stop (see the caller contract); when no stop is owed it is ignored and counted.VadStartagain 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.VadStartresumes the same turn and drops the pending analysis (its verdict is then stale).AwaitingResponse: the user turn ended; waits for the bot.BotAudioStartedof the current epoch moves toBotSpeaking.BotAudioEndedof the current epoch means the response produced no audio and returns toIdle.VadStartis a barge-in candidate (see "Interruption policy"). A final that answers an earlier stop is consumed with no effect.BotSpeaking:BotAudioStartedof 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 leastecho_guardafter the previousBotAudioStarted); closer, repeated or older reports change nothing (see "Echo window").BotAudioEndedof the current epoch returns toIdle.VadStartis a barge-in candidate.- Stale audio:
BotAudioStartedandBotAudioEndedof any other epoch are ignored and counted (tested inAwaitingResponseandBotSpeakingat 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 toIdleand the nextVadStartwould emit noInterrupt). Closedignores every input, including anSttFinalstill owed by a stop that was open when the call hung up (tested after hang-ups from every state).- Barge-in (from
AwaitingResponseorBotSpeaking): epoch + 1,Interrupt{new_epoch}, thenStartUserTurn{new_epoch}, stateUserSpeaking. There is no separate "interrupted" state: no state exists in which no user turn is open. Whether aVadStartis a barge-in at all is decided by the interruption policy (next section); with the default (disabled) policy it is, at once. Closed: terminal.HangupfromUserSpeakingorUserPausedemitsEndUserTurn{reason: Hangup}so that everyStartUserTurnis paired with an end; the caller emitsUserTurnEndedand 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:
| Event | Result |
|---|---|
| policy confirms | Interrupt{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 epoch | the 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 |
Hangup | candidate 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):
| Guard | Field | Blocks while |
|---|---|---|
| Backchannel | backchannels, BackchannelSet, backchannel_max_speech | the 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 guard | echo_guard, echo_min_words | bot 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 speech | min_speech, vad_hangover | VAD 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 words | min_words | fewer interim words than min_words |
| Transcript wait | backchannel_wait | backchannels 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
VadStopis dropped for good: noInterrupt, no user turn, nothing for the LLM, only thevetoed(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 (withrecommended(), a 250 ms "stop" without an interim), and a one-word interruption whenmin_words >= 2. Callers that cannot afford it keepmin_speechlow and deliver interims promptly. - The echo guard is lifted by any interim with
echo_min_wordswords. 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
BotAudioEndedthe echo window no longer applies to a detached candidate. With an echo-only policy (nomin_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 withmin_speech(asrecommended()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_stopinside 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:
VadStartat speech start,VadStopat the backdated stop (the start of the silence). - STT: exactly one
SttFinalperVadStop, in the same order. The STT stage finalizes on every VAD stop (as the referenceTurnManagerdoes,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
Verdictechoes theAnalysisIdof itsRequestAnalysis. - Timers: one timer slot. Deliver
Tick{now}at (or after) the armed deadline; aTickfor 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 leastecho_guardafter 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) sendBotAudioEndedstraight afterEndUserTurn. Otherwise the machine waits inAwaitingResponse, and the nextVadStartis reported as a barge-in against nothing. - Hangup:
Hanguponce, 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
VadStoprequests one analysis (RequestAnalysis, a fresh id). - Fallback deadline = VAD stop +
fallback_silence(default 3 s, Pipecat'sstop_secs). It is always armed while the pause is open and ends the turn withEndReason::Fallbackwhen 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 withVerdict). The deadline becomes verdict time +final_grace(default 800 ms) if that is earlier than the fallback; reaching it ends the turn withEndReason::FinalGrace. The end reason isFinalGraceas 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_gracewhen that is earlier than the fallback, and it ends withEndReason::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
requestis 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:
ArmTimerreplaces the previous one,CancelTimerclears it. The machine emits nothing when the desired deadline is unchanged.Tickconsumes the timer that fired; aTickearlier than the deadline re-arms it, and aTickoutside 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 repeatedBotAudioStarted) 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::MAXpolicies through every deadline kind (huge_durations_clamp_instead_of_panicking). - A
Tickstrictly later than the armed deadline ends the pause exactly like one at the deadline (real timers fire late): tests coverdeadline + 1 msanddeadline + 5 sfor 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
AwaitingResponsehas no deadline. A response that never starts leaves the machine waiting until the caller deliversBotAudioEndedor 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)),startbeing the offset from the utterance's first audio sample. Carrier in the frame model:DataFrame::Word(Box<WordTimestamp>)(brisvox_core::frame;Framestays at most 64 bytes, checked at compile time). Timestamps may arrive late or out of order; the mapping is computed whenheardis 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_playedraises 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 (
hadoes not match insidehaha); 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_datais 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, andprovider_datahandling. - 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 usePlayout; 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::acceptyields aCallLinkper call. The adapter keeps the matchingCallEnds(call_pair):WireEvents in (caller audio, DTMF, mark echoes, fault, hang-up),WireOuts out (audio, clear, mark, hang-up), andended, 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 readsTransportEvents (recv) and sendsTransportCommands (send, or a clonedcommands()CommandSender).close(timeout)cancels and joins every task; dropping the session cancels it.stop_reason()returns why the session ended, even if theStoppedevent 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()parsesmetadata[CallInfo::SAMPLE_RATE_KEY]intoResult<Option<CallRates>>(CallRates { input, output },CallRates::symmetric):Ok(None)when the adapter reports no rate (the loopback),Errfor a malformed or unsupported value. The engine falls back toEngineRates::fallback_callonNone. 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.
Audiogoes into the productionOutputPacer(pipelinetransport.out);Markis recorded at the enqueued position;StoprecordsRequestedand cancels.Clearis 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 overtakeWireOut::Clear). When the ledger's generation changed it sendsWireOut::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), cancelsCallEnds::ended, and emitsStopped{reason}. The first task to end cancels the rest, with one exception: when the output task finds the wire closed (the adapter dropped itsoutgoingend), 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 isPeerHanguprather thanPeerDisconnected. Past that second, or when the adapter's event side closes without a hang-up, the session ends asPeerDisconnected.
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:
- 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::Audioand every queuedWireOut::Markout of the wire queue (Sender::purge; the output task sends only after it saw the new generation, so nothing newer is in the queue); - it wakes the output task;
- 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 writesWireOut::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 ofWireOut::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). AClearwith an epoch not newer than the current one is ignored. Audio must follow theClearof its epoch (epoch 0 needs none); audio of any other epoch is dropped.Stopis 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) isDropOldestwithin its class, so a slow engine loses the oldest caller audio.PlaybackPositionisDisplace: 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.StoppedisDisplacetoo: it evicts the oldest input audio to get in.Started,Dtmf,MarkPlayed,MarkCleared,ErrorareBackpressure(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.Cleardoes not use it. - wire events (
WIRE_EVENTS_CAPACITY= 64):AudioDropOldest, the restBackpressure. - 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,Stoppedis dropped. The reason stays available fromTransportSession::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
WebSocketServerimplementsTransport.bind/servestart one accept task (tracked by aTaskTracker);acceptyields aCallLinkper connection that completed the upgrade and sent a validstart.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 thecall_pair(queuesWireEvent::Startedfirst, hands theCallLinkto the accept queue), then splits the socket and drives a reader and a writer future withjoin!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'sstart.metadata, plusclient_call_id,transport,sample_rate,encoding,peer_addr(server keys win). The engine readssample_rateto synthesize at the call's rate.websocket::client::runis the reference client (also thews_clientbinary);wavis 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'smedia/mark/clearcarry no stream id (Telnyx's schemas forbid extra properties).sequence_number,chunk,timestampare strings (RTP timestamp, not ms).errorframes (100002..100005) become engine faults. - Codecs: PCMU or PCMA at 8 kHz (inbound from
start.media_format, outbound fromTelnyxConfig::codec, which must equalstream_bidirectional_codec); L16/G722/OPUS/AMR-WB are refused atstart(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_bytesis 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 byclear. - Order: frames are not ordered.
telnyx::reorder::Reorder(sans-IO,BTreeMap) holds at mostreorder_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 orreorder_timeoutafter 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 thanmax(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 astopor close is delivered.dtmfwaits up toreorder_timeoutin a bounded list (32) and is delivered insequence_numberorder (dtmf_reordered). Frames that overtakestartare dropped (counted, at most 64). - Auth: no signature. With
auth_token(Telnyx'sstream_auth_token): a wrongx-telnyx-streaming-auth-tokenupgrade header is 401; a missing header defers toconnected.x-telnyx-streaming-auth-token; without proof bystartthe connection is closed 1008 and the engine never sees it. Comparison: HMAC-SHA256 of both under a fixed key, compared withring::hmac::verify(constant time, length independent). The token isSecretin the config and redacted in the parsedconnectedmessage. - Hang-up: closing the socket is not documented as a hang-up.
TelnyxConfig::hangupis a host-supplied async hook (RESThangup; brisvox holds no Telnyx API key), called once, bounded byhangup_timeouton 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 afterstart(unsupported media format, engine not taking calls), but not after Telnyx'sstopand not for authentication or duplicate refusals. Telnyxerrorframes are reported as faults but do not count toward the 16-fault limit. - One stream per call:
telnyx::registryremembers live and the last 1024 endedcall_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:
TelnyxStatsaddsmedia_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 asDatagram { proto: Tcp, reply: Some(writer) }and the call'sstr0mseesReceive::new(Protocol::Tcp, remote, tcp_candidate, ..). The call remembers (remote address, writer) per connection (at most 4) and framesTransmits withproto == Tcponto 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 oftcp_queueframes 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, asLink: rel="ice-server"headers on the WHIP201(RFC 9725 section 4.6) and as JSON fromGET <whip_path>/ice(same bearer check asPOST,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 ofWebRtcConfig::turn(turns:...:443?transport=tcppreferred), 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'sRtcas a host candidate (ICE-lite refuses relayed local candidates), after the direct one sostr0mgives it a lower local preference; inonlymode 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 abovemax_frameend that allocation only), turns Data indications and ChannelData intoDatagram { local: Some(relayed) }on the call's inbound queue (drop-oldest, like UDP), and wraps the call's outgoingTransmits (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,Refreshwith lifetime 0 when the call ends. Setup is bounded bysetup_timeout(3 s): on failure the call answers with its direct candidates, or inonlymode503. Countersrelay_*. Tests:tests/webrtc_relay.rsagainstturn-server-protoin-process over TCP and TLS. Facts, sources and the crate's deviations (unpadded ChannelData, USERHASH):docs/providers/webrtc.mdsection 12. Tests:tests/webrtc_tcp.rs(a str0m client whose only candidate is TCP-active completes ICE and DTLS and carries audio both ways withdatagrams_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 andLinkheaders,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.
| # | Decision | Source and reason |
|---|---|---|
| D1 | New 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 |
| D2 | Self-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. |
| D2a | EventSlot::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. |
| D3 | RunningPipeline::stats() exposes per-stage QueueStats and stale_dropped. | P1 |
| D4 | STT 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. |
| D5 | Two 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 |
| D6 | Utterance 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 |
| D7 | Model 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 |
| D8 | No second pacer. TransportSession::open already contains OutputPacer. The pipeline tail is TransportOut. | All proposals |
| D9 | Cross-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_sendinsideprocess(). They are never an awaited push fromprocess(). 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 checkslatest() <= epochimmediately 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:
Interruptalways precedesUserTurnStarted. - 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()andclock::wait_until. There is noInstant::now(). - Action mapping:
StartUserTurnpushesUserTurnStarted.RequestAnalysis{id}takesSmartTurn::snapshot()(an ownedVec<i16>) and spawns a job through Offload that callsSmartTurn::analyze_snapshot_blocking(the engine'sOffloadis the one place jobs go, andbrisvox-inferencecannot 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/CancelTimerreplace a single timer helper task.Interruptruns the barge-in path in section 3.EndUserTurngoes through the finals gate.
- Finals gate.
owed_finalsis +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
EndUserTurnwhile finals are owed, the end is held until they arrive orfinal_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
UserTurnEndeddownstream. The driver feedsBotAudioEndeditself (the caller obligation).
- response_deadline backstop. After
UserTurnEndedthe driver arms a deadline (default 20 s) that bounds "the bot never produced audio" (a stalled LLM or TTS). The firstTtsStartedof that epoch (TransportOut sends it once, at the first audio handoff) cancels it, as doesTtsEnded: a response that is playing must never hit the backstop, because the syntheticBotAudioEndedwould make the machine Idle while the bot still talks and the user's next speech a plain new turn withoutInterruptandClear. The end of playback is then guaranteed by the transport's own mark timeout. On expiry the driver feeds a syntheticBotAudioEndedand 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 sendsClear{epoch}. On an idle transport this cuts nothing. Clearis idempotent for the same epoch.- Without this rule the second turn's audio is silently dropped.
- TransportOut keeps
- Barge-in guards. The driver's machine judges candidate barge-ins with the real
InterruptionPolicy, configured byEngineConfig::interruption; see section 12, "SttStage and TurnDriver". WithInterruptionConfig::OFFevery barge-in interrupts at once (the plain turn-machine behaviour).
3. Barge-in path, step by step
- VadWorker job completes and pushes
UserStartedSpeaking{at}(system lane). STT forwards it. - TurnDriver feeds
VadStart. The machine returnsInterrupt{new_epoch}(bot active). - In the same
process()call, the driver does these in order:timeline.interrupt(...).- 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 finalPlaybackPositionand emitsMarkCleared. - Push
System Interrupt{new_epoch}downstream. - Push
Control UserTurnStarted{new}.
- 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 recordspending_cut(old_epoch). It forwards Interrupt. It never mutates the Context while the round task is alive (join first). - SegmenterStage calls
reset(). TtsStage callscancel.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, andGuardedSender. - TransportOut marks
AudioOutStoppedon the interrupted epoch and sends an idempotentClear. - At the next
UserTurnEndedthe 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::LlmRequestpasses 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
convertmodule maps agent Context/Message/ToolSpec to providers types. This covers assistanttool_callswithprovider_id, arguments serialized to JSON text, andprovider_dataValue to JSON text. It also mapsLlmEventtoassemble::Delta, includingDone.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:
- Push
LlmResponseStarted{epoch}. Register(epoch, round_ordinal)in the PlayoutBook withmsg_index=None. timeline.llm_request(epoch)returns the round index, which is passed tollm_first_tokenandllm_done.provider.streamruns under clock-based first-token (8 s) and idle (15 s) timeouts.TextDeltagoes to the assembler AND is emitted asLlmTextimmediately (leading whitespace of the first delta stripped once, before both).ToolCallDeltagoes to the assembler.- 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 withmsg_index. - No calls: push
LlmResponseEndedand end the turn. - With calls: push
Flush, so the text before the tool is spoken during execution. Then raceToolEngine::execute(calls, epoch, &token)againstclock.sleep(delay), the delay of theFillerPolicy(LlmConfig::filler, default 1.2 s). - 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 itsmsg_indexis set then and it is cut to what was heard like any round. - After execution:
Execution::append_to, thentake_late_notes()only when no call is open, then the next round. The limit ismax_tool_rounds(default 6). Onbudget_exhaustedor the round limit, speak a fixed fallback line.
- Push
- 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::Erroror timeout is allowed ONLY before any text or tool dispatch. Otherwise the turn ends withLlmResponseEndedand no invented speech. An empty or errored response makes the runner push an upstream-safeTtsEnded(non-blocking), soBotAudioEndedfires.
5. Played-text truncation (marks plus released audio)
Source of truth.
PlaybackPositionis an upper bound: audio released to the wire, ahead of the caller by sink 4 + wire queue 8. It is the continuous source.MarkPlayedis the exact boundary.- Heard position =
min(released, total_audio) − lead.leadis the client's reported playback lead when the transport sends one (PlayoutBook::client_lead, TwilioPlaybackLead; the report in force when theClearcut the utterance), elselead_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 fixedlead_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
Clearcovered 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,lostandtotal_audio. settledis set whenMarkPlayed,MarkCleared, or the final position after Clear arrives. While an end mark is outstanding onlyMarkPlayed/MarkClearedsettle (the transport sends the final position beforeMarkCleared, so a stale periodic report cannot); a mid-utterance barge-in has no mark yet and takes the first position afternote_clearas final (known limitation, see theplayoutmodule 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), whichmark_appliednever 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:
- 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 countsettle_timeouts. - Walk the rounds from last to first, so Removed outcomes do not shift earlier indices. A round with
msg_index=Noneis skipped. - For each round: if
marked_played(or followed by a played utterance) and notlost, keep it as sent. Otherwise buildPlayedText::heard(position, &PlayedConfig)and callapply_truncation(&mut ctx, idx, &trunc). - A round never reached (position 0) goes through
apply_truncationwith an empty heard prefix. Whatever outcomeapply_truncationproduces is the policy, identical to the reference LlmStage. The engine adds no omission policy of its own. - Orphan tool results are removed together with a removed assistant message. Assistant messages that keep tool calls are retained so tool pairs stay valid.
- Record
TruncationStats. - Assert
Context::validateafter 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 typedCallRates{input, output}via a newCallInfo::sample_rate()in transport. An unknown rate is a fatal Error frame. AudioIn resamples input → 16 kHz. - Output. The TtsStage reader task owns one
StreamResamplerper session, 24 kHz →CallRates.output. It hasflush()atFlushed,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 msAudioFrames by zero-copyBytes::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.
- Each provider chunk is copied once into a
- 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 andTtsEnded. 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.
Errfromprocessis reserved for unrecoverable invariants. Fatal ErrorFrames go to the supervisor, and a non-fatal Error goes to a counter plus tracing targetbrisvox::engine.- Transport
Stopped: TurnMachineHangup, 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 theHighline, everything else (Smart Turn analyses, warm-ups, builds) in theNormalone; a worker takes a waitingHighjob 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-serversizes itmax(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. Withoutwith_smart_turn_offload(and withBRISVOX_SMART_TURN_THREADS=0) the analyses use the session's offload as before.
- The pool has two lines (
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-simgainstrait SessionFactory { build(SessionParts) -> BuiltSession{pipeline, probes, InputSink, OutputLog} }.ReferenceFactoryreproduces today's chain exactly. Scenarios s01–s11, played_text, fixtures and chaos bodies move intobrisvox_sim::suite, generic over the factory and aProfile{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.
- 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 (
- Interrupt counts come from
Interruptframes (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:
- Engine pump/AudioIn/VAD + reference STT/Turn/LLM/TTS/pacer.
- Engine input chain + STT + TurnDriver + reference downstream.
- Engine input chain + LlmRunner + reference output.
- Everything in engine.
- Harness. Input in tests comes from
LoopbackClient::send_audiothrough the real TransportSession and pump.LoopbackClientgets 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
BotAudioEndedafter 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::handleand the resamplers are held to the 50 µs rule. - Chaos. 100 production calls cover all chaos kinds plus HangUp. SlowTransport is a slow
LoopbackClientconsumer.
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
- Wake/EventSlot self-wake touches core. Mitigations: core tests first, the 64-byte assert, and
Customas the documented fallback. - 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. - 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.
- 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.
- 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. - Budget profile loosening. Budget ranges must be set from measured values and reviewed. Never compare production fingerprints to the reference.
- 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 theTransportSessionand is started after the pipeline (it needsRunningPipeline::input()andupstream_input()). Audio istry_sendonly. Lossless frames (DTMF,Stop, a fatalError, the upstreamTtsEnded) go through bounded FIFOs, one per target lane (data, system, upstream), each delivered by its ownselect!branch, so a stuck pipeline never stops the reading and a frame stuck on one lane never holds back another. TheStoptherefore 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 isSystemFrame::Stopinto the first stage plusPumpHandle::end(); a hangup is also issued when the event stream ends withoutStopped(a defensive branch:TransportSessionalways sendsStoppedunless 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 byflush_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 arePumpHandle::rates().TransportEvent::Erroris a NON-FATAL transport fault (the Twilio connection keeps its own fault budget and ends the call withStoppedwhen 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: truerestores the strict reading where the firstErrorhangs 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_resolvedreturns the utterance's epoch, so the upstreamTtsEndedcarries it. A cut mark (MarkCleared) updates the book and ends nothing upstream (the barge-in'sInterruptalready told the machine). - AudioIn forwards a 16 kHz frame untouched (same
Bytes), converts other rates through aStreamResamplerinto a ring of 32 reusableBytesMutslots (no allocation per frame once the ring has been used once), copies the 16 kHz samples into theVadTee, and never waits for the worker. A jump inAudioFrame::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 aNotifypermit. - 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 leastfailure_window(default 1 s of audio time, from the batch timestamps) fail the worker open: one non-fatalErrorframe, no VAD events, the tee is only drained. Fail-open is NOT permanent (review fix): everyrecovery_intervalof audio time (default 5 s,Nonedisables) the next batch is run as a probe through a fresh stream; the first success clears the failed-open flag and the streak (recoveriescounter, 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) andtests/silero_realtime.rs(featuresilero-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)).processnever awaits the provider. Audio is forwarded downstream at once and copied (a refcountedBytes) into a bounded command queue (mutex-guarded ring plus aNotifypermit: audio drop-newest at 64,Finalizeat 32, overflow answered synthetically). ONE session task owns the provider session, the ledger of unansweredFinalizes and every frame the stage emits besides the forwarded ones, so transcripts andUtteranceFinals are strictly ordered. The task connects when the pipeline starts (prewarm; explicitconnecttimeout on the clock) and reconnects after a failed connect or a dead session with a fixed backoff, at mostmax_reconnectsconsecutive attempts; then the stage is degraded until the nextUserStartedSpeakinggrants 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 everyFinalizeis 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 syntheticUtteranceFinal(interims_promoted). A real answer to aFinalizedoes the same when no final segment was forwarded since the lastUtteranceFinal(a session that died mid-utterance leaves its interim behind and its successor answers theFinalizeempty); otherwise the caller's words would be lost. Upstream frames are forwarded withtry_send(drops counted), never awaited fromprocess. - Exactly one
UtteranceFinalperFinalize. Onlyfrom_finalize && speech_finalanswers the oldest outstandingFinalize(aspeech_finalfrom the provider's own endpointing closes nothing; afrom_finalizeresult with nothing outstanding is late and dropped). AFinalizeunanswered afterTimeouts::finalize(3 s, longer than the driver'sfinal_wait) declares the session dead: every outstandingFinalizegets its synthetic answer and the session is replaced, so the provider's late answer can never be seen (no per-Finalizeids exist in the protocol, so a kept session could not tell a late answer from the next one). Finalizenever precedes the speech it finalizes. The VAD stop is a system frame and overtakes queued audio. AFinalizeis therefore held until the stage has processed an audio frame stamped at or after the (backdated) stop time, ormax_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 ownDeadlineTimer, woken throughSystemFrame::Wake), so it holds even when no further audio arrives (a leg that suppresses silence, a stalled lane): theFinalizeis then sent after at most 1 s instead of waiting for the driver'sfinal_wait.- TurnDriver (
TurnDriver::new(TurnDriverConfig::from_engine(&config), clock, timeline), builderswith_smart_turn(model, offload),with_commands(CommandSender),with_interruption(policy),with_probe(TurnProbe)). One task; helpers (the singleDeadlineTimerfor the machine's, the gate's and the response backstop's deadlines, and the Smart Turn job) return through theEventSlotand aWake. Handling is level-triggered: on any wake it compares every deadline withclock.now(). - Smart Turn. The driver owns the 8 s ring (
SmartTurn::pushof the forwarded audio, no allocation per frame; 128 000 samples, 256 KB;SmartTurn::newallocates only the ring, the buffer of its ownanalyzepath is lazy and the driver never callsanalyze).RequestAnalysiscopies the ring withSmartTurn::snapshot_intointo an ownedVec<i16>and runsSmartTurn::analyze_snapshot_blockinginsideOffload::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 namedanalyze_snapshot(offload, samples, timeout); the delivered asyncanalyze_snapshot(&PoolHandle, model, samples, timeout)takes the pool handle becausebrisvox-inferencecannot depend on the engine'sOffload(the engine would be a dependency cycle), and the engine, which must also run underOffload::Inlinein paused-time simulations, callsanalyze_snapshot_blockingitself; the async variant is for pool-only callers. A failed, timed-out or unavailable analysis is counted and the machine'sfallback_silencedeadline ends the turn.brisvox-inferencegainedsnapshot,snapshot_into,model,analyze_snapshot(pool) andanalyze_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 defersFinalizefor. 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 mostANALYSIS_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.
Interruptdoestimeline.interrupt, then the transportClear{new_epoch}through theCommandSender(or aClearHook,with_clear_hook; bounded byTimeouts::send; a failed or timed-out clear is counted and theInterruptstill follows), then pushesSystemFrame::Interrupt, then the next action pushesUserTurnStarted. STT interims are fed asSttInterim(and not forwarded); upstreamTtsStarted/TtsEndedbecomeBotAudioStarted/BotAudioEnded, which open and close the echo window.EngineConfig::interruptionis aCopydescription (InterruptionConfig,OFForRECOMMENDED, defaultRECOMMENDED) of the agent'sInterruptionPolicy; custom phrase lists go throughwith_interruption. - Finals gate. The driver keeps one
OwedStopperUserStoppedSpeaking(with the turn it belongs to;Nonefor a vetoed candidate's stop). The final segments between twoUtteranceFinals are joined into oneSttFinalfor the machine and appended to the turn's text. A machineEndUserTurnis held while a stop of that turn is unanswered, untilfinal_wait. Whenfinal_waitexpires and the turn has no text at all (no final text of an answered stop and no interim), the hold is extended ONCE byTurnDriverConfig::late_final_wait(defaulttimeouts.finalize - final_wait, 1.5 s; zero disables; counted asgate_extensions): the STT stage answers every stop itself atTimeouts::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 joinedTranscriptandUserTurnEnded, marksTurnEndand clears the Smart Turn ring. A gate timeout uses the latest interim if no final text arrived; an empty text emits noUserTurnEndedand feeds the machine aBotAudioEnded. If the user starts speaking while an end is held, it is a RESUME, not a barge-in: the machine is already inAwaitingResponse, but no response was requested and no bot audio exists, so the driver first feeds it the same synthesizedBotAudioEndedas for an empty turn and then theVadStart, which opens a new turn without any guard (no minimum speech, no backchannel veto, noInterrupt, no transportClear). 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
UserTurnEndedaresponse_deadline(cancelled by the firstTtsStartedof that epoch, and by itsTtsEnded; see section 2) feeds a syntheticBotAudioEndedwhen the bot never produces audio.SystemFrame::Stopcloses the machine, drops an open turn and a held end withoutUserTurnEnded, 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) andInterruptedviatimeline.interrupt. - Config rule.
EngineConfig::validaterequirestimeouts.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 realTransportSessionclear, 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 (alsoTtsEndedalone), 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_graceexpiry, barge-in while awaiting the response, stale and repeated bot audio and empty turns, replayed through the driver and required to give the bareTurnMachine's exact action sequence),tests/hybrid_turn.rs(gate 2: pump,AudioIn, VAD worker,SttStageandTurnDriverin 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
TtsEndedupstream, so the echo window is exercised bytests/turn_driver.rsonly; the realTtsStartedcomes withTransportOut.
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 aDropStaleprocessor;UserTurnEndedspawns ONE round task per turn (aTaskGuardand a childCancellationToken) that owns all rounds of the turn.Transcript(final) is consumed,UserTurnEndedis forwarded first,Startwarms the provider in a bounded helper task, upstream frames are forwarded withtry_send. - Round ordinals.
LlmResponseStartedis sent per round (filler and fallback rounds included); the ordinal of a round is the number ofStartedframes of its epoch before it. The runner registers(epoch, ordinal)in thePlayoutBookBEFORE sending the frame and recordsmsg_indexwhen 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_idlebounds every later event; both on the injected clock.LlmTextgoes 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: oneArcclone, one mutex lock, onepush_str; no other allocation. - Finish.
ResponseAssembler::finishruns inline belowLlmConfig::inline_finish_bytes(4 KiB) and on theOffloadabove 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.
Flushbefore 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::executeis raced against the filler delay of theFillerPolicy(fromLlmConfig::filler: by default 1.2 s, or the smallestfiller_afterof the called tools underFillerConfig::PER_TOOL); anExecutionthe 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 viaExecution::append_to(or the flow'sapply_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 answeredtool_round_limitwithout 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 answeredunknown_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 byTimeouts::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 newUserTurnEndedwhile 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;Stopduring 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, upstreamTtsEndedif silent) instead of leaving the machine to its 20 s response deadline. - Errors. Retries (
LlmConfig::retries, default 1,retry_delay200 ms) only while nothing of the response was sent. After the final failure: partial text kept,LlmResponseEnded, and an upstreamTtsEnded(non-blocking) only if the whole turn produced no text (otherwise the TTS chain ends the response itself and an earlyTtsEndedwould 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 setsretries = 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 inllm_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 atUserTurnEnded(before the user message) and atStop; the stub isKeepAll. The production source implements it over thePlayoutBook(async: the settle wait); the hybrid test implements it over the reference word and playout logs, judged atinterrupted_at. The runner bounds the whole pass byTimeouts::analysis_jobon the injected clock (a source that never answers is answered withKeepafter 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), offersflow.tool_specs()per round, screens the calls, applies the results each round (apply_resultsunder 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_replaceddrops the round records, marks the book's rounds applied and forgets the window summary.Sayactions are not played yet. - Request building.
RequestBuilder::buildis the single seam:build_windowwithEngineConfig::llm.window, then (if the latest turn alone is over budget) the tool results of that turn are clipped oldest first totool_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). AfterLlmResponseEndedthe runner starts ONE background task (owned by the runner, cancelled with it, bounded bysummary_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 (acovers_untilat the end of the context would stop being a turn start and the whole summary would be dropped asInvalidSummary). 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 andreset_summarybumps 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_degradedafter 3) and logged atwarn; once degraded a circuit breaker backs the attempts off (summary_timeoutdoubled per further failure, at most 60 s;summary_backoffs), so a hanging summarizer costs a bounded number of billed calls.Done.provider_datacounts against the budget (WindowEstimator) and is bounded: dropped (provider_data_oversized) above 32 KiB or above half a message's allowance (max_message_tokens * 2bytes), parsed on the offload frominline_finish_bytes. The simulator's referenceLlmStageis deliberately unchanged (the golden baseline); the context windowing lives in the engineRequestBuilder. - Cost of a request build (50 us rule).
buildwalks 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::clippedgallops 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.rsbuilding_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 theOffloadwhen the context holds more thanINLINE_MAX_MESSAGES(384) messages or the latest turn more thanINLINE_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 testclipping_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 ofbuild_windowrepeats the measurement under load. - Filler. The filler is sent while
ToolEngine::executeis 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 TokenEstimatoracross the summarizer's await, which must keep the round taskSend). - Tests.
src/{convert,request}.rsunit tests (round trips preservingContext::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
PlayoutBookas truncation source and the settle wait;Sayactions andapply_recorded_resultfor late flow results; speculative tool starts; the realTtsStageconsuming 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_spansreturns 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). TheSegmenterStageemits the raw slicestream[prev_end..chunk_end](leading whitespace included, which Cartesia'scontinue: truewants), keeps only the unemitted tail of the stream, and starts a new stream (offsets from zero) at everyFlush,LlmResponseEnded,LlmResponseStartedandInterrupt. Property tests (tests/segment_spans.rs, EN and TR, trailing whitespace, tinymax_chunk_chars, random deltas) prove that the slices concatenate to the text with its trailing whitespace trimmed, and thatPlayedText::push_sent+apply_truncationnever fail withNotAPrefixat any playback position; the same holds through the stage (tests/segmenter_stage.rs).first_min_wordsis 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. PastRAW_CAP_FACTORxmax_chunk_charsbytes (16,000 with the defaults) the stage ends the stream as aFlushwould (overflow_finishes,raw_high_waterin 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 madeapply_truncationreportNotAPrefixand the unplayed text stayed in the context).TtsStagerecords 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 mostWHITESPACE_LEAD_CAP= 64 KiB per round, conservative; beyond it the whitespace is dropped and counted inwhitespace_dropped, and that one degenerate round falls back to the safeNotAPrefix) 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 threewhitespace_*tests intests/tts_stage.rs. -
Who records the sent slices. The
PlayoutBookis keyed by utterance, and the utterance (provider context) is minted by the TTS stage, soTtsStagecallspush_sentwith 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 ofLlmResponseStartedframes of the epoch so far, minus one (the ordinal the runner registers).TtsEndedis emitted exactly once per response, decided in one mutex-guarded ledger shared by the stage and its reader task: whenLlmResponseEndedarrived and no context of the epoch is pending. Ordering rules that make that safe: the providerFlushgoes out BEFORE theLlmResponseEndedframe is forwarded (the last words wait for it); the ledger is updated AFTER the audio tail of a flushed context was handed on (aTtsEndedcan 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 duplicateLlmResponseEndedis counted and ignored. -
The reader task owns the
OutputFramer. Provider PCM atEngineRates::ttsis converted to the call's output rate (CallRatesCell, published by the pump throughPump::spawn_publishing; the engine's fallback rates untilStarted) and cut into exactEngineRates::frameframes; 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: oneBox. A chunk is converted in pieces of at mostmax_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 theOffloadat the first audio of a call.UtteranceStartedis 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 anInterrupt(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 atStart(prewarm) and again when a round with text finds it dead; connect is bounded byTimeouts::connecton the clock and abandoned on an interrupt; after a failure the stage backs offreconnect_backoffcounted 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 thanmax_reconnectsconsecutive 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 owedTtsEnded; the rest of the round whose context died is NOT resurrected on the new session (its words would restart at offset zero; testthe_rest_of_a_round_whose_session_died_is_dropped_not_replayed_on_a_new_session). A provider that is silent foridle_timeout(defaultTimeouts::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 aFlushuntil itsFlushed; 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.Stallfaults that end by themselves just delay the response. -
TransportOutis 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 ownOutputPacerspaces 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 mostTimeouts::sendand not past the end of the epoch; a frame the transport does not take marks its utterance lost. The stage isKeepAlland filters epochs itself so it can count (stale_dropped,stale_control): the newest of {its ownClearepoch, the lastInterrupt} is the floor. Epoch ledger rule:Clear { epoch }before the first audio of any epoch above the stage'stx_epoch(noInterruptis needed, the second turn of a call is the case that matters), idempotent with the turn driver's own clear on a barge-in; anInterruptmarksAudioOutStoppedon the epoch whose audio was playing and sends an idempotentClear. The first audio the transport accepted marksFirstAudioOutand sendsTtsStartedupstream, once per epoch. -
End mark.
TtsEndedbecomesMark { 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 sendsTtsEndedback through this stage; it is let through once per epoch (ended_through), so the echo, amark_timeoutend and a response end without audio can never report the same epoch twice. A client that never echoes: the stage ends the responseTimeouts::mark(10 s) after the audio should have played out (first handoff plus the audio handed over), oneDeadlineTimerwoken throughSystemFrame::Wake; a late echo is a counted duplicate. A cut epoch never reports an end: its staleTtsEnded,UtteranceStartedand late mark echo are dropped and counted (stale_control), its pending mark is forgotten at theInterrupt, and the overdue mark of an epoch the transport already moved past ends nothing (testsa_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 ofTtsEndedfails it).FirstAudioOutandTtsStarteddescribe the moment the transport took the frame, not its arrival (testthe_first_audio_out_waits_until_the_transport_has_taken_the_frameblocks the first send for a second), and anInterruptalone 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::Codecinside 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 readsStarted);brisvox_sim::InputTarget::bind_upstream(default no-op, called bySimSession::run_withright afterbind: 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 anInputSinkmethod called once by the runner,session.rs);brisvox-simstill does not depend on the engine. Test:crates/brisvox-sim/tests/seam.rs, the whole-suite test counts onebind_upstreamper session, afterbind, anda_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 indocs/LATENCY.md, "Segmenter push cost in the stage"; the test istests/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 istests/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
LlmRunnersends its own upstreamTtsEnded(nothing downstream would report the end), and theTtsStagestill ends the response, whichTransportOutturns into a direct end (no audio, no mark). The runner's frame travels from the runner up to the driver and never passesTransportOut(which is downstream of it), so no stage can deduplicate the two: the driver receives twoTtsEndedfor a silent turn and one for a spoken turn (the mark echo). Harmless: the turn machine ignoresBotAudioEndedwhile idle. "Exactly oneTtsEndedper 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), andtests/hybrid_output.rs(gate 4). -
Gate 4 (
tests/hybrid_output.rs): engine pump,AudioIn, VAD,SttStage,TurnDriver,LlmRunner, thenSegmenterStage,TtsStage, a recording tap andTransportOuton a realTransportSessionover 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 thePlayoutBookthe 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 productionPlayoutTruncation. 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 (LoopbackClientreceived_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'scontinue/flushsemantics, word timestamps of ElevenLabs/Cartesia against the normalized text).real
TtsStageconsuming the ordinals.
Played-text truncation from the PlayoutBook
- The source.
PlayoutTruncation(truncation.rs,PlayoutTruncationConfig::from_engine:lead_margin,settle_wait,PlayedConfig) is the productionTruncationSource;LlmRunnerBuilder::with_playout(book)iswith_bookplus it. The runner is unchanged: it already asked last round first atUserTurnEndedandStop, bounded byanalysis_job, and assertedContext::validateafter every cut. The source answersKeepfor a round the book no longer knows or with blank text,HeardInFullfor a round played to its end, andHeardotherwise (PlayedText::heardat 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 ofsettle_waitafter 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_waitmust stay well belowTimeouts::analysis_job(the runner's bound for the whole pass):EngineConfig::validaterequiressettle_wait <= analysis_job / 2andPlayoutTruncationConfig::from_engineclamps to it, so the pass never hits the runner's deadline (which would answerKeepand 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_indexnone): 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 outsettle_waitand the runner's speculation answeredskipped_busyfor the rest of the call. Not waiting for it costs nothing that matters: its stale, lowerreleasedonly 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
playoutmodule 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_marginassumes 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 byPlayoutTruncation::new), the time a position report last movedreleasedfor each utterance;RoundSnapshot::margin_attakes 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-levelafter_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 theTurnDriverclear hook:book.note_clear(new_epoch), thenTransportCommand::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 bythe_clear_hook_tells_the_book_before_it_sends_the_commandand, over the loopback transport,the_clear_hook_tells_the_book_before_the_clear_reaches_the_client). Use it instead ofTurnDriver::with_commandswherever the production source reads the book. The assembly MUST installclear_hookon the TurnDriver andLlmRunnerBuilder::with_playouton the runner with the SAME book the stages write. Without the note a mid-utterance cut never settles (every barge-in waits outsettle_wait) and the client's lead is not tied to its clear. Miswiring is detected, not silent:BookStats::clears_notedcounts the notes, andPlayoutTruncation::unnoted_clear_cuts()counts interrupted responses asked about while it was zero (first occurrence logged as an error onbrisvox::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): theTtsEndedof the cut response can be handled after the transport applied theClearbut before theInterruptreached the stage's epoch watch.CommandSender::sendreads the ledger epoch when the command is enqueued, so theMarkcounts 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 staleMarkon the wire (and theBotAudioEndedit makes upstream) remain. A proper fix is inTransportOut(check the book's clear floor before sending a mark) or in the transport (TransportCommand::Markcarrying 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 tomin(released, total) - leadand can drop up to one lead of the end of a sentence the caller heard (testa_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_margin0 (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 withhybrid_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 sharedsuite::played_textchecks 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 assertsTruncationStats::estimated), anda_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). TheEngineFactorytap records everything but audio as it passes; audio is recorded at wire arrival, so the shared played-text suite runs unchanged onEngineFactory. 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
Transcriptdownstream 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 finalTranscriptandUserTurnEnded, so there is no race between a candidate and the commit.UserTurnStarted,Interrupt,StopandCancelalso drop a speculation. (A dedicatedControlKindwould be cleaner; it needs a change inbrisvox-coreand was left out to keep the change additive.) - Candidates (TurnDriver). (1) At the
UtteranceFinalof 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) Withon_partial, at the VAD stop that paused the machine: the final segments heard so far, else the latest interim; withpartial_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
UserTurnEndedthe 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 thanmax_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.
İisi(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 (şuis notsu). 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.
- Normalisation.
- 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). OneTaskGuardtask 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 piecesLlmTextwould carry into a bounded channel (max_buffered_events, 256, validated to at mostSpeculationConfig::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 atmax_ageunless the turn claimed it, and when its token is cancelled. Nothing reaches the pipeline, thePlayoutBook, 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 (
LlmResponseStartedfirst, so thePlayoutBookregistration 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
Donethe helper finishes the assembler as the round task does and, with a tool engine and no flow, callsToolEngine::speculate: ReadOnly calls run now, SideEffect calls are held inside theSpeculation. At the commit the round task pushes the assistant message, sendsFlush, and runsToolEngine::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) andwasted_input_tokens(provider-reported),head_start_ms; the snapshot'shit_rate()is hits over decided speculations. The driver countsspeculations_offered,speculations_withdrawnandspeculations_capped. - Costs and limits. Every discarded speculation is a billed request;
on_partialtrades 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::speculatedocuments 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_partialoff) 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 inllm_runner.rs(a claimed speculation keeps its helper pastmax_agewhile the turn settles; an expired one cannot be claimed) and inllm_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), andtests/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 withon_partialthe 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).
SttStageandTtsStageconnect atStart;LlmRunnercallsLlmProvider::warm()atStartin aTaskGuardunderTimeouts::connectand countswarms/warm_failures; the OpenAI, Anthropic, Gemini and OpenAI-compatible adapters warm with an unauthenticatedHEAD; Deepgram warms DNS, Cartesia parks one socket, ElevenLabs warms its socket; Deepgram sendsKeepAliveafterkeepalive_intervalwithout 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 thewarm()HEAD warmed a pool nobody else could use. Nowbrisvox_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, onewarnlog), so sharing never switches off.net::pool_stats()exposesbuilt/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 throughbuild_with, the function behind the production client): ALPN settles onh2; awarm()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 atStart, before the first connect, under the connect timeout on the injected clock (SttStatsSnapshot/TtsStatsSnapshotgainwarmsandwarm_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 inhybrid_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 underTimeouts::connectand 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 (LlmResponseStartedfinds a dead session): the reconnect overlaps the LLM's time to first token instead of waiting for the first text. Proved inhybrid_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.rsruns the production chain on a paused clock with probe providers that give everywarm()andconnect()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 incrates/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 intests/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:
ResilientTtsreplaces a session idle for 80 % ofTtsProvider::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 bySessionBuilder::with_vad) and the turn driver asksTurnModel::warm(threads)atStart; see "Assembly" below.hybrid_prewarmstill covers the providers only;run_call.rsandwarm_pool.rscover 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; seedocs/RISKS.mdR16 (capacity follow-up: measure with N concurrent calls, shard the pool key if it limits).
- (Built, wired by the server) A proactive refresh of an idle TTS socket before the
provider's idle limit:
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 theEngineConfig. TheOffloadis required (the pool in production,Offload::Inlineonly 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), aFrameTap(tests), aTurnProbe, 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 bytimeouts.stop;Error::Timeoutafter cancelling a pipeline that did not drain),Call::cancel, dropping theCall, andCall::waitreturning (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 awaitCall::wait(or stop, cancel, drop). - Tap upstream drops. The test-only tap stage has the
sinkinbox (4 data frames), so the upstream laneTransportOutwrites 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 ofEngineConfig::queues(input: 8 data frames, drop-oldest; user: 8, lossless; bot: 32, lossless; sink: 4, lossless; system lanes 32). - One
PlayoutBookper call, shared by the pump (marks, positions),TtsStage(utterances, sent text),TransportOut(words, audio), the runner (round to message index) and thePlayoutTruncationsource, and the turn driver's clear hook. - Clear hook. The driver clears through
playout::clear_hook(commands, book):note_clearon the book, then theClearcommand.SessionHandles::unnoted_clear_cutsstays 0 when the hook is wired;run_call.rsand everyengine_suiterun assert it (removing the hook fails five tests). The stale endMarkthat could reach the wire after the driver'sClearis fixed:TransportOutsends marks withCommandSender::send_in(the epoch they belong to), so a mark whose epoch aClearended between the stage's epoch check and the send is cut by the transport (MarkCleared), never written after the clear (transport.rsandtransport_out.rstests; the latter fails with plainsend). - Warm-up at
Start. STT and TTS (providerwarm, then connect) and the runner (LlmProvider::warm) as before; new: the VAD worker runsSpeechModel::warmonce per offload thread (Offload::warm_each, every job submitted at once so each idle pool thread takes one;VadSetup::with_warm_up, bounded bytimeouts.connect), and the turn driver runsTurnModel::warm(offload.threads())as one pool job atStart(the ORT backend prepares one session per concurrent analysis). Failures are counted (warm_failures) and never fail the call. A bareVadSetupdoes not warm (its factory may be scripted in worker tests). - Early analysis.
SessionBuilder::with_vadappliesVadSetup::with_early_analysis(&config), the VAD half ofEngineConfig::early_analysis; the driver half is its configuration. run_call(session, builder, &cancel) -> Call. Builds the pipeline oversession.commands(), starts it under a child ofcancel, then starts the pump under the same child with the running pipeline's two inputs.Call::wait(caller hang-up),Call::stop(graceful, bounded bytimeouts.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 (TaskCountertest).build_session_pipelinereturns the unstarted pipeline, the handles and aPumpWiring, for harnesses that start the pipeline themselves (the simulator binds the pump inInputTarget::bind_upstream).- Tap. A
FrameTapis a pass-throughKeepAllstage in front ofTransportOut(it sees whatTransportOutsees;TransportOutfilters 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), realTransportSessionand pump,Offload::Inline, the simulator's energy VAD as anEnergyModelbehind the production VAD worker,FixedTurnModelfor the adaptive policy, optionaltime_intool. The tap recordsInterruptand 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_warmkeys:vad:<VadSetup::with_model_id>, elsevad:<SpeechModel::warm_key>for a setup made withVadSetup::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) andsmart_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 afterWARM_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 thanWARM_MAX_QUEUE_PER_THREAD(1) job per thread (warm_skips): live VAD windows go first. Smart Turn warms withsmart_turn_warm_jobs(threads)= half the threads (at least one) jobs at once, each askingwarm(threads); the ORT backend reserves every session before creating it, so the jobs createthreadssessions in all (about two each). Spansvad_warm/smart_turn_warmonbrisvox::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'sfs::metadatanever 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_heavyalso counts the whole context (INLINE_MAX_CONTEXT_BYTES, 256 KiB). - Margin fade.
note_clearstamps the clear; the lead margin fades only up tomin(interrupted_at, clear). - Stats.
Call::stats()returnsCallStats { pipeline, pump, stages: StageCounters }(every stage's counters, the book's, the truncation counters);CallStats::tracewrites onecall statsevent onbrisvox::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.rsruns the shared 100-call chaos suite onEngineFactory(Budget). The slow consumer is a slow reader of the loopback client (LoopbackClient::split, a one-event client queue,transport_delayafter 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 indocs/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/ResilientTtsimplement the provider traits over a primaryLegand an optional secondaryLeg(a provider instance plus itsCircuitBreaker). The engine sees an ordinary provider: no engine code changed. Breakers and the processRetryBudgetare 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 moststt_replay, 4 s) is replayed into the new session, followed by an unansweredFinalize. 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. Aspeech_finalsegment (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 toldCancelled). - Idle refresh. A
ResilientTtssession with nothing in flight for 80 % of the provider'sidle_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
Bytesclones 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.rspins 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 itsCancelledacross 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::ResilientProvidersholds one process-wideResilienceand oneLegper provider instance;providers()builds aResilientStt/ResilientLlm/ResilientTtsover them for every call (IdleRefresh::Auto), with the agent definition's[stt|llm|tts].secondaryas the secondary leg. A secondary TTS is anOwnVoice: 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.rsjudges 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
- Schema: every tool declares name, description, JSON Schema for args,
kind(ReadOnly|SideEffect), timeout, optional filler policy. - Normalization: one internal representation; per-provider adapters translate (OpenAI, Anthropic, Gemini, OpenAI-compatible). Tool-call ids are stable internally.
- Assembly: streamed argument deltas are buffered until complete; partial JSON never executes.
- Validation: args validated against schema before execution. Invalid → structured error result to the LLM; bounded retries (default 2), then graceful spoken fallback.
- Execution: independent calls run in parallel; results appended to context in the order the LLM issued them.
- 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_doneunless the tool isrepeatable. The scope of this guarantee is exactly that, see "What "never twice" covers" below. - Speculation: SideEffect calls from speculative LLM runs are held until the turn commits; discarded if the speculation is cancelled. ReadOnly calls may run speculatively.
- Interruption: ReadOnly in-flight calls are cancelled. SideEffect in-flight calls complete, are recorded, and are reported to the LLM on the next turn.
- 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. - 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:
| Situation | What happens |
|---|---|
| The same tool call (same session, turn epoch and call id) is delivered again: a replay, a redelivery over the agent link | The 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 result | Refused 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 response | Two 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 again | A new session with an empty store: it runs. |
| The server restarts, or another instance handles the call | The 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 error | Recorded 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-Keyheader 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.idempotencyKeyin the SDKs (andctx.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) andexamples/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>, whereseqcounts every call of the turn across tool-loop rounds and restarts — mint them withContext::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 inToolCall::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_callscloses them with an "interrupted" error result so the next turn is valid; a SideEffect call that finishes later is reported byContext::push_late_tool_resultas a system note. - Provider round-trip data (reasoning signatures etc.) rides in
provider_dataon the assistant message and on each call; adapters define and verify its contents. - Token estimates go through
TokenEstimator(defaultCharsPerToken: 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/namemay arrive on any fragment: the first non-empty value wins, a different later value is anAssembleErrorand the delta is not applied. Empty strings count as absent.- Nothing is parsed while streaming.
finish(epoch, &Context)parses once: ids are minted withContext::next_tool_call_idsin index order, empty or blank arguments mean{}, and a call with no name, invalid JSON or a non-object becomesAssembled::Invalid(its id, raw text andCallError), answered withInvalidCall::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 isAssembleError::TooManyCallsand 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 isConflictingCallProviderData), andDelta::ProviderDatais kept per response (ConflictingResponseProviderDataon a different later value). Both are carried verbatim intoassistant_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:cancelconsumes the assembler, and a doctest checks thatfinishcannot be called afterwards. Response::assistant_message()builds the assistantMessagefor the context (Nonewhen there is no text and no call; provider data alone does not count). A named invalid call is recorded under its name with{}arguments, andunknown_toolis 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::validaterejects the second push (pinned by a test). - Cost:
finishparses linearly in the buffered bytes. Run it onbrisvox-inferencefor responses near the cap, not on a tokio worker. - Decision:
id/nametake the first non-empty value. The literal "first non-None" reading would keepSome("")and raiseConflictingIdon the real id that follows, which breaks providers that repeat empty fields on every chunk. warm()is not part of the assembler. It stays onLlmProvider::warm(default no-op) and is wired by the connection pre-warming code that knows the provider adapters. This keepsbrisvox-agentfree of a dependency onbrisvox-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::ReadOnlyorToolKind::SideEffect.repeatable(defaultfalse,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: theFillerPolicyreadsfiller_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:confirmorrepeatableon 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
executefuture. It keeps running after its slot was answeredtimeoutorinterrupted. 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 theToolEngine(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
callor in its future) is answeredtool_failedwith the detailthe tool panicked, and its sibling calls are answered normally. A panicking SideEffect task is recorded asLost(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 overmax_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 overmax_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:
- Plan, synchronously and in issue order. Calls past position 32 of one response are answered
too_many_calls. Malformed calls are answered at once. Underspeculate, SideEffect calls are held. Nothing runs during planning. - 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 answeredbudget_exhausted, andExecution::budget_exhausted(orSpeculation::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.
- A call of an older turn is stale: every call of the round is answered
- Run. The steps start in issue order, then all of them are awaited together (
join_all). Each result is answered in its own slot, soresultsis in issue order whatever the completion order. Two SideEffect calls of one response that share a call id are not both run: the second answersduplicate_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 anExecutionwhoseresultsare the whole response in issue order (ReadOnly and held results interleaved as the LLM issued them). Append it, as forexecute. Commit spends no budget. A speculation of a superseded turn answers its held callsstale_turn, and a turn whose budget is spent since then answers thembudget_exhausted. - Discard: drop the
Speculation. Its held calls never run. The caller then closes the open calls withContext::interrupt_pending_tool_calls(), so the context stays valid (Context::validatewould 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
interruptedand 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
interruptedat once (outcomeinterrupted_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 asContext::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
executenever 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(outcometimed_out). - SideEffect: the slot answers
timeoutwith a detail that says the call keeps running, that its result follows as a note, and not to repeat it (outcometimed_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,speculateorcommitfuture does not drop it. The task records the result, or, when its task ends without one (a panic, an abort), records the key asLost. The engine keeps the tasks in aJoinSet(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 asLost. - 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 asLost(unknown_outcome, never run again, outcomelost), 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 answerstore_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
executefuture 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 dropsexecuteloses nothing:Context::interrupt_pending_tool_callstells 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 answersduplicate_call. A lost key answersunknown_outcomeand is not run again. When every entry is in flight, a new call answersstore_full. - Bounds, per session: at most
idempotency_capacityentries andidempotency_max_bytesof 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
Erris recorded like a success: the key isDonewith thetool_failederror as its result (outcomefailed). 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_bytesis cut to that size, with the marker...[truncated], and it is recorded and reported as done (outcomeok_truncated), because the effect happened. ReadOnly results over the limit becomeresult_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
Doneand not an error, is not run: it answersalready_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 inside_effect_repeats, traced asrepeat_refused. Found by the scenarioS3(docs/TOOLS.md, "Verification"): a model that read the confirmation of itsbook_tableasked 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 declaredrepeatable(ToolSpec::with_repeatable(true): pressing the same IVR digit twice in separate responses; the built-insend_dtmfis repeatable,end_callandtransfer_callare not; a tool declared by a remote agent opts out withrepeatablein 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 mostORPHAN_CAPACITY= 32), under the state lock thatstart_side_effectalready holds. - Late notes: a late result that finds the mailbox full (
MAILBOX_CAPACITY= 64) is counted inlate_notes_dropped()and not reported. Otherwise a late result waits for the nextexecuteorcommit, which append it after their results (Execution::late_notes), or fortake_late_notes(). Calltake_late_notes()only when no tool call is open in the context (after anExecutionwas 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.
- Push the response's assistant message (
Response::assistant_message) beforeexecuteorspeculate. The engine never reads the context. - Append each
Execution(append_to) before the next message of the turn. - On discarding a speculation, or dropping an
executefuture mid-round (for example in aselect!on barge-in), close the open calls withContext::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. - 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):
| arguments | bytes | time |
|---|---|---|
typical (city, days) | 26 | under 1 us |
uniqueItems array of 2500 integers | 11,399 | 22 us |
uniqueItems array of 1500 strings | 12,399 | 24 us |
uniqueItems array of 800 objects | 7,899 | 45 us |
16 KB string against an enum | 16,008 | 9 us |
1500 strings that all fail a pattern (error path, 5 issues reported) | 10,899 | 0.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
calland in its future) answeredtool_failednext 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 KiBuniqueItemsarray is refused); - ReadOnly interruption (dropped); SideEffect interruption (not dropped, reported later as a note);
a result sent before a dropped
executeis read, and a slot resolved inside a droppedexecute(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 droppedexecutefuture (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_fullwithout 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 pinneddigest()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_doneand the tool runs once; other arguments, a new turn, two calls in one response, arepeatabletool, 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
executeafter 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_exhaustedafter the budget ran out since the speculation (and for a speculation made under an exhausted budget), answersinterruptedwith 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) andretries,heldandlate_resultas separate events, and no argument value in any field (a capture layer,brisvox::toolstarget); 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 areSendandSync.
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
Clockfrom 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 withwith_delay. A tool opts out withToolSpec::with_filler_after(None); under a fixed delay its ownSome(d)is only the on/off switch.FillerPolicy::per_tool()takes each tool'sfiller_afteras its delay (the original behaviour).FillerConfig { enabled, delay }(FillerConfig::DEFAULT= on, 1.2 s;FillerConfig::PER_TOOL= on, each tool's own delay) +from_configbuild either. A round with several calls fills after the smallest delay of its calls (ties: the first call). -
Session config:
LlmConfig::filler(inEngineConfig::llm, defaultFillerConfig::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_policyreplaces 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_afterwould. 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 withdelay_msis 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 (chooseis 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 phraseDEFAULT_FILLER_TEXT("One moment, please."). An empty list orwith_enabled(false)switches fillers off. -
Pre-rendered clips: a
FillerPhrasemay carry aClipKey.with_clips(Arc<dyn FillerClips>)names the cache; the key reaches theFillerChoiceonly when the cache reports the clip as available (StaticClipsis the simple implementation; the engine'sFillerClipStorealso holds the audio). The runner counts the choices that had a clip (LlmStats::filler_clips). -
Clip playback:
brisvox_engine::filler_clip. AFillerClipis PCM16 mono at the TTS provider's rate with its word timings (FillerClip::new, orwith_spread_wordsfor audio rendered without timings); aFillerClipStoremapsClipKeys 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 theTtsStage(with_clips);with_filler_policysets phrases with clip keys. The path:- The runner's filler round cues its
RoundKeywith the clip key in thePlayoutBook(cue_clip) before itsLlmResponseStarted, then sends the phrase text as usual. - 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_utterancein 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). - 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, theBytesshared and copied once per piece), then the clip'sWords, then the end of the context (TtsEndedwhen it was the last one of the response). - A provider context opened while a clip is pending waits for it (
clip_waits; abandoned at a barge-in, bounded byCLIP_WAIT_LIMIT= 30 s), because a provider streams contexts independently and its audio would otherwise overtake the clip. - 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).
- The runner's filler round cues its
-
In the runner: the policy from
LlmConfig::filler, orLlmRunnerBuilder::with_filler_policy;with_phrases(filler, fallback)replaces the generic phrase of the current policy. The filler is its own round (ownLlmResponseStarted, own ordinal in thePlayoutBook). 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 (
haltat a barge-in, the next user turn,Stop, panic recovery): after the open calls were closed withinterruptedresults, and before the user message.
Its round gets its
msg_indexthen (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. - right before the next assistant message of the turn (the model's next response, or the
fallback line):
Interruption (item 8)
-
ReadOnly: cancelled. Its future is dropped when the turn's token fires, or when the caller's
executefuture is dropped or aborted (the runner aborts its round task at a barge-in). Both are counted inToolStatsSnapshot::read_only_cancelled; a call that had not started isnot_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, itsexecutefuture was dropped, or itsExecutionwas dropped before it reached the context (see "Delivery" below). A later call of another response run (eachexecuteorcommitis 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 withinEngineConfig::orphan_window(120 s,ZEROturns 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'slate_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_notesor carried by a deliveredExecution; 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.
- orphan finished: its recorded result, under the new call id (
-
Delivery: an
Executioncarries aDeliveryguard.Execution::append_to(ormark_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 afterexecutereturned) 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'sLlmStatskeepsfillers,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:
- 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. - Text deltas are spoken while they stream; tool-call deltas only go to the assembler (item 3).
- At
Donethe assistant message, calls included, is pushed to the context once, before any call runs. With calls, aFlushmakes the text before the tool speak while it runs. ToolEngine::executeraces 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 mostmax_tool_rounds(6) rounds; past it, or when the retry budget for invalid calls is spent, a fixed fallback line is spoken (item 4).- 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"). - Speculation is opt-in (
EngineConfig::speculation, default off; item 7): a speculative run callsToolEngine::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. - 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 sendstool.canceland a latetool.resultis ignored. - SideEffect (item 8): the engine answers the slot
timeoutand keeps the future; the link keeps waiting up toside_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" (atool_failedresult 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, stageagent_link). tools.setnarrows 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 marksTurnEvent::LlmRequest(first wins, as before).TimelineRecorder::llm_first_token(epoch, round, at): the round's first token. Also marksTurnEvent::LlmFirstToken(first wins).TimelineRecorder::llm_done(epoch, round, at): the round's completion.TurnRecord::llm_rounds(),llm_round(i)andllm_round_count()giveLlmRoundMetrics { 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 inTimelineStats::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 inTimelineStats(recordedif it changed the turn mark or opened its round,repeatedif it changed nothing).llm_rounds_droppedcounts requests past the cap. - Summaries:
brisvox::timelineemits onellm request finishedevent 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.
| source | tools | how |
|---|---|---|
| application | anything | the 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_appointment | canned answers labelled as demo data, so examples/agents run end to end; never for callers |
telephony (feature call-control) | transfer_call, send_dtmf, end_call | registered 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 aReadOnlytool: 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 aToolErrorwhose text tells the model what to say or retry.save_note(text)is aSideEffecttool: never speculative, never run twice for one call. It still keys its effect onctx.idempotency_key(), so a redelivered request gets the same note back. Key your own store on the wholeIdempotencyKey(distinct keys may share a digest); passdigest()only to an external API as its idempotency key.- State that outlives a call (here the notes) lives behind an
Arcinside yourAppTools; per-call data comes only fromToolCallCtx.
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.
- 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."} - The model asks the user. The user answers in a later user turn.
- 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 theconfirm_pendingcall.
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); aconfirm_pendingmade 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_startedmarks 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_pendingfinds 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'sMockLlmon a paused clock (calls arriving one after the other:plain, and with interleaved argument fragments:interleaved) and the real adaptersOpenAiCompatibleLlm,AnthropicLlmandGeminiLlmover 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 (onetoolmessage per result for OpenAI; thetool_resultblocks of one user message for Anthropic; thefunctionResponseparts 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.
| # | Scenario | Tools | What the exact (scripted) run pins |
|---|---|---|---|
| S1 | three independent lookups in one response | get_weather x3, 600/400/200 ms | one 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 |
| S2 | chain of three rounds | get_order -> get_customer -> get_weather | four 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 |
| S3 | a booking next to a lookup, the model repeats the booking | book_table (SideEffect), get_weather | the table is booked once (BK-1001, never BK-1002); the repeat is answered already_done; three results in issue order |
| S4 | arguments that break the schema | get_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 |
| S4b | the model never gets it right | get_forecast | exactly three requests, nothing runs, budget_exhausted, one spoken fallback, no endless loop |
| S4c | broken calls among good ones | get_weather + an unknown tool | results ok / invalid_arguments / unknown_tool / ok in issue order, the two good calls run together, the retry budget is spent once |
| S5 | a slow tool | get_weather 2 s, filler after 1.2 s | exactly one filler, at the policy's delay (+/- 30 ms of simulated time), before the tool ends, then the answer |
| S5b | three slow calls | get_weather x3, 2 to 3 s | still one filler; phase length is the slowest call |
| S5c | a tool past its timeout | get_weather 5 s, timeout 600 ms | the future is dropped, timeout result, one filler, the model apologises, the next request goes out at the timeout and not at 5 s |
| S6 | barge-in while the tools run | book_table 600 ms + get_weather 5 s | the 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.
| Scenario | MockLlm plain | MockLlm interleaved | OpenAI-compatible | Anthropic | Gemini |
|---|---|---|---|---|---|
| S1 parallel | pass | pass | pass | pass | pass |
| S2 chain | pass | pass | pass | pass | pass |
| S3 repeated booking | pass | pass | pass | pass | pass |
| S4 invalid then valid | pass | pass | pass | pass | pass |
| S4b invalid forever | pass | pass | pass | pass | pass |
| S4c broken among good | pass | pass | pass | pass | pass |
| S5 slow tool + filler | pass | pass | pass | pass | pass |
| S5b slow parallel, one filler | pass | pass | pass | pass | pass |
| S5c tool timeout | pass | pass | pass | pass | pass |
| S6 barge-in | pass | pass | pass | pass | pass |
| loose (live) assertions, S1 to S6 | pass | - | pass | pass | pass |
| S2S stage: S1, S2, S3 | scripted 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):
| Provider | Variables |
|---|---|
| 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) |
| Anthropic | ANTHROPIC_API_KEY; optional ANTHROPIC_MODEL |
| Gemini | GEMINI_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.
| Scenario | OpenRouter openai/gpt-6-luna | Anthropic (Haiku 5.5) | Gemini gemini-3.1-flash-lite | Gemini 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 s | ok: 3 calls, 1.4 s, 1 round, 3.3 s | ok: 3 calls, 1.8 s, 1 round | ok: 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 s | ok: 3 rounds, 0.6-1.1 s per round, 5.1 s | ok: 3 rounds (spaced 13 s) | ok: Africa -> Algeria -> Algiers -> weather, 3 rounds |
| S3 booking repeated | ok: booked once | ok: booked once | ok | n/a |
| S4 invalid args | ok: retried with valid args | ok | n/a | n/a |
| S5 slow tool + filler | ok: 1 filler | ok: 1 filler | n/a | n/a |
| S6 barge-in | ok: late note next turn | ok: late note next turn | n/a | n/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.
| Model | Params, quant | GPU memory | Scenarios passed (of 18) | TTFT p50 | First sentence p50 |
|---|---|---|---|---|---|
gemma4:12b-it-qat | 11.9B, Q4_0 | 7.7 GB (all on GPU) | 18/18: S1 to S6 each 3/3 | 69 ms | 267 ms |
gemma4:e4b-it-qat | 7.5B (4B effective), Q4_0 | 3.1 GB | 12/18: S2 0/3, S4 0/3, rest 3/3 | 42 ms | 157 ms |
qwen3.5:4b | 4.7B, Q4_K_M | 3.1 GB | 12/18: S2 0/3, S4 0/3, rest 3/3 | 65 ms | 208 ms |
qwen3.5:9b | 9.7B, Q4_K_M | 5.5 GB | 12/18: S1 2/3, S2 2/3, S4 0/3, S5 2/3, S3 and S6 3/3 | 79 ms | 291 ms |
qwen2.5-coder:7b | 7.6B, Q4_K_M | 4.7 GB | 0/18 | 34 ms | 220 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:4band, once in three,qwen3.5:9bcallget_customerandget_weatherin the same response with a guessed city (Rome, Paris) instead of waiting forget_customer.gemma4:e4b-it-qatanswered nothing at all (an empty response, no text and no call). - S4, an invalid argument (
daysabove its maximum). Every model butgemma4:12b-it-qatread the structuredinvalid_argumentsresult, 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:9bonce 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:7bnever 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
Bytesframes on a bounded channel (the engine usestry_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 percall_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'sSpeechStarted. 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:UserStartedSpeakingis the barge-in decision,UserStoppedSpeakingcommits 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:
hook(new_epoch):note_clearon thePlayoutBook, then the transportClear(fast path), the sameclear_hookas the turn driver (bounded bytimeouts.send).Interrupt{new_epoch}downstream;TransportOutmarksAudioOutStoppedand drops stale frames.- 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 modeCancelResponseis sent (the provider's VAD does it itself in Provider mode). - 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
Commitahead of it. - Liveness: the OpenAI adapter pings after
keepalive_intervalwithout any inbound frame and fails the session with a timeout error when no frame (Pong) arrives withinio_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
VecDequein the task, bounded byS2sOptions::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'sio_timeoutfor 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 tomax_reconnectsconsecutive 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 (anEngineEvent::Errorwithfatal) and drops caller audio, counted. The task is aTaskGuard: 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.updateshape, base64 audio, every control event, event mapping, a tool call delivered once from either event,function_call_outputbeforeresponse.create, barge-in truncation (conversation.item.truncatewithaudio_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.updatefields 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,clientContentmid-session, emptyturnCompleteas a response request, binary frames,voiceActivityby default, header key), and what OpenAI does at the 60 minute limit and with an assistantoutput_textseed 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
AudioIntarget). brisvox-serverconfig switch anddocs/CONFIG.mdentry for choosing an S2S provider.
Protocols
brisvox speaks three protocols to the outside, each with its own reference page:
| Protocol | For | Direction | Reference |
|---|---|---|---|
| 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 |
| RTVI | Pipecat'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_rateandencodingthe client gave instart: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_mismatchnotice per call. The engine never sends such audio: it reads the call's rate from the transport'sStartedevent 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
type | fields | meaning |
|---|---|---|
start | version (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). |
dtmf | digit: one of 0-9 * # A-D (a-d are accepted too and mean the same) | A key press. |
mark | id (at most 256 bytes) | Echo of a server mark: everything the server sent before it has been played. |
stop | The client hangs up. |
Unknown fields are ignored. A text frame larger than max_control_bytes (default 16 KiB) is an
error.
Server to client
type | fields | meaning |
|---|---|---|
started | version, call_id, sample_rate, encoding | The call is open. Bot audio can follow from here. |
clear | epoch | Barge-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). |
mark | id (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. |
stop | reason: 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. |
error | code, message, fatal | A 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
- The client connects (HTTP upgrade). If the server is at
max_calls(or, inbrisvox-server, atBRISVOX_MAX_SESSIONS), the answer is HTTP 503 withRetry-After: 1, and no websocket exists. Abrisvox-serverwhose inference pool is overloaded (5) or whose provider budget is exhausted (2) answers 503 with a longerRetry-Afterthe same way. Abrisvox-serverthat is draining for a shutdown answers HTTP 503 withoutRetry-After: connect to another instance (docs/OPERATIONS.md). - The client sends
startwithinstart_timeout(default 5 s). - The server validates it and answers
started(or a fatalerror, then a close). - 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 forping_timeout(default 30 s) ends the call withping_timeout. - The call ends when:
- the client sends
stop: the server answersstop{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 sendstopafter it read the client's close frame, so a client that wants thestopmessage should sendstopand wait for the server's close.stopfollowed at once by a close frame (a browser'ssend(stop); close()) works: the server holds back reading the close frame until it has sentstop{ended}, and the engine seesPeerHangup; - 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 noerrorevent: 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).
- the client sends
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.
code | fatal | close code | when |
|---|---|---|---|
invalid_json | yes | 1002 | a text frame that is not a JSON object with a string type |
invalid_message | yes | 1002 / 1008 | a known type with missing or invalid fields (bad digit, bad start, too long id, start not first) |
unknown_message | no (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_version | yes | 1008 | start.version is not 1 |
unsupported_sample_rate | yes | 1008 | start.sample_rate is not a supported rate |
unexpected_start | yes | 1008 | a second start |
audio_before_start | yes | 1008 | a binary frame before start |
start_timeout | yes | 1008 | no start in time |
bad_audio | yes | 1008 | a pcm16 frame with an odd byte count |
frame_too_large | yes | 1009 | a 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_errors | yes | 1008 | see unknown_message |
output_rate_mismatch | no | the engine produced audio at another rate than the call's; dropped, reported once per call | |
mark_id_too_long | no | the engine asked for a mark with an id over 256 bytes; the mark is not sent | |
server_busy | yes | 1013 | the engine is not taking new calls |
overloaded | yes | 1013 | the engine takes no input from the call for send_timeout |
ping_timeout | yes | 1008 | see 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://orwss://; TLS with the server's built-in webpki roots, plus the private CA ofBRISVOX_AGENT_TLS_CAif set). WithBRISVOX_AGENT_TOKENset, the handshake carriesAuthorization: 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 asfe80::1%25eth0counts only on a link-local address, so a public address with a zone is still public),localhostor 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 asagent). A single-label name is accepted but logged once as a warning: the resolver's search list can turnagentintoagent.example.com, so prefer the full.internal/.localname. The rule reads the URL text only; it does not resolve names. The server logs and reports the agent URL asscheme://host:portonly (no userinfo, path or query). Any other host withws://fails the configuration at startup; usewss://, or setBRISVOX_AGENT_ALLOW_INSECURE=1([agent_link] allow_insecure = true, orallow_insecure = trueon 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 everywss://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
typefield. Binary frames are not part of the protocol (answered with anerror). 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
typefrom the agent is answered withunknown_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
- The server connects (bound:
connect_timeout, 2 s) and sendssession.start, offering the versions it speaks inversions([1]in this build). - The agent answers within
handshake_timeout(2 s, counted from the connection: thesession.startwrite is part of it) with eithersession.acceptwith theversionit picked fromversions, its tools, and optionally a system prompt, an initial context, a greeting and an event subscription; orsession.reject(code,message): the agent does not take this call. An agent that speaks none of the offered versions rejects with codeunsupported_version.
- A
versionnot inversions, 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:
| field | type | meaning |
|---|---|---|
version | integer | The version picked. Required. |
tools | array of tool declarations | Remote tools (below). At most 64. |
system_prompt | string | Replaces the agent definition's prompt (at most 64 KiB). |
context | array of {role, text} | Messages the conversation starts with (at most 32; roles system, user, assistant). |
greeting | string | A line the bot speaks at once (a bot line, below). |
resume | boolean | Optional 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. |
events | array of event types | Only 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
type | fields | meaning |
|---|---|---|
turn.user_started | epoch | A user turn started (speech; also a confirmed barge-in). |
turn.user_ended | epoch | The user turn ended; the engine answers it now. |
user.transcript | text, final | A transcript segment from the STT (interim when final is false). |
user.text | epoch, text | The whole text of the user turn the LLM answers (spoken finals and typed text). |
bot.llm_started | epoch | The LLM started a response round (a tool loop has several). |
bot.text | epoch, text | A streamed LLM delta. A bot line arrives as one delta. |
bot.llm_stopped | epoch | The round ended. |
bot.tts_text | epoch, text | A chunk sent to the TTS. |
bot.tts_stopped | epoch | The TTS produced the response's last audio. |
turn.bot_started | epoch | The bot's audio of epoch started playing. |
turn.bot_ended | epoch | The bot's audio of epoch was played to its end. |
turn.interrupted | epoch | A barge-in: the call moved to epoch; everything older was dropped. |
turn.metrics | epoch, 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. |
error | stage, message, fatal | A 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.lost | count | Events the agent did not get (see "Backpressure"). |
session.end | reason (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
type | fields | meaning |
|---|---|---|
tool.call | id (integer), call_id, name, arguments (object, valid against the declared schema), kind, epoch, idempotency_key (16 hex digits), timeout_ms | Run the tool and answer with tool.result echoing id. |
tool.cancel | id, reason (interrupted, timeout, abandoned) | The server stopped waiting: a later tool.result is ignored. Advice only for a side_effect tool. |
command.result | id, 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).
type | fields | meaning |
|---|---|---|
say | text (at most 4 KiB), interrupt (default false) | A bot line: the bot speaks text verbatim, without an LLM request. |
end | say (optional farewell), reason | End 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. |
transfer | destination (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.update | system_prompt, append (at most 32 {role, text}) | Edit the conversation (all of the command or nothing). |
tools.set | enabled: array of tool names, or null | Offer only these tools (local and remote) to the LLM; null: all again. |
tool.result | id (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, onebot.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. Withinterrupt: truethe 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.setthen narrows the node's tools. A tool call to a tool that is not offered is answeredunknown_toolwithout running. end: the server stops the call gracefully (as at a drain), then sendssession.endwith reasonended_by_agent.
Remote tools
A tool declaration (session.accept.tools[]):
| field | type | meaning |
|---|---|---|
name | string | 1 to 64 of A-Z a-z 0-9 _ -; must not collide with a tool of the agent definition. |
description | string | For the LLM (at most 4 KiB kept). |
parameters | JSON Schema | "type": "object"; compiled at the handshake (no remote $ref). |
kind | read_only / side_effect | TOOLS.md items 7 and 8. |
timeout_ms | integer | The engine's wait for the result: default 5000, at most 30 000. |
filler_after_ms | integer | A filler phrase after this long without a result: default 700; 0 disables. |
confirm | boolean | side_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. |
repeatable | boolean | side_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 attimeout_msor when the caller barges in; the link then sendstool.cancel(abandonedorinterrupted) and ignores a late result. The LLM gets atimeout/interruptederror result. May run during a speculative response.side_effect: never speculative, never run twice for one call id (idempotency_keyis 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"). Attimeout_msthe engine answers the LLM with atimeouterror but the call keeps waiting: the link waits up toside_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 sendtool.call. It records a pending confirmation and gives the model aconfirmation_requiredresult; the tool is offered to the agent as atool.callonly when the model calls the built-inconfirm_pendingtool (added to the model's tool list automatically) after the user's next reply. The agent sees onetool.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 indocs/TOOLS.md.
Ordering
- The server writes messages in one task:
seqorder 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 forendandtransfer, which run on a separate task (one at a time) and answer when done. - A
tool.resultmay come at any time, in any order.
Backpressure and bounds
| queue | bound | when full |
|---|---|---|
| events to the agent | 256 | the newest event is dropped and counted; the next event that fits is preceded by events.lost with the count |
| engine event ring | 1024 (shared with the RTVI bridge) | the oldest are lost, reported the same way |
| control messages to the agent | 64 | a tool call fails at once; a command.result is dropped (counted) |
| bot lines into the call | 16 queued + 8 held by the turn driver | queue_full |
| conversation edits into the call | 64 | queue_full (nothing of the command is applied) |
end / transfer | 4 | queue_full |
| remote calls waiting | 32 | tool_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
| code | meaning |
|---|---|
bad_message | Not JSON, a missing field, a field of the wrong type. |
unknown_type | A type this version does not know. |
invalid_argument | A value breaks a rule (an empty or too long text, a bad phone number). |
too_large | A count bound was hit (messages of one context.update, tool names). |
queue_full | The call's input queue is full: retry later. |
unsupported | The call cannot do this (no call-control for transfer). |
call_ended | The call is over. |
not_ready | A handshake message after the handshake. |
rejected | The carrier refused the transfer; nothing changed. |
unavailable | The carrier could not be reached; nothing changed. |
outcome_unknown | The transfer may or may not have started (a timeout): do not repeat it blindly. |
unsupported_version | For session.reject: the agent speaks none of the offered versions. |
unknown_session | For 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:
- 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. - 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. - It sends
session.startagain, carryingresume:
{"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:
- The route the call's start message names: the metadata key
agent_routeof a generic websocketstartmessage, 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). - The definition's
[agent_link] url. - 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"}}
| field | type | meaning |
|---|---|---|
carrier | string | twilio, telnyx or sip. |
carrier_call_id | string | The 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. |
from | string | The caller's number. Absent when the carrier did not say, and when the numbers are withheld (below). |
to | string | The called number, with the same absence rules. |
pii_redacted | boolean | Present 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
startmessage carries theCallSidbut no numbers.fromandtoare the stream parameters namedfromandto(FromandToalso 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'sFromandTo(docs/TELEPHONY.md). Without those parameters onlycarrier_call_idis sent. - Telnyx. The stream's
startmessage carriesfrom,toand thecall_control_id. - SIP. The user parts of the INVITE's
FromandToheaders (+15551230001in"Ann" <sip:+15551230001@trunk.example>; atel:URI works too; a URI without a user part gives no number) and theCall-ID. On an outbound callfromis the number the call is placed from andtothe 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 of0-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 putfromortounquoted into an LLM prompt (a user part such asannis a name the caller chose, not a fact), and should treat them as an untrusted lookup key. - No carrier id, no identity.
carrier_call_idis always the carrier's own id. When the carrier's start message did not give one (a TwiliostartwithoutCallSid, say),callis absent; the server does not substitute its own stream or session id. - The generic websocket, RTVI and WebRTC have no carrier:
callis 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 anytools.setof 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 declaredresume). - An
endreceived before the failure still completes: the farewell is spoken and the call ends when it was played (the agent may close its socket right afterend). - The call ends (hang-up, drain, shutdown): the server sends
session.endand closes the link (bounded byclose_timeout, 1 s).
Server configuration
| variable | default | meaning |
|---|---|---|
BRISVOX_AGENT_URL | unset (no agent) | The agent's ws:// / wss:// URL; every call links to it. |
BRISVOX_AGENT_TOKEN | unset | Sent as Authorization: Bearer <token>. Never logged. |
BRISVOX_AGENT_ALLOW_INSECURE | off | 1 accepts ws:// to a host outside the private networks (see "Transport and connection"). Logs a warning. |
BRISVOX_AGENT_CALLER_IDENTITY | off | 1 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_CA | unset | PEM bundle of a private CA, trusted besides the webpki roots for wss:// agent URLs. |
BRISVOX_AGENT_CONNECT_TIMEOUT_MS | 2000 | Bound on the connection and WebSocket handshake. |
BRISVOX_AGENT_HANDSHAKE_TIMEOUT_MS | 2000 | Bound on the agent's session.accept. |
BRISVOX_AGENT_RECONNECT_ATTEMPTS | 5 | Reconnection attempts per series for an agent that declared resume (0: never reconnect). |
BRISVOX_AGENT_RECONNECT_WINDOW_MS | 15000 | No 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.lostsays how much); an agent that needs the conversation after a restart reads it from its own store or asks for it withcontext.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. transferneeds a call-control binding on the call (TelephonyOps, such asbrisvox_transport::call_control::BoundCall): the server binary passes the call's own binding (Twilio, Telnyx; build withcall-control) and reports it incapabilities.transfer.- The agent cannot cancel a running LLM response without speaking (an interrupting
sayalways 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 ofdocs/WEBSOCKET_PROTOCOL.md, not RTVI. A Pipecat client SDK keeps usingBRISVOX_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
recorderSampleRatedefault), 24 kHz out (itsplayerSampleRatedefault). The JS client ignores thesample_rateof bot audio and plays at its own rate, soRtviConfig::output_ratemust equal the client'splayerSampleRate. The engine reads both rates fromCallInfo::sample_rate()(input_sample_rate/output_sample_ratemetadata). Transport::acceptalso works and gives only theCallLink(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 areRtviConfig::sessions(short-lived single-use session tokens,BRISVOX_SESSION_SECRET, the production way),RtviConfig::token(a shared static token, development) andallowed_origins. The server binary refuses to bind a non-loopback RTVI address with neither a session secret nor a token (BRISVOX_INSECURE_LISTENERS=1overrides;docs/SECURITY.md). - The token travels in
?token=(what the Pipecat client SDKs append; it lands in proxy logs and browser history), inAuthorization: Bearer <token>, or in the WebSocket subprotocolbrisvox-token.<token>: a browser page offers["brisvox", "brisvox-token." + token]and the server selectsbrisvox. 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 carryCache-Control: no-store. A session token's claims reach the call's metadata (session_id,session.agent,session.meta.<key>);max_sis 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) anderror, all on one queue. The mapping table is in the module docs (crates/brisvox-engine/src/rtvi.rs); the events come frombrisvox_engine::events. Everyuser-started-speakinggets itsuser-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-textbecomes 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: falseholds it for the next turn (Pipecat appends it to the context at once instead). A typed turn sends nouser-started/stopped-speaking(Pipecat neither), onlybot-interruptedif the bot was busy and theuser-llm-text.client-messagegoes to theClientMessageHandler(server-responsewith its answer,error-responseon its error, its timeout or without a handler); up to 8 replies run at once beside the inbox, so a slow handler never delays asend-textbarge-in.- With
RtviBridgeConfig::bot_speaking_eventson (the default),attachswitches the adapter's own speaking events off for the call (RtviSender::set_auto_events(false)), so the client never gets them twice whateverRtviConfig::auto_eventssays. With it off the adapter's derivation stays as configured. RtviBridgeConfig::event_capacityis clamped toMAX_EVENT_CAPACITY(65 536) andtext_capacitytoMAX_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 inMessageFrame.data(field 4, field 1); audio isAudioRawFrame(field 2; audio 3, sample_rate 4, num_channels 5); the server never sends a text frame orInterruptionFrame(field 5); client-readywith the exactRTVIMessage.clientReady()shape is answered withbot-readyechoing theid, version2.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_bytesis 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
RtviEventhas the JSON of the doc, server events carry noidand payload-less events nodata; - barge-in:
bot-interruptedandbot-stopped-speakingare sent, no audio of the old epoch (a distinct fill byte) reaches the client afterbot-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 KiBclient-messageagainst a 1 KiB limit, so the websocket layer lets it through) closes the call with 1009, before and afterclient-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 (
1003before the handshake, a fault after), bad JSON, a missingid, a foreignlabeland unsupported types are answered as Pipecat answers them; client-message/send-text/llm-function-call-resultreach the inbox,dtmfbecomes DTMF events,disconnect-bota hang-up; token, origin, path, call limit, timeouts and shutdown (1001 also for a browser that has not sentclient-readyyet);- the bounds: a client that stops reading ends the call after
write_timeout,RtviSender::sendtimes out on a full queue, a full inbox answers aclient-messagewith "server busy" (and dropssend-textsilently), 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 beforeclient-readycloses 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.
- Start the server:
cargo run -p brisvox-transport --example rtvi_server -- 127.0.0.1:8765 /rtvi(it logsrtvi call started/rtvi call ended). - In an empty directory:
npm init -y && npm i @pipecat-ai/client-js @pipecat-ai/websocket-transport vite, put the TypeScript above inmain.tswithwsUrl: "ws://127.0.0.1:8765/rtvi",recorderSampleRate: 16000,playerSampleRate: 24000, runnpx viteand openhttp://localhost:5173(a microphone needs a secure context;localhostcounts). Open the browser console. - Expected, in this order:
connect()resolves andonBotReadyfires (thebot-readyanswer carries version2.1.0); noUnrecognized connection parameterand 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
onBotStartedSpeakingwhen it begins andonBotStoppedSpeakingabout when it ends, and logs abot-outputtext; await client.sendClientRequest("ping", { n: 1 })resolves with the same payload;client.sendText("hello")produces no error;client.disconnect()ends the call; the server logsrtvi call ended.
- If the beep is pitched wrong or the page plays chipmunk audio,
playerSampleRatedoes not matchRtviConfig::output_rate. If the server logs a mismatched-input-rate error,recorderSampleRatedoes not matchRtviConfig::input_rate. - Record the answers to the open points (each is UNVERIFIED in
docs/providers/rtvi.mduntil then), indocs/providers/rtvi.mdwith the date and package versions:- does the client send a
Sec-WebSocket-Protocolheader (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 sendsbot-interrupted; - the default timeout of
sendClientRequest(call it with aclient-messagetype 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.
- does the client send a
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
RtviSenderare ordered among themselves only. They are not ordered against bot audio or the auto-derivedbot-started-speaking/bot-stopped-speaking(separate queues, RTVI messages first), so an event can overtake the audio queued before it. Turnauto_eventsoff and send the speaking events yourself if the UI needs a strict order (the engine'sRtviBridgedoes exactly that). -
With the engine bridge:
send-textwithaudio_response: falseis answered with audio anyway (counted astext_only_requests), and thellm-function-call-*events are not sent (the engine runs its tools itself; a client'sllm-function-call-resultis counted as unsupported). -
Input rate: client audio at a rate other than
input_rateis passed on at its own rate (no resampling); the first such frame is reported once as an engine error. Set the client'srecorderSampleRateto the configured input rate. -
TransportEventhas no variant forclient-message,send-textandllm-function-call-result(adding one would break every exhaustive match in the engine), so they travel onRtviInbox. 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-batchJSON messages (Python serializer carrier, unused by the JS client) are answered withUnsupported 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
- v1: Deepgram (STT), OpenAI (LLM), Cartesia (TTS)
- Next: Anthropic, Gemini, OpenAI-compatible (Groq/Together/Fireworks/vLLM/Ollama), ElevenLabs
- 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 injectedClock.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::Errorasconnect_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.
localhostmock servers keep working (::1is refused at once,127.0.0.1wins). - HTTP/SSE adapters use reqwest 0.13.5, whose hyper-util 0.1.21
HttpConnectorraces the two address families by default (happy_eyeballs_timeout300 ms); the shared client sets nolocal_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/speakhas no timestamps: the adapter never emitsTtsEvent::Word; played-text tracking falls back to its char-rate estimate (barge-in truncation is coarse). The contract suite is told so byTtsHarness::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 atconnect,DeepgramAuraConfig::language = "tr"atnew(Error::Protocol). Use Cartesia for Turkish. - Cancel is local first.
Clearis the only cancel andClearedcomes after the remaining audio:Cancelledis emitted at once, audio andFlushedare dropped untilCleared, and the next context is held until then (a lostClearedis given up on afterio_timeout). - Flush once per turn (
FlushPolicy::PerTurn, default; Deepgram allows 20 per 60 s and warns about quality).PerTextflushes 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 nextSpeak(the 2000-character limit may be per flush; 1009 would end the call). UnderPerTurnthe silence of a server that holds unflushed text is bounded by a livenessPing(half anio_timeoutafter the last sign of life; aPongrestarts the deadline). Whether Aura generates without aFlush(whatPerTurnbets on) is open item 3: the live test prints it; if it does not, switch the default toPerText. - Clear only when the server may hold text (written on this connection, unflushed or with a
Flushedowed): a cancel during a reconnect, or of a fully answered context, sends none. - Text is sent as
Speakpieces of at most 1000 characters (sentence, then space boundaries, spaces kept). No keepalive message (none documented); idle closes reconnect lazily (also with a flushedPerTextcontext 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_targetcovers the first call). Live check:live_deepgram_aura_sessionintests/deepgram_aura.rs(BRISVOX_LIVE_TESTS=1+DEEPGRAM_API_KEY, optionalDEEPGRAM_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> }; inputsAudio(Bytes)/Finalize; eventsTranscript { text, is_final, speech_final, from_finalize }/Error. An utterance can have several final segments;speech_finalmarks its end (provider endpoint or the end of a requested finalize). - LLM:
stream(LlmRequest) -> LlmStream; eventsTextDelta,ToolCallDelta { index, id?, name?, arguments }(fragments of different calls may interleave),Done { finish, usage },Error. A stream that ends withoutDone/Errorwas cut off. Errors before streaming (e.g.Rejected { status }) come fromstream()itself. - TTS:
connect(TtsConfig) -> TtsSession { input, cancel, events }. Utterances are identified by an engine-chosenContextIdechoed on every event (Audio,Word { start }relative to the context's first sample,Flushed,Cancelled), so stale output after a cancel is detectable.cancelis 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_urlishttps://openrouter.ai/api/v1(the adapter appends/chat/completions); plainhttp://is accepted only for loopback, a hosted service is alwayshttps://.modelis 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_envnames the variable that holds the key (export OPENROUTER_API_KEY=...); the key is never read from the agent file.brisvox-server check <file> --require-secretsreports 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"]withopenrouter_allow_fallbacks = falsepins the upstream. These two keys are OpenRouter-only: withoutpreset = "openrouter"or abase_urlon openrouter.ai the agent file is rejected (brisvox-server checkreports 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 asopenaidoes not select theopenai/fastoropenai/flexservice-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, fieldsupstream_providerandupstream_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 isbench/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,modelandapi_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 "anAuthorizationheader" without the prefix.- Short-lived JWT alternative:
Authorization: Bearer <JWT>;/listenWebSocket accepts temporary tokens, valid only during the initial connection; default TTL 30 s, maxttl_seconds3600 (token-based-authentication page). - Browser
sec-websocket-protocolsubprotocol 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) anddg-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, AUapi.au.deepgram.com, Indiaapi.in.deepgram.com. Patternwss://api.{region}.deepgram.com/v1/listen. All three regions support/v1/listenand/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/listenwill 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:
| Param | Values / default |
|---|---|
model | nova-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" |
language | default en; Turkish = tr (Nova-3 also lists tr-TR) |
encoding | linear16, 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_rate | integer Hz, required together with encoding for raw audio |
channels | default 1 |
interim_results | true/false, default false |
endpointing | ms integer, or false to disable; reference default 10 |
utterance_end_ms | 1000..5000 ms (min 1000 on cloud); requires interim_results=true |
vad_events | true/false, default false (enables SpeechStarted) |
smart_format | true/false, default false |
punctuate | true/false, default false |
keyterm | Nova-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, dictation | not needed for brisvox v1 (names not individually re-verified) |
version | default latest |
mip_opt_out | model 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"}
Finalizewith optionalchannel(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
Finalizechannel 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}}.keytermsreplaces the whole list (including keyterms set via thekeytermquery param); empty array clears; omitted/null keeps the current list. Other models return anErrorwith codeKeytermsNotSupported. On the EU/AU/India regional endpoints, mid-streamkeytermsinConfigureis not available yet (returns anError, stream keeps its keyterms; regional-endpoints page) — this is region-specific, not a contradiction; set keyterms via thekeytermquery parameter there (and simplest everywhere).
Server -> client messages (all JSON text frames, discriminated by type)
Results: required fieldstype,channel_index([idx, total]),duration,start,channel,metadata; optionalis_final,speech_final,from_finalize,entities.channel.alternatives[](transcript,confidence,languages[],words[]withword,start,end,confidence,language,punctuated_word,speakeronly with diarization),metadata(request_id,model_uuid,model_info{name,version,arch},diarize_infoonly when a diarizer ran),entities[](label,value,raw_value,confidence,start_word,end_word) per the reference. Docs example (interim; example omitstype):
{"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(needsinterim_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(needsvad_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 afterCloseStream.Error(non-fatal; fatal failures close the socket):type,variant(required;SchemaError= unparsable client text message,messageholds the original;InvalidConfigureMessage= rejected Configure),description(required), optionalcode(e.g.KeytermsNotSupported), optionalmessage.
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 severalis_final=trueresults.speech_final=true: endpointing detected a pause (utterance end). Docs: "Do not usespeech_final: truealone to capture full transcripts." Algorithm per docs: append everyis_final:truetranscript to a buffer; whenspeech_final:truearrives the buffer is the full utterance; clear it.- Docs' own endpointing example shows
is_final:falsetogether withspeech_final:true; the page does not explain it. Treatspeech_finalwithis_final=falseas 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/previousis_finalflush the buffer). from_finalize=truemarks results produced in response toFinalize.
Keepalive / timeouts
- Send
{"type":"KeepAlive"}every 3-5 s when no audio is being sent; no server response. - Closes with
NET-0001if 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". ForNET-0002(no audio within the no-audio timeout window, close reasonno_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 afrom_finalizeresult (use a timeout; the brisvox adapter should synthesise the end of utterance itself, e.g. after the last final received or a short deadline). Whetherspeech_finalis set on the from_finalize result: UNVERIFIED (the finalize page does not mentionspeech_final; the brisvox trait says the last from_finalize transcript isspeech_final, so the adapter must set that itself).CloseStream: server processes all remaining audio, returns final results, sends aMetadatasummary, then terminates the WebSocket. Docs say to always send it when done. Behaviour if sent before any audio: the page's example Metadata showsduration0.0 /channels0 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/durationon Results and per-wordstart/endare 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 settingspeech_final;endpointing=falsedisables 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).UtteranceEndfires when a gap ofutterance_end_msfollows 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) andFinalize; 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; checkencoding/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 reasonno_audio_timeout). - HTTP errors at handshake (errors page): 400 (
INVALID_QUERY_PARAMETER,Bad Request, ...), 401INVALID_AUTH, 402ASR_PAYMENT_REQUIRED, 403INSUFFICIENT_PERMISSIONS(project lacks model access), 429TOO_MANY_REQUESTS, 503Service Unavailable. JSON body fields:err_code,err_msg,request_id(the TTS 503 example useserror_code: parse both). The 400 Invalid JSON example usescategory,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-2tr; Basetr. Enhanced and Nova (1): not listed. Flux: not listed. - Nova-3
language=multicovers only English, Spanish, French, German, Hindi, Russian, Portuguese, Japanese, Italian, Dutch — NOT Turkish. For Turkish passlanguage=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(defaultlatest).
Flux (turn-taking STT) — does NOT change the Turkish setup
- Models
flux-general-en(English) andflux-general-multi(10 languages: en, es, fr, de, hi, ru, pt, ja, it, nl). Turkish is NOT supported by Flux (language_hint=trwould give 400INVALID_PARAMETER). Plainmodel=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 enableEagerEndOfTurnandTurnResumed),eot_timeout_ms(500-60000, default 5000),language_hint(repeatable, onlyflux-general-multi; other models return 400),encoding/sample_ratefor 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-wordword/confidence/times, languages; eventsEndOfTurn,EagerEndOfTurn,TurnResumed; the quickstart does not mention anUpdateevent, but the latency page says to count Flux interim results via"type": "Update"— the two pages disagree/are unclear: UNVERIFIED); control:Configure(updateseot_threshold,eager_eot_threshold,eot_timeout_msmid-stream; per language-prompting page alsolanguage_hintsplural,[]clears),ForceEndTurn(payload undocumented). Exact JSON/field names (e.g.event,turn_index): UNVERIFIED. EagerEndOfTurncan 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
Authorizationheader only; use theTokenprefix for API keys. - Control messages must be text frames; audio binary frames. Never log the key or the
Authorizationheader. - 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_finalcan arrive on a result whoseis_finalis false per the endpointing example (unexplained).- UTF-8 Turkish text arrives in
transcript;punctuated_wordholds 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
sec-websocket-protocolbrowser auth. 2. Whetherspeech_finalis set onfrom_finalizeresults and whatFinalizedoes with little audio (docs: response not guaranteed). 3. Exact semantics ofis_final:false+speech_final:true. 4. Max KeepAlive-only pause duration after audio started, and NET-0002 timeout duration.- Normal close code after
CloseStream. 6. mulaw/alawsample_rateguidance (8000 assumed). 7. Flux exact JSON field names and whether anUpdateevent 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. Plainmodel=fluxvalidity. 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:
- Deepgram's
from_finalizeresult (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 withinfinalize_settle_gapof 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.) - The adapter, after
finalize_timeoutcounted 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. - The adapter, at once and without sending anything, when the Finalize finds the recognizer
idle: no open finals, no interim text, and the latest
Resultsmessage of the connection ends withinfinalize_settle_gapof 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 oneResultsmessage 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_finalonfrom_finalizeresults: not relied on (set by the adapter).finalize_settle_gap(500 ms): chosen as roughlyendpointing(300 ms) plus recognition latency; how far an endpointed final'sstart + durationlags 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-0000close, "no Text frame to the client within the timeout window", suggests so). If it does not (e.g.interim_results=falseand a very quiet caller), a quiet stretch longer thanio_timeoutcosts a reconnect with replay (and a second one in a row ends the session withCore(Timeout)); raiseio_timeoutthen. - The KeepAlive-only pause limit and the
NET-0002window: 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_rateunlesssample_rate_hzoverrides it.
Gotchas found during implementation
tokio_tungstenite::Connectoris a different type fromtungstenite::Connector;connect_async_tls_with_configwants the tokio one.- The tungstenite defaults buffer writes (128 KiB) and the read buffer is 128 KiB: the adapter sets
write_buffer_size(0)(eachsendflushes),read_buffer_size(16 KiB),max_message_size(1 MiB)andTCP_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 inconnection::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
Resultsexample in the docs has notype; do not require it. Finalizewithout 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 everyspeech_finalas 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
1011right after connect (NET-0001: no audio) must not make the adapter loop: the reconnect attempt counter is only reset by aResultsmessage (aMetadataor 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 intests/deepgram.rsruns withmax_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 answerFinalize, and withspeech_final?). It setsfinalize_timeoutto 8 s andfinalize_settle_gapto zero, so the adapter's own fallbacks cannot answer, and requires thefrom_finalize+speech_finalevent 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_finalsegment 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". Finalizesent at a VAD stop is answered withis_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. WithoutFinalize,speech_finalcame ~600 ms after the stop (endpointing 300 ms plus the recognizer). An answer toFinalizeon 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_partialspeculation 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
llmGatewayResponsemessage 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
- 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
Auth
Authorization: <ASSEMBLYAI_API_KEY>header on the WebSocket upgrade, with NOBearerprefix (reference, overview and quickstart all say so). Never put the permanent key in a URL.- Alternative for browsers:
token=<temporary token>query parameter. Token endpointhttps://streaming.assemblyai.com/v3/tokenwith the API key inAuthorization; query paramsexpires_in_seconds(required, 1-600) andmax_session_duration_seconds(optional, 60-10800, default 10800); the JSON response carries atokenfield. 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-Versionpins the API version (default: latest). Value format: UNVERIFIED (theBeginexample shows"api_version": "2025-05-12"). - Failed auth closes the socket with 1008 (see Close codes).
Endpoint(s) / regions
| Endpoint | URL | Processing |
|---|---|---|
| Edge routing (default) | wss://streaming.assemblyai.com/v3/ws | nearest region, any US or EU location |
| US data zone | wss://streaming.us.assemblyai.com/v3/ws | US regions only (us-east-1, us-east-2, us-west-1, us-west-2) |
| EU data zone | wss://streaming.eu.assemblyai.com/v3/ws | EU 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 ID | Languages | Notes |
|---|---|---|
universal-3-6-pro | 32 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-pro | 18 (models page and FAQ) or 19 (select-model page), incl. Turkish per models page + FAQ | older Pro; $0.45/hr (models page); mode, prompt, interruption_delay, previous_context_n_turns are 3.5-Pro-specific per the reference |
universal-streaming-english | English | format_turns, end_of_turn_confidence_threshold; $0.15/hr |
universal-streaming-multilingual | en, 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)
| Param | Values / default |
|---|---|
speech_model | see above; effective default universal-3-6-pro |
encoding | pcm_s16le (default), pcm_mulaw, opus, ogg_opus, aac |
sample_rate | integer 8000-96000, default 16000; ignored for opus, ogg_opus, aac |
language_codes | list 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_detection | bool, 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_turns | bool, default false; Universal Streaming only (3.6 Pro always formats) |
end_of_turn_confidence_threshold | float 0.0-1.0, default 0.4; Universal Streaming only (deprecated per migration guide). 1 = acoustic-only, 0 = silence-only (not recommended) |
min_turn_silence | ms, 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_silence | ms. 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 |
mode | min_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_threshold | float 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_delay | 0-1000 ms, Pro only, server adds a fixed 256 ms (reference) |
include_partial_turns | bool, default true (false when redact_pii on) |
keyterms_prompt | list, 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 |
prompt | string max 1750 chars (reference), context not instructions (Pro; reference says 3.5 Pro) |
agent_context | string max 1750 chars; the agent's last TTS reply as context (3.6/3.5 Pro) |
session_heartbeat | bool, default false; Heartbeat message every 5 s |
inactivity_timeout | integer 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.UpdateConfigurationis 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_codesare "U3 Pro" only). No acknowledgment message or error behaviour for invalid values is documented: UNVERIFIED.KeepAlive: resets theinactivity_timeouttimer; only needed wheninactivity_timeoutis 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 partialTurns (end_of_turn:false), one finalTurn(end_of_turn:true). EveryTurnre-transcribes the whole turn and supersedes the previous one of the sameturn_order: REPLACE, do not append.turn_orderincreases 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
Turnhasend_of_turn:true, is fully formatted (turn_is_formatted:true),end_of_turn_confidence1.0. Partials haveend_of_turn:false(3.5 Pro:turn_is_formattedalways equalsend_of_turn). Partials come early (timed byinterruption_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: atmin_turn_silencea terminal mark (. ? !) ends the turn;max_turn_silenceforces the end. - Universal Streaming: with
format_turns=trueyou get an UNFORMATTED final (end_of_turn:true,turn_is_formatted:false) followed by a FORMATTED final for the sameturn_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_formattedis true on all turns, partials included, per the migration guide (conflicts with the previous sentence for the no-format_turnscase): UNVERIFIED. A turn is complete only whenend_of_turn:trueANDturn_is_formatted:true(message-sequence page: "bothend_of_turnandturn_is_formattedaretrue", written for Universal Streaming +format_turns). Withformat_turns=falsethe final hasturn_is_formatted:falseand there is no second message (message-sequence page, confirmed). - brisvox mapping proposal (derived): partial ->
Transcript{is_final:false}; final turn -> oneTranscript{is_final:true, speech_final:true}(an AssemblyAI turn is the utterance, so each final turn ends the utterance); a final produced after aForceEndpointsetsfrom_finalize:true. Concerningfrom_finalize: there is no marker on the wire (no equivalent of Deepgram'sfrom_finalize), so the adapter must itself remember that a ForceEndpoint is outstanding and flag the nextend_of_turnTurn (UNVERIFIED which final answers it when a natural endpoint fires at the same moment).
Finalize / flush / cancel semantics
ForceEndpoint= brisvoxFinalize. Docs: "ends the current turn immediately, without waiting for silence"; "without waiting for silence or punctuation"; the server responds with theend_of_turn: truemessage(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 beforeTermination: the final formattedTurnfor audio already sent; on Universal Streaming a closingTurnwith emptytranscriptfor an unfinished turn. "Closing the socket early discards your last transcript" (message-sequence page). Keep reading untilTermination. 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 afterTerminatewith a timeout (likeclose_timeoutin 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 forseconds"; KeepAlivemessages prevent it. Default brisvox plan: leaveinactivity_timeoutunset, 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_atis a Unix time (presumably that limit: UNVERIFIED). Calls longer than 3 h need a planned reconnect beforeexpires_at. session_heartbeat=truegives a server message every 5 s of wall-clock time: useful as liveness for a client watchdog (the Deepgram adapter'sio_timeoutpattern), at the cost of a few bytes. Whether Heartbeats continue while no audio flows: UNVERIFIED.
Timestamps / interim results
- Word
start/endandSpeechStarted.timestampare milliseconds.SpeechStarted.timestampis "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_latencyis "Returns text as quickly as possible";balancedis 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)
| Code | Reason | Cause / 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: []) / "Session terminated due to inactivity: No messages received for | 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, newturn_ordernumbering (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-proalso lists Turkish (models page, language FAQ).universal-streaming-multilingual(en, es, de, fr, pt, it) anduniversal-streaming-englishdo 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
trbefore 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. Treatend_of_turn:trueas the utterance end on Pro; on Universal Streaming +format_turns=true, wait forturn_is_formatted:trueon the final (avoid double-processing). For Turkish brisvox uses Pro, where this does not apply. - Always read until
TerminationafterTerminate; closing earlier loses the last transcript. - Pro models: use
ForceEndpointfrom 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 describeend_of_turn_confidence_threshold=1(Universal Streaming); no equivalent for Pro is documented (raisingmax_turn_silenceis 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
Authorizationheader ortoken. - The reference's
llm_gatewayand speaker/redaction params are irrelevant for v1; unknown params are ignored silently, so typos fail silently (verifyBegin.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_modelexplicitly.
UNVERIFIED summary
- Default
speech_modelwhen omitted (reference + select-model page say U3.6 Pro; migration guide says Universal defaults to English). language_codesURL serialisation (JSON array string vs repeated key). 3. Effective U3.6 Pro defaults formin_turn_silence/max_turn_silence/vad_threshold(turn-detection page, migration guide and reference disagree).ForceEndpointbehaviour with no open turn / no buffered speech, and what marks its answer (nofrom_finalizeanalogue). 5. Whether a formatted second message follows a ForceEndpoint final on Universal Streaming.- Whether
modeworks on U3.6 Pro (reference: 3.5 Pro only, but its Begin example and other pages show it with 3.6). - Idle behaviour of a connection that never receives audio. 8. Whether
Heartbeatis sent while no audio flows. - 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_mulawat 8000 Hz (rate guidance absent). - Temporary-token HTTP method (POST vs GET). 13.
AssemblyAI-Versionvalue format. 14.Errormessage field names and JSON example. 15.UpdateConfigurationacknowledgement and error behaviour. 16. Turkish accuracy and latency numbers; Turkish count discrepancy between 3.5 Pro pages (18 vs 19). 17. Fine semantics ofTurn.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 Streamingturn_is_formattedwithoutformat_turns(pages conflict). 21. The ~1.25x throttle / 5-minute buffer claims (no official source found). 22. Whether 3.5 Pro sendsSpeechStarted. 23.UpdateConfigurationkeyterms_prompt: []clearing the list;agent_contextreplace semantics. 24. 3008 delivery timing and extra-time billing. 25.Begin.expires_atequals 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, noBearer); key is aSecret<String>.speech_modelis always sent (defaultuniversal-3-6-pro),Begin.configuration.modelis compared with it (warning on mismatch). - Connect waits for
Beginunder the sameio_timeoutas the handshake. A close frame instead ofBeginmaps 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 aconnecterror. - 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_paramsis the escape hatch),format_turns(only foruniversal-streaming*models),min_turn_silence,max_turn_silence,keyterms_prompt(JSON array string),session_heartbeat=true(default),inactivity_timeout(opt-in), plusextra_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-inbatch_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 perFinalize. 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); finalTurn(end_of_turn) -> oneTranscript{is_final:true, speech_final:true}.from_finalizeis set on the first final after aForceEndpoint(nothing on the wire marks it; UNVERIFIED 4). Withformat_turnson a Universal Streaming model the unformatted final is shown as an interim and the formatted twin is the final. Repeated finals of an endedturn_order, stale partials, and the empty closingTurnafterTerminateare dropped.SpeechStarted,Heartbeat,SpeakerRevision,LLMGatewayResponseand unknown types are ignored. - Finalize:
ForceEndpointafter 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 aForceEndpointreply 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 withoutfrom_finalize. A final that arrives while aFinalizewaits answers it (it may be a natural endpoint that raced theForceEndpoint). - Keepalive / liveness: no idle timeout exists unless
inactivity_timeoutis set; then{"type":"KeepAlive"}is sent afterkeepalive_intervalwithout audio. The response watchdog (io_timeoutof server silence after audio was given) relies onHeartbeatmessages (session_heartbeat=true, 5 s) because the server says nothing during silence; withheartbeatoff a WebSocketPingprobes afterio_timeoutand a secondio_timeoutwithout 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 forkeepalive_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_interval4 s <io_timeout10 s, scaled down): a half-open server is replaced atio_timeoutwhile 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 inconfig.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 finalTurn(word times assumed to be ms from connection start: UNVERIFIED 10) and to theForceEndpointposition when it is answered or given up on; bounded byreplay_window(8 s). Replay is paced atreplay_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 pendingFinalize'sForceEndpointposition), 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 andForceEndpointreach 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 withoutwords, UNVERIFIED 10/17) trims the ring to theForceEndpointposition it answered or to the audio sent so far, so a delivered turn is not replayed and delivered twice. A pendingForceEndpointis re-sent after the replay. 1008 and 401-403 are never retried. The attempt budget is restored by aTurnor aHeartbeat. - 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,
Terminateis sent and the socket drained untilTermination/close for at mostclose_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
Deserializefor 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
Errorframe order: theErrortext 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_sessionwas read withoffset=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): headerx-gladia-key: <GLADIA_API_KEY>andContent-Type: application/json(the init page showsx-gladia-keyas the auth header; thatContent-Typeis 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
urlcarries 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 sessionid; AsyncAPI: "session token passed as thetokenquery parameter"; nosecuritySchemesdefined). 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 addsvalidation_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 queryregion, valuesus-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
urliswss://api.gladia.io/v2/live?token=...in all doc examples. Whetherregionchanges the hostname of the returnedurl: UNVERIFIED (docs only show the global host). Default region whenregionis omitted: UNVERIFIED. - Result retrieval after the session:
GET https://api.gladia.io/v2/live/{id}(headerx-gladia-key; response hasstatusqueued/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_recordingor 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.
| Field | Type / default / values |
|---|---|
encoding | string, 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_depth | number, default 16; 8, 16, 24, 32 |
sample_rate | number, default 16000; 8000, 16000, 32000, 44100, 48000 |
channels | integer, default 1, min 1, max 8 (multi-channel billed as duration x channels) |
model | string, default and only value solaria-1 ("live transcription supports solaria-1 only") |
endpointing | number seconds, default 0.05, min 0.01, max 10 |
maximum_duration_without_endpointing | number 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_processing | custom_vocabulary (+custom_vocabulary_config), custom_spelling (+custom_spelling_config), translation (+translation_config), named_entity_recognition, sentiment_analysis (all default false) |
post_processing | summarization (+summarization_config.type: general default / bullet_points / concise); chapterization deprecated and ignored |
messages_config | which WebSocket messages to receive (below) |
callback, callback_config | callback 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_metadata | free-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 viareceive_speech_events;data.timeseconds from session start,data.channelint):
{"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 carryacknowledged(bool) anderror(object or null) and reuse the client message name astype: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":acknowledgedtrue if accepted, false if rejected;dataprovider 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(dataprovider-defined, examplereason: "user_request",received_total_bytes: 1048576; "provider has stopped receiving audio"),end_session("session is closed and no further data will be sent"; nodata). Whetherend_sessionis 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} +transcriptionand add-on results),post_summarization(data.resultsstring). 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_errorsflag 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." "Whenis_finalis 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 >=endpointingseconds, ormaximum_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 lowendpointing(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 Deepgramis_finalsegments.- "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_recordingends 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
endpointingseconds 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):- If an open utterance exists (a partial with no final yet), wait up to a
finalize_timeoutfor Gladia's own final (lowendpointingmakes this quick), then deliver it withfrom_finalize:true, speech_final:true. - 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 thatdata.idas a duplicate. - If no utterance is open (final already delivered), answer at once with an empty/synthetic
from_finalizefinal 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).
- If an open utterance exists (a partial with no final yet), wait up to a
- Whether
stop_recordingflushes a pending (unfinalised) utterance into a lastis_final:truetranscript 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_recordingor 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/endandwords[].start/endare seconds from the session start (spec: "seconds from session start"). Whether they continue monotonically across a resumed socket: UNVERIFIED.speech_start/speech_enddata.timeare in the same time base ("Time in seconds from session start").acktime_rangegives the audio time covered by each accepted chunk (debugging aid only).- Latency guidance from the docs: lower
endpointingsuits voice agents; "Voice Agents" row of the recommended parameters page:endpointing"0.05 - 0.1",maximum_duration_without_endpointing15, partials on, custom vocabulary on; "If utterances get cut off mid-sentence, raiseendpointingslightly." 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 bysolaria-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 onlysolaria-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:vocabularystrings 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 asRejected{429}with a bounded retry in the caller. - The
urlcontains the session token: redact in logs/errors (Protocolerrors must not contain credentials). - Interims are OFF by default (
receive_partial_transcriptsfalse). - Ack messages reuse
typevaluesaudio_chunkandstop_recording; disambiguate by presence ofacknowledged(and absence ofdata.chunk), or switch acks off. Unknowntypes 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 rateappears in the spec as anInvalidArgumentexample). - 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}withx-gladia-key; 202 = deleted, 401, 403 (job not in a deletable state), 404. The exact list route path: UNVERIFIED (not fetched).
UNVERIFIED summary
- Session token lifetime / delay allowed between init and socket open. 2. Whether
regionchanges the returnedurlhost; default region. 3. Exact shape of partial transcripts (no example anywhere), default of a missingis_final, empty-text messages. 4. Whetherstop_recordingflushes the pending utterance as a final transcript; whetherend_sessionappears on the socket. 5. Standalone error message type on the socket and the close codes4408/4504(support article only, official reference defines none), plus 4504 behaviour with silent audio. - Timestamp continuity and what is replayed after a reconnect-and-resume; retry/backoff values. 7.
wav/ulawat 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 ofaudio_enhancer, acks off andspeech_thresholdon latency. 11. Max chunk size. 12.code_switchingignored with exactly one language;Content-Typemarked required; "prepaid wallet" on the pricing page; list-live route path;post_transcriptsub-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 returnedurl; both together are bounded byio_timeouton the injected clock (a stalled init or a stalled upgrade ends inCore(Timeout)at the deadline). The returned URL is a secret (Secret<String>, dropped after the handshake, never formatted into errors or logs); it must bewss://(ws://only on loopback hosts, for the mock). The initidequals the token in the quickstart example, so it is not logged either. Init errors log only the status and Gladia'srequest_id; the error body is not logged (it may echo fields). - Orphaned sessions are deleted (review fix).
POST /v2/livecreates a session (one concurrency slot: 1 free / 30 paid) before the WebSocket exists. If the upgrade then fails (refused, stalled pastio_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'sid(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 detachedDELETE /v2/live/{id}withx-gladia-key, bounded byclose_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 throughstop_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_hzis 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 theSttConfigrate of the audio the caller sends.GladiaConfig::sample_rate_hz = Some(hz)is accepted only whenhzequals that rate (and Gladia supports it); any other value failsconnect()with a configProtocolerror 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, orspeech_startwith no final since; a final clears that, because the order ofspeech_endand 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 tofinalize_timeout(default 800 ms, counted from the request, also across a reconnect) for Gladia's own final of that utterance and deliver it asfrom_finalize+speech_final; (2) on timeout deliver the last partial (or empty text) as a syntheticfrom_finalizefinal 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: emptyfrom_finalizefinal 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) waitsfinalize_timeoutonce; 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 putfinalize_timeouton 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 aspeech_finalthat answers no Finalize (stt stage docs). - Every Finalize is answered exactly once (review fix). The engine's STT stage keeps one ledger entry per
Finalizeit sends and pops one perfrom_finalize+speech_finalresult; an unanswered entry makes it declare the session dead after its own 3 s timeout. AFinalizethat 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 thefinalize_timeoutdeadline (counted from that first request); whatever answers it (the real final, a blank final, the timeout) is followed by one emptyfrom_finalize+speech_finalfinal per extra request. The count survives a reconnect and is capped at 32 (the engine's defaultfinalize_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_finalmissing 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 isspeech_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_acknowledgmentsdefault 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): afterkeepalive_intervalwithout any frame from the server the adapter sends a Ping and any frame withinio_timeoutproves 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 everykeepalive_interval + io_timeout, setliveness_ping: false. Withacknowledgments: trueevery audio chunk is acked, andio_timeoutwithout 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 mostio_timeout / 2so an idle caller stays alive. UNVERIFIED: whether Gladia acks binary frames the way it acks JSONaudio_chunkmessages; that is why acks are off by default. The first live run must check it (turn the flag on and watch forCore(Timeout)).acknowledged: falseon 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, anerrormessage 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 byreplay_window, 8 s) atreplay_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, elseDisconnected(orCore(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) getsstop_recordingand a close frame first, bounded byclose_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_recordingand waits at mostclose_timeoutfor the server to close; events close first and nothing after the hang-up is delivered. Whetherstop_recordingflushes a pending utterance is irrelevant here. - Errors on the socket. A
type: "error"message is accepted in both plausible shapes (errorordatacarryingstatus_code/exception): status >= 500 reconnects, anything else is fatalProtocol(UNVERIFIED shape; the free textmessageis never copied into errors). Only a frame that is not a JSON object is fatalProtocol. Fields are read leniently because the partial shape is UNVERIFIED:text: nullor a number reads as blank, a numericidis kept as its decimal text, a non-booleanis_finalis a partial, atypethat 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-lessHEADto 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 nox-gladia-key,AuthorizationorCookieheader, 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,CookieandWWW-Authenticateon a cross-host redirect, not a custom header such asx-gladia-key, and a proxy from the environment would see the header.redirecting_init_endpoint_is_not_followedanswers the init call with a307to a second listener and assertsRejected { status: 307 }and zero requests at the second listener;tests/gladia_proxy.rs(its own test binary, because it setsHTTP_PROXY/ALL_PROXYfor 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,endpointing0.1,maximum_duration_without_endpointing15,language_configonly when a language is known (tr-TRis sent astr), 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, optionalcustom_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),HEADandDELETE(both recorded) and upgradesGET; 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 offixtures/tr_two_utterances.wavin 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 todevelopers.openai.com/api/docs/..., re-confirmed forguides/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). OptionalOpenAI-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 usehttps://api.openai.com/v1/...; response headeropenai-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 showsv1/chat/completions; openai-pythonapi.mdlistspost /chat/completionsrelative 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).
- Responses:
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:
- OpenAI: "While Chat Completions remains supported, Responses is recommended for all new projects." (migrate-to-responses guide).
- 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_effortisnone". The migrate guide says the same from GPT-5.4 on: "Starting with GPT-5.4, Chat Completions does not support tool calling withreasoning_effortvalues other thannone." (Note: the Astra model page listsfunction_callingas a supported feature onv1/chat/completionswithout qualification, which conflicts with the latest-model guide; follow the guide and use Responses.) - 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).
- 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).
store: false+ replaying our own history matches the brisvox invariant "after an interruption the LLM context contains ONLY what was actually played": we buildinputourselves from the engine's played-text history. Do NOT useprevious_response_idorconversation(server-side history would contain the unplayed text of an interrupted answer). Noteinstructionsis not carried over when usingprevious_response_idanyway (SDK docstring forinstructions; the migrate guide says "previous_response_iddoes not carry over the previous response's top-levelinstructions" and advises resending stableinstructionson each request).- 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 ID | Role per docs | Price /1M in / cached in / out | Reasoning 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)
type | Fields | Use |
|---|---|---|
response.created, response.in_progress, response.queued | response (Response), sequence_number | ignore (record response.id for logs) |
response.output_item.added | item, output_index, sequence_number | start of an item (message / function_call / reasoning). For function_call, item carries id, call_id, name, arguments:"" |
response.content_part.added / .done | content part info | ignore |
response.output_text.delta | delta (text), item_id, output_index, content_index, logprobs, sequence_number | -> LlmEvent::TextDelta |
response.output_text.done | text (final text), item_id, output_index, content_index, logprobs, sequence_number (class ResponseTextDoneEvent) | ignore, we accumulate |
response.refusal.delta | delta, 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.delta | delta (arguments fragment), item_id, output_index, sequence_number | -> LlmEvent::ToolCallDelta |
response.function_call_arguments.done | arguments (full string), item_id, output_index, sequence_number (no name field) | validate JSON; the doc says it carries the full arguments |
response.output_item.done | completed item, output_index | for reasoning items, take encrypted_content from here, not from .added (SDK docstring: value in .added may be incomplete) |
response.completed | response (full Response incl. usage, output), sequence_number | terminal success |
response.incomplete | response, 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.failed | response (response.error has code (enum, e.g. server_error, rate_limit_exceeded, invalid_prompt, data_residency_mismatch, image errors), message, optional misalignment), sequence_number | terminal error |
error | code (opt), message, param (opt), sequence_number | -> LlmEvent::Error |
response.reasoning_summary_text.delta etc., response.reasoning_text.delta | reasoning text | ignore (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(NOTitem.id; tool results are keyed bycall_id),name=item.name, both emitted fromresponse.output_item.added(with emptyarguments) and followed by pure fragments. Parallel calls are sequential items in OpenAI's examples but interleaving must still be assumed possible (key byitem_id).Done { finish, usage }<- terminal event.FinishReason: Responses has nofinish_reason; deriveToolCallsif the output contains anyfunction_callitem,Lengthifresponse.incompletewithmax_output_tokens, elseStop.content_filterhas no brisvox variant (gap; map toStop+ log or extend the enum).Usage:input_tokens,input_tokens_details.cached_tokens,output_tokens(output_tokens_details.reasoning_tokensis included inoutput_tokens, UNVERIFIED but docs say reasoning tokens "are billed as output tokens").input_tokens_details.cache_write_tokensalso exists (required int in the SDK type; gpt-5.6+), plustotal_tokens.- Stream ending without a terminal event -> report as cut off (brisvox
LlmStreamcontract).
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: falseon every object, every property inrequired; optional = add"null"to the type (["string","null"]). Non-compliant schema withstrict: trueis rejected (that last point was not re-confirmed in the fact-check: UNVERIFIED). Ifstrictis 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". BrisvoxToolSpec.parameters_schemais arbitrary JSON Schema, so the adapter must either setstrict:falseor normalise: decision for the adapter (defaultstrict:falseunless 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", orallowed_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
FunctionDefinitiondocstring, not stated in the function-calling guide): a-z A-Z 0-9_-, max 64 chars. async: trueon 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 originalcall_id" (latest-model guide, listed under GPT-6 in general, not specifically Luna; not mentioned in the function-calling guide). The SDKResponseFunctionToolCallhas anasyncfield. 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 afunction_call_outputanswers nofunction_calland the API rejects the request. (The agent's ownMessage::Assistantalready hastool_calls; the simprovider_messagesmapping inbrisvox-sim/src/reference.rsdoes not copy them yet because that reference stage runs without tools: follow-up for the real LLM stage.)Message::provider_data: Option<Arc<str>>andLlmEvent::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'sreasoningandfunction_calloutput items exactly as received inresponse.output_item.done(theencrypted_contentof a reasoning item is only complete there). The agent'sDelta::ProviderData/Message::Assistant.provider_dataare the intended carriers (they holdserde_json::Value; the providers crate keeps text so it needs no JSON dependency outside this feature).- Constructors
Message::tool_result(call_id, content)andMessage::assistant(content, tool_calls, provider_data).
Request mapping
- Leading
Systemmessages ->instructions(joined by a blank line); a laterSystemmessage (a late note) -> an input item with roledeveloper. User->{"type":"message","role":"user","content":text}.Assistant-> the items ofprovider_datain the order the server produced them (only ifreplay_reasoning): eachreasoningitem 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 exactfunction_callitem (keepsid/status) for everytool_callsentry whosecall_idis in the data. So a responsers1, fc1, rs2, fc2is replayedrs1, fc1, rs2, fc2, neverrs1, rs2, fc1, fc2.Done'sprovider_datacarries 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 intool_calls) is dropped with it; atool_callsentry missing from the data is synthesised as{"type":"function_call", "call_id","name","arguments"}after the rest. Withreplay_reasoning = false, or without data, the turn is rebuilt from the engine's own record only: the text, then one synthesisedfunction_callper call, noid, nostatus(this is the fallback if the API rejects a replay, so it must not depend onfc_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 aProtocolerror names the call id when: a tool message has notool_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 opaqueRejected{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"}.strictistrueonly if the schema already complies (object root,additionalProperties:falseand every property inrequiredon every object, none ofoneOf/allOf/not/patternProperties/if/then/ else/dependentSchemas/dependentRequired/propertyNames/unevaluatedProperties/unevaluatedItems/ contains/minContains/maxContains), and every sub-schema (property,items,anyOfbranch,$defsentry) declares its shape withtype(a string or an array of strings),$reforanyOf: a typeless leaf such as{"enum":["a","b"]},{"description":"x"}or{}makes the toolstrict:false(the strict validator is believed to reject "schema must have a 'type' key", UNVERIFIED, so the conservative choice applies), elsefalse(a conservative list: whether the API rejectsstrict:truefor each of them is UNVERIFIED, and a wrongtruewould fail every request of the agent with HTTP 400, while a wrongfalseonly 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 aProtocolerror before any I/O.- Always sent:
model,stream:true,store:false,stream_options.include_obfuscation;include:["reasoning.encrypted_content"]whilereplay_reasoning.tool_choiceis not sent (auto):LlmRequesthas no field for it.
Response mapping
| Server event | Output |
|---|---|
response.output_text.delta, response.refusal.delta | TextDelta (a refusal is spoken like an answer) |
response.output_item.added with a function_call item | ToolCallDelta { 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.delta | ToolCallDelta { 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.completed | Done: 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.incomplete | Done: 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 error | Error(Disconnected) |
| invalid JSON, unknown call, event over the cap | Error(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_timeoutcovers connect, TLS, the request and the response headers (Core(Timeout)fromstream());first_output_timeoutruns 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 thatidle_timeoutis 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_durationcaps the whole response ("openai stream"). The HTTP client also getsconnect_timeout(its own timer, belt and braces).first_output_timeoutis longer than the idle gap on purpose, since a reasoning model may think before it speaks; raiseidle_timeoutas well for efforts abovenone, where gaps between events can be long (UNVERIFIED how long). - Dropping the
LlmStreamdrops the receiver; the reader task notices at once (it selects onSender::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 onSender::closed()next to the socket read); dropping thestream()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'sprovider_dataand 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 andprovider_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 isCore(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(): unauthenticatedHEAD {base}/v1/responseson 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 nextstream()skips DNS/TCP/TLS. The pool keeps an idle connection forpool_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 callwarm()again (it is idempotent and cheap). No key is sent and nothing is billed. Idempotent. The pool is perOpenAiLlm: 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 ALPNh2,http/1.1; no system proxy. The client settings are now tested over a TLS + h2 loopback server inbrisvox-providers(net/http/tls_tests.rs): ALPNh2, one TLS handshake forwarm()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 overh2(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 themsg_...item it originally preceded. include_obfuscation:falseandinclude:["reasoning.encrypted_content"]are accepted for the chosen model (both are in the SDK's create params).parallel_tool_callsis accepted whenever tools are sent; omitted when there are none.- Whether response headers arrive before the first event;
first_byte_timeoutis therefore generous (10 s) whileidle_timeoutis the voice-relevant bound. - Dense call indexes (instead of
output_index) andcall_idas the id: as recommended above. - An
errorevent carriescodeat the top level; a nestederror.codeand the SDK's untyped{"error":...}are accepted too. [DONE]may or may not follow the terminal event; both are tolerated.HEAD /v1/responseswithout credentials answers with some HTTP status (even 401/404/405).- The replayed history is accepted when a
function_callitem keeps its serverid/statusand is separated from its reasoning item only by the played text (the order the server produced); thereplay_reasoning = falsefallback (noid, 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 thedata: [DONE]message. Theusagefield on this chunk shows the token usage statistics for the entire request, and thechoicesfield will always be an empty array. All other chunks will also include ausagefield, 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(nulluntil the last content chunk),logprobs.delta:role,content(string|null),refusal,tool_calls[], deprecatedfunction_call,audio.delta.tool_calls[]fragment:index(required, identifies the call),id(first fragment),type: "function",function: {name, arguments}(argumentsis a JSON string fragment; may be invalid/hallucinated: validate).finish_reasonvalues:stop,length,tool_calls,content_filter, deprecatedfunction_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"}}(strictoptional bool;parametersomitted = empty parameter list). - Tool result (Chat): message
{"role":"tool","tool_call_id":"<id>","content":"<string>"}(contentmay also be an iterable of text parts); the preceding assistant message carriestool_calls: [{"id","type":"function","function":{"name","arguments"}}](andcontentmay be null/omitted whentool_callsis set: "Required unlesstool_callsorfunction_callis specified"). - Chat
delta.tool_callsmaps 1:1 ontoLlmEvent::ToolCallDelta(index,id,function.name,function.arguments), the existing brisvox design assumption;usagearrives in a separate last chunk with emptychoicesafterfinish_reason, beforedata: [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 }fromstream()): 400 (error-codes page: invalidservice_tier, typeinvalid_request_error,error.param:"service_tier"; context overflow withtruncation:"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-retryablecredit_balance_exhausted,organization_spend_limit_exceeded,project_spend_limit_exceeded,organization_usage_limit_exceeded(error.typemay beinsufficient_quota; checkerror.code): "Retrying billing, spend, or quota errors won't restore API access". 503server_is_overloaded(typeservice_unavailable_error): retry honouringRetry-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 readserroranderror.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 defaultmax_retries2; 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 variantsx-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-setX-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: dropLlmStream) is the plan. Billing of tokens generated before the drop: UNVERIFIED.response.status:"cancelled"exists (relevant tobackgroundmode).
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_tokensrounded 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+). Chatusage.prompt_tokens_details.cached_tokens(SDK chunk field list). Map toUsage.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 value30m(default): prefix stays eligible 30 min after last write/reuse. Older:prompt_cache_retentionin_memory("around 5 to 10 minutes of inactivity, up to one hour") or24h(around 30 minutes, up to 24 hours; the only option for gpt-5.5 and later per the SDK docstring; without ZDR the default is24h, with ZDRin_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):
- 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".) - 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.
- Use
tool_choice: "none"orallowed_toolsrather than removing tools for a turn. - Do not vary
reasoning.effort/modelmid-call if cache matters. - Send a stable
prompt_cache_keyper 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). prompt_cache_options.prewarm: trueprepares the cache without generating output (setsgenerateto false); the warm-up tokens are billed at the cache-write rate. Candidate forwarm().
- Fixed order:
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 andtype:"reasoning"; thesummary_textinner shape is from the first pass). SDK fields:id,type:"reasoning",summary[], optionalcontent[](reasoning_text), optionalencrypted_content, optionalstatus(in_progress|completed|incomplete). - Stateless mode (
store:false): "reasoning items in the response'soutputarray include anencrypted_contentproperty by default"; the legacyreasoning.encrypted_contentvalue inincludeis "still accepted ... for compatibility, but doesn't require it". Always sendinclude: ["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
usermessage. Withstore:falsethe guide says "preserve every output item, append the next user message, and replay the complete history". For brisvox: the adapter needs an opaqueprovider_datablob per assistant turn holding the raw JSON of thereasoningandfunction_calloutput items (taken fromresponse.output_item.done) to replay in the next request.Messagetoday has onlyrole/content/tool_call_id, so the adapter needs either aprovider_data: Option<Arc<str>>field onMessageor 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):autouses 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 (autofor 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_secretsfor ephemeral keys; example modelgpt-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 eventresponse.create; the payload "mirrors the normal Responses create body, except that transport-specific fields likestreamandbackgroundare 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 namedstream_idvalues (FIFO per lane is from the first pass, not re-checked); continuation viaprevious_response_idwith only the new input items, compatible withstore=false/ZDR;generate:falsewarms state; uncached id withstore=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 aresponse.steerevent) 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_callitems have bothid(fc_...) andcall_id(call_...); results usecall_id.- Reasoning
encrypted_contentis incomplete inoutput_item.added; useoutput_item.done. - Argument fragments may be invalid JSON; validate before dispatch (also the brisvox
MalformedToolArgumentsfault). - 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.
truncationdefaults todisabled: 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_obfuscationadds padding bytes per delta (on by default): setfalsefor 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-astrarejectsreasoning.effort:"none"with HTTP 400.
UNVERIFIED list
- Responses SSE:
event:line values, presence of[DONE], any keep-alive comments,response_idfield on delta events (guide shows it, SDK types do not). - Fields of
response.refusal.doneand content-part events. - Whether
function_call.idmust be echoed on stateless replay; orphaned reasoning items; reasoning after model switch; reasoning items ateffort:"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: truetools andconfiguration_updateinput 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.contextdefault 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.createdvisible; 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 want | Start with | Verified |
|---|---|---|
| A hosted LLM, any language, tools | OpenRouter: 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 LLM | Ollama: preset = "ollama", model = "gemma4:12b-it-qat" (ollama pull gemma4:12b-it-qat), reasoning_effort = "none", no key | Live: 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, vLLM | The matching preset | Mock 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_urlincludes the version prefix and differs per vendor (table below). Regions: not researched (UNVERIFIED for every vendor). - Protocol: SSE of
chat.completion.chunkJSON objects, terminated bydata: [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 URL | Auth | Source | |
|---|---|---|---|
| OpenAI (baseline) | https://api.openai.com/v1 (see openai.md) | Authorization: Bearer | openai.md |
| Groq | https://api.groq.com/openai/v1 | Authorization: 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 AI | https://api.together.ai/v1 | -H "Authorization: Bearer $TOGETHER_API_KEY" (doc) | docs.together.ai/docs/openai-api-compatibility |
| Fireworks AI | https://api.fireworks.ai/inference/v1 | Bearer token in the authorization header (cURL example); also OPENAI_API_BASE / OPENAI_API_KEY env | docs.fireworks.ai/tools-sdks/openai-compatibility |
| vLLM | http://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 local | http://localhost:11434/v1/ | none; client needs a placeholder such as 'ollama', ignored by the server | docs.ollama.com/api/openai-compatibility |
| Ollama cloud | https://ollama.com/v1 | Authorization: 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[], optionalsystem_fingerprint,service_tier,obfuscation,moderation,usage.choices[]:index,delta,finish_reason(nulluntil the end;stop,length,tool_calls,content_filter, deprecatedfunction_call),logprobs.delta:role,content,refusal,tool_calls[], deprecatedfunction_call,audio.delta.tool_calls[]:index(required int, identifies the call),id(optional, "The ID of the tool call"),type("Currently, onlyfunctionis supported"),function{name,arguments};argumentsis a JSON string that "may be invalid" and must be validated.usage: only present withinclude_usage; "null in every chunk except the last"; the last chunk haschoices: []("The array can be empty on the last chunk whenstream_options.include_usageis set"; thechoices: []quote was not re-confirmed on 2026-10-08: UNVERIFIED); fieldsprompt_tokens,completion_tokens,total_tokens,prompt_tokens_details.cached_tokens,completion_tokens_details.reasoning_tokensand 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 format | id | index | Evidence | |
|---|---|---|---|---|
| Groq | The 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: UNVERIFIED | optional per SDK type | required int per SDK type | console.groq.com/docs/tool-use/local-tool-calling (code example); groq-python chat_completion_chunk.py (source) |
| Together AI | Incremental: "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 delta | per doc example index: 0 in both deltas; null seen in non-streaming multi-function example | docs.together.ai/docs/inference/function-calling/single-call.md, .../parallel.md |
| Fireworks | Incremental: "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 index | docs.fireworks.ai/guides/function-calling |
| vLLM | Depends 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 whole | UNVERIFIED for streaming (non-stream default id format chatcmpl-tool-<uuid>, kimi_k2 parser uses functions.<name>:<idx>) | UNVERIFIED | docs.vllm.ai features/tool_calling; vllm chat_completion/serving.py, chat_utils.py (source) |
| Ollama | Not 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.ID | Function.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: changebase_url, pass the Groq key. - Unsupported fields, "400 error if supplied":
logprobs,logit_bias,top_logprobs,messages[].name;nmust equal 1.temperature: 0is converted to1e-8. (The compat page does not mentionstream_optionsorparallel_tool_calls; the API reference does list both. The groq-pythoncompletion_create_params.pydefinesparallel_tool_callsbut NOTstream_options; it only hasstreamasLiteral[True/False].) - API reference:
stream(SSE, ends with "data: [DONE]");stream_options("Only set this when you setstream: true"; the property list is collapsed in the fetched text, so whetherinclude_usageis accepted is UNVERIFIED);parallel_tool_calls(boolean, defaulttrue);tool_choice(none/auto/required/named function, defaultnonewithout tools,autowith);max_completion_tokens(replaces deprecatedmax_tokens);service_tier(auto,on_demand(default),flex,performance;flex"will succeed or fail quickly").reasoning_effort: valuesnone|default|minimal|low|medium|high|xhigh|max, model dependent (qwen3 models:nonedisables reasoning,default/null = model default;qwen/qwen3.8-27badditionallylow|medium|high, its default isnone;openai/gpt-oss-20band-120b:low|medium|high, defaultmedium); "Values outside a model's supported set are rejected with a 400". - Streaming chunk: SDK type
ChatCompletionChunkhas top-levelusageand anx_groqobject withusage,id,debug,error,seed,usage_breakdown. The API reference callsx_groq"Groq-specific metadata for non-streaming chat completion responses"; where Groq puts usage in a stream (top-level on the last chunk, insidex_groq.usage, or on a trailing empty-choiceschunk): UNVERIFIED. Accept all three.x_groq.errorexists in the type: a mid-stream error may be delivered there (UNVERIFIED). finish_reason(SDK Literal):stop,length,tool_calls,function_call(deprecated). Nocontent_filterin the Groq type.deltaextras (SDK):reasoning(string; per the SDK docstring it is "only populated when the request'sreasoning_formatisparsed", so by default thinking may arrive inline incontentor be hidden depending onreasoning_format: the otherreasoning_formatvalues 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 listsqwen/qwen3.8-27bandminimaxai/minimax-m2.7under 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 namesmessage,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-instant560 tokens/s, 131,072 context, 131,072 max completion;openai/gpt-oss-20b1,000 tokens/s, 131,072 context, 65,536 max completion;openai/gpt-oss-120b500 tokens/s, 131,072 context, 65,536 max completion;llama-3.3-70b-versatile280 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) andopenai/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 andresponse_formatare supported. - Differences from OpenAI (compat page):
service_tier,store,metadata,predictionaccepted but ignored;seedbest-effort;logit_biasunsupported on most models;nnot 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 areasoningorreasoning_contentfield on the assistant message, "the field name depends on the model";idandsystem_fingerprintuse Together formats; errors "follow OpenAI's shape, buttypeandcodevalues are Together's. Match on HTTP status codes". - Reference (
ChatCompletionChunk): requiredid,object(chat.completion.chunk),created,choices,model; optionalsystem_fingerprint,usage,warnings.choices[]:index,delta,finish_reason(nullable),logprobs,top_logprobs,seed.delta:role(required: UNVERIFIED as "required" on 2026-10-08), optionalcontent,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_reasonvalues:stop,eos,length,tool_calls,function_call.eosis a Together-only value; treat asStop. stream_optionsandparallel_tool_calls: NOT defined on the reference page (confirmed 2026-10-08:ChatCompletionRequesthas onlystreamas a boolean) nor the parameters page: support UNVERIFIED. Do not rely oninclude_usage; accept usage if present. The function-calling parallel page documents parallel calls as a model behaviour (three calls withindex0,1,2 and distinctids such ascall_aisak3q1px3m2lzb41ay6rwf), without a toggle.tool_choice(best-practices):"auto"default,"none","required", or{"type": "function", "function": {"name": "get_current_stock_price"}}.strict: trueconstrains arguments (streaming interaction not described).max_tokens;context_length_exceeded_behaviordefaults toerror(truncateoverridesmax_tokenswith 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_tokensparameter 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 forcontext_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 areaccounts/fireworks/models/<id>(examples on the quickstart:glm-5p3-flash,kimi-k3,deepseek-v3p1,glm-5p3); dedicated deploymentsaccounts/<ACCOUNT_ID>/deployments/<DEPLOYMENT_ID>(UNVERIFIED on 2026-10-08, not re-seen in fetched text). - Compat page, "minor differences":
max_tokensbehaves differently when the context is exceeded (context_length_exceeded_behavior:truncateis the DEFAULT and lowersmax_tokens;errormatches OpenAI). No supported/unsupported parameter list exists on the page. - Streaming usage: Fireworks returns usage stats for streaming too. The compat page says "the
usagefield is returned in the very last chunk on the response"; the querying-text-models page says usage is in the final chunk, "the chunk withfinish_reasonset", 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 tofalseto opt out" (parameterinclude_usage), and usage arrives as a final chunk, "not the chunk carryingfinish_reason", beforedata: [DONE](the "emptychoicesarray" wording and "Per-choiceusagemay 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-choiceusage). Otherstream_optionsfields: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(defaultauto;none;required(aliasany);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_tokensis 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_metricsobject on the final chunk; the reference says setting it totrue"additionally includes the same metrics in the response body under theperf_metricsfield"),response_format. Tool support per model is exposed as asupportsToolsfield (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 beginningvllm serve NousResearch/Meta-Llama-3-8B-Instruct, the remaining flags UNVERIFIED); client examplebase_url="http://localhost:8000/v1". Default served model name = the model argument (UNVERIFIED: not read). - Chat API:
parallel_tool_callssupported: "Setting theparallel_tool_callsparameter tofalseensures vLLM only returns zero or one tool call per request"; multiple calls withtrueare model dependent. Only unsupported item noted:image_url.detail;useris 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 thanstream_intervalwas not re-confirmed: UNVERIFIED),stream_interval("Number of tokens to batch into each streamed chunk"; leave at default for latency). The page does not mentionstream_options/continuous_usage_stats. - Source (
serving.py): withinclude_usagea trailing chunk withchoices=[]andusageis emitted after the token loop (serialized withexclude_none=True; the earlier note that it also usesexclude_unset=Truewas not confirmed);include_continuous_usageputsusageon EVERY chunk (also the initial role chunk and echo chunks, running counts; the exactcompletion_tokens=0on the first chunk is UNVERIFIED); every path ends withdata: [DONE](yielded unconditionally at the end of the generator, even after an error chunk). Errors mid-stream are yielded asdata: {...}error chunks (viacreate_streaming_error_responseor_convert_generation_error_to_streaming_responseforGenerationError), followed by[DONE](body shape not read: UNVERIFIED). AGenerationErrorframe 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 isstop, it is rewritten to"tool_calls"- EXCEPT when a namedtool_choicefunction is used, where it stays"stop"even though a tool call was produced. (Non-streaming: alsotool_callswhentool_choice == "required"and the engine saidstop.) Adapter rule: deriveToolCallswhen 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.hermesfor 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 withtool_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 andrequireduse 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). Thehfparser rejects "strict tools, required or named tool choice, orparallel_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; "providedtool_call_idfields are truncated to the last 9 digits". (Not inchat_utils.py.) - Tool call id format (source
make_tool_call_id):chatcmpl-tool-<uuid>;kimi_k2parser:functions.<name>:<idx>(used for model typeskimi_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/, cloudhttps://ollama.com/v1. Responses API (/v1/responses, non-stateful only) exists since v0.13.3. -
/v1/chat/completionssupported request fields:model,messages(text, base64 images, parts arrays; image URLs not supported),frequency_penalty,presence_penalty,response_format,seed,stop,stream,stream_options(onlyinclude_usage),temperature,top_p,max_tokens,tools,reasoning_effort,reasoning(effort: theeffortsub-field was not re-confirmed). Not supported:tool_choice,logit_bias,user,n, logprobs.max_completion_tokensandparallel_tool_callsare 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 asdata: <json>followed by a blank line; order is content chunks, a dedicated finish chunk (emptydelta,finish_reason), then (only withinclude_usage) a second finish-style chunk withusageand a non-standardtimingsobject (durations in milliseconds) andchoices: [], thendata: [DONE]. Successful streams useContent-Type: text/event-stream. Chunk id isfmt.Sprintf("chatcmpl-%d", rand.Intn(999))(i.e. 0..998; collisions possible; do not treatidas unique).system_fingerprintis"fp_ollama".createdis 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 indelta.reasoning(field namereasoningnot re-confirmed: UNVERIFIED). -
finish_reason(source):DoneReasonor"stop"; overridden to"tool_calls"only when the reason isstopand a tool call was sent; other reasons (e.g.length) pass through. -
Tool calls (source): each carries
id(generated ascall_+ 8 lowercase alphanumerics in the chat handler viamath/rand;omitemptyin 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 showsindexinsidefunction({"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 usetool_call_id(UNVERIFIED how the server mapstool_call_idback; 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, optionalprompt_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 wheredoneistrue. -
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 theapplication/x-ndjsonformat with anerrorproperty" and the status stays 200. On the OpenAI path (sourceopenai/openai.go):{"error": {"message","type","param","code"}}withtypeinvalid_request_error(400),not_found_error(404), elseapi_error;paramandcodeserialize asnull(not re-confirmed). The chat writer has no SSE error event: a non-200 status is sent as plainapplication/json; how a mid-stream failure appears on the OpenAI path: UNVERIFIED (routes.gostreamResponsewrites a final NDJSON error line for the native path once output has started). -
Concurrency (faq):
OLLAMA_NUM_PARALLELdefault 1 per model (the page says "default 1"),OLLAMA_MAX_LOADED_MODELSdefault 3 * GPUs (3 on CPU),OLLAMA_MAX_QUEUEdefault 512; a full queue answers "503 ... server is overloaded". Models stay loaded 5 minutes (OLLAMA_KEEP_ALIVE, per-requestkeep_aliveoverrides; negative = forever, 0 = unload: the per-request/negative/0 details were not re-confirmed). The documented OpenAI fields have nokeep_alive, so setOLLAMA_KEEP_ALIVEon the server or warm via the native API; the first request after unload paysload_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_ctxandollama 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:cloudthrough 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".Model GPU memory Tool scenarios (of 18) TTFT p50 First sentence p50 gemma4:12b-it-qat7.7 GB 18 69 ms 267 ms gemma4:e4b-it-qat3.1 GB 12 (fails the 3-round chain and the invalid-argument retry) 42 ms 157 ms qwen3.5:4b3.1 GB 12 (same two) 65 ms 208 ms qwen3.5:9b5.5 GB 12 (flaky; once answered with made-up weather, no tool call) 79 ms 291 ms qwen2.5-coder:7b4.7 GB 0 (writes the call as JSON text, which would be spoken) 34 ms 220 ms -
Thinking models. Set
reasoning_effort = "none"(the config key of the same name): theollamapreset does not send it by itself./v1/chat/completionshonours it (and the nestedreasoning: {effort: "none"}) but ignores the nativethink: false. Without itqwen3.5spent a whole 200-token budget on hidden reasoning and never spoke; withmediumeffort the 9b model passed all tool runs but took 14 to 35 s to the first word. -
GPU memory.
gemma4:12b-it-qatis 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 withnum_gpu = 30).
Known deviations (checklist for the adapter and its tests)
- Tool-call shape: fragments (OpenAI, Together, Fireworks) vs whole calls (Ollama most
likely; vLLM
hfparser). Same code path must accept both. Never requirearguments:""on the first delta. - Missing or null fields:
idoptional on delta types for Groq, Fireworks, Together, Ollama (omitempty);indexisnullin Together's non-streaming parallel-multiple example. Fallbacks: ifindexis missing, use the position intool_calls[]of that delta, and if a newidappears for an index already holding a differentid, start a new call. If noidever arrives, synthesise one (call_<n>) so thetoolmessage 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). finish_reasonvalues:stop,length,tool_calls,content_filter(OpenAI),function_call(deprecated, OpenAI/Groq/Fireworks/Together),eos(Together). Mapping to brisvoxFinishReason:length->Length;tool_calls/function_call->ToolCalls;stop/eos/content_filter->Stop(content_filter has no variant, same gap as openai.md); additionally forceToolCallswhen any call was accumulated (vLLM namedtool_choicekeepsstop).- Usage placement: OpenAI: trailing chunk,
choices: [], before[DONE]. vLLM: same, plus optional per-chunk usage. Ollama: same (extratimings). Fireworks: documented as BOTH "on the finish chunk" and "separate trailing chunk" (pages disagree; on by default, opt-out). Groq: top-levelusageandx_groq.usageexist in the SDK type, placement UNVERIFIED. Together:usagenullable,stream_optionssupport UNVERIFIED, cached/reasoning counts "in different locations depending on the model". Rule: remember the last non-nullusageseen anywhere (top-level,x_groq.usage, per-choiceusageUNVERIFIED), emit it inDone; do not wait for it past the terminal event more than a short grace (a missing usage must not delayDone; 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. - Reasoning deltas: Groq
delta.reasoning(only withreasoning_format=parsed), Ollamadelta.reasoning, Fireworksdelta.reasoning_content, Togetherreasoning/reasoning_content(model dependent). These must never reach TTS: ignore them forTextDelta(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 orreasoning_effort: nonewhere supported (Groq: Qwen3; OpenAI: see openai.md). - Unsupported / rejected request fields: Groq 400 on
logprobs,logit_bias,top_logprobs,messages[].name,n != 1; Ollama lackstool_choice,n,logit_bias; Together ignoresservice_tier,store,metadata,prediction. Send only what is needed:model,messages,stream,tools(when non-empty),max_tokensormax_completion_tokens(configurable name),temperature; everything else behind explicit per-provider config (stream_options.include_usage,parallel_tool_calls,tool_choice). - Max-token field name: OpenAI/Groq prefer
max_completion_tokens(max_tokensdeprecated); Ollama, Together, Fireworks (accepts both, not together), vLLM (docs not read for it) documentmax_tokens. Make it a config enum, defaultmax_tokens. - Error bodies: OpenAI-style
{"error":{"message","type","param","code"}}(OpenAI, Groqmessage+type, Together "OpenAI's shape ... Together's values", Ollama OpenAI path); Ollama native{"error":"string"}; vLLM mid-streamdata: {...error...}then[DONE]; Ollama native mid-stream NDJSON{"error":...}with status 200. Parser: accepterroras an object or a string; also acceptx_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). - Context-overflow status: Together documents 403 (not 400) for input +
max_tokensover the context on its error-codes page (but its chat-completions reference says 400 forcontext_length_exceeded_behavior=error); Fireworks silently truncatesmax_tokensby 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 toRejected { status }unchanged; the LLM stage decides. - 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).
- 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 afinish_reasonwas cut off ->Error(Disconnected). A stream that has afinish_reasonbut no[DONE]before EOF: accept as complete only after a short bounded wait for the usage chunk (UNVERIFIED how each vendor behaves). - Non-standard chunk fields: Together
warnings,token_id,seed,top_logprobs; Ollamatimings,system_fingerprint: "fp_ollama"; Fireworksperf_metrics,internal_content; Groqx_groq. Parse leniently, nodeny_unknown_fields; parsetype-like discrimination in two steps as openai.md does for events. - Chunk
idnot unique (Ollama random number < 999): never key state by chunkid. - Empty
choicescan appear for usage chunks (all) and possibly for keep-alives (UNVERIFIED): treatchoices: []as "no content", not an error.choices[0]only (n = 1). - Role-only first chunk
delta: {"role":"assistant","content":""}(OpenAI) orcontent: null: empty string must not produce aTextDelta.
Rate limits and error bodies
| Headers (documented) | Notes | |
|---|---|---|
| OpenAI | x-ratelimit-limit-requests, -limit-tokens, -remaining-requests, -remaining-tokens, -reset-requests, -reset-tokens, x-request-id, Retry-After sometimes (openai.md) | |
| Groq | retry-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 |
| Together | none 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" |
| Fireworks | none 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) |
| vLLM | none (deployment-defined) | UNVERIFIED |
| Ollama | none; queue-full -> 503 | OLLAMA_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::Assistantwithtool_calls->tool_calls:[{"id","type":"function","function":{"name","arguments"}}]withcontentnull/"" when empty.Message::provider_datais ignored (not this adapter's). Tools whoseparameters_schemais not valid JSON ->Protocolbefore any I/O. Omittoolswhen empty (some servers rejecttools: []: 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 upstreamindex, 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 thatHEAD {base}/chat/completionsanswers 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-qatrecommended);gpt-oss:20b,qwen3:8b,llama3.2are 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 /nullindex, empty or missingid(syntheticcall_<n>), usage at the top level, inx_groq.usageor in the choice, finish wordsstop/length/tool_calls/function_call/eos/content_filter/ unknown,argumentsas a string (or, defensively, a JSON object), error bodies as objects or strings,x_groq.error.Quirksonly decides what is sent and how long the end of a response waits (see below). - Call identity. A fragment with an
indexbelongs to the newest call with that index, unless it carries a different non-emptyid(then it opens a new call: a vendor numbering every call 0), and so does a fragment whosenamediffers from the name the call already has (whole calls of an Ollama-style vendor may all sayindex: 0and carry no id; a repeated identical name is taken as a continuation). Without anindex: a knownidcontinues that call; noidbut anameopens a call; neither continues the newest call. Names are emitted once. - Finish. A response that accumulated tool calls ends in
ToolCallswhen the vendor saidstop/eos/ unknown (vLLM keepsstopfor a namedtool_choice), but staysLengthwhen it was cut bylength(the arguments may be truncated; the caller must not run them). - When
Doneis emitted. At the chunk withfinish_reasonif that chunk carries usage orQuirks::usageisFinishChunk/Absent; otherwise (TrailingChunk, the default and the presets for Groq, Fireworks, vLLM, Ollama) at the usage chunk,[DONE], EOF or afterusage_grace(250 ms), whichever is first. Only usage on the finish chunk itself counts as final: running totals on earlier chunks (vLLMcontinuous_usage_stats) are kept, but the response still waits for the trailing chunk and reports the last usage seen. WithFinishChunk/Absenta separate usage chunk after the finish chunk is deliberately not waited for (the preset says there is none; a vendor that sends one belongs onTrailingChunk). A stream that ends or breaks (EOF, reset, cut chunked body) after itsfinish_reasonis complete; one that ends before it isDisconnected;[DONE]without anyfinish_reasoncompletes asStopwhen no tool call is open (text only). Decision (review fix):[DONE]without afinish_reasonwhile tool-call fragments were seen is a cut, mapped toDisconnectedexactly 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 getDone { 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 afinish_reasonon all five vendors' documented streams; a custom server that omits it for tool calls will seeDisconnected. - Decision: tool-call shape,
finish_reasonvariants 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;Quirkskeeps 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 switchsynthesize_missing_ids(off: a call without an id is a protocol error). Not offered: forcing fragmented-only parsing, or makingstopwith 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_pathis appended to the path ofbase_url, the query ofbase_url(?key=...) stays a query after the path, a query onchat_pathis appended to it with&, and fragments are dropped (they are never sent).https://gw.example/v1?key=abc+/chat/completionsposts 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
Debugof the configuration prints scheme, host and port plus header and body-field names.extra_bodymust not settemperature/parallel_tool_callswhile the adapter sets them (no duplicate keys in the body). - Never spoken / never used.
delta.reasoning,reasoning_content,internal_content,refusal,annotations,x_groqextras,timings,warnings, the chunkid. Arefusalis 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 notool_choice, the parallel-call support of Groq differs per model),max_completion_tokensinstead ofmax_tokens(Groq preset).toolsis 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 hascontent: null.Message::provider_datais 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-Afterand the sanitised errortype/codeare logged, the message never. In-streamerror(object or string): a numericcode/status400-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 isProtocol. Ollama's native mid-stream NDJSON error line ({"error": "..."}withoutdata:) is not SSE: the SSE parser ignores it and the response ends asDisconnected(UNVERIFIED whether the/v1path ever emits it). - Warm-up is an unauthenticated
HEADto the chat URL (UNVERIFIED that every vendor answers it; any HTTP status counts, a refused connection isDisconnected). 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_tokensonly (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
Protocolwhen 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=1plusGROQ_API_KEY/TOGETHER_API_KEY/FIREWORKS_API_KEYand/orOPENAI_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=1with the keys in.env:tests/openai_compatible/live.rspasses 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.rswhere a live capture differs: tool-call fragmentation per vendor, usage placement andstream_optionsacceptance,finish_reasonwords, mid-stream error body shapes, whether the five vendors send[DONE]after a tool-callfinish_reason,HEADwarm-up answers.
UNVERIFIED (consolidated)
- Wire captures of
delta.tool_callsfor 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_usageacceptance (not in groq-python params), whether tool-call arguments are fragmented, mid-stream error delivery (x_groq.error), Authorization header spelled out as text, exactfailed_generationJSON values,reasoning_formatvalues other thanparsed. - Together:
stream_options/include_usageandparallel_tool_callsparameters, usage in the last chunk, rate-limit headers, JSON error body, streamingindexalways 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_callsper-model support, namedtool_choiceform (conflict{"type":"function","name"}vs nested),tool_choiceenumeration,buffer_ms,prompt_cache_isolation_key, dedicated-deployment model id format. - vLLM: default host/port, auth header name, streaming fragmentation per parser (only
hfdocumented as whole), exact failure behaviour without--enable-auto-tool-choice, mid-stream error body shape,stream_optionsacceptance in docs (source showsinclude_usage),max_tokensvsmax_completion_tokenshandling, 429/503 behaviour, fullvllm serveflags. - Ollama: whether streamed tool calls are always whole, whether
max_completion_tokens/parallel_tool_callsare ignored or rejected, mid-stream error shape on/v1, mapping oftool_call_idback to a call,Authorizationhandling for a reverse-proxied local server,handleNativeChattool-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.pymerge 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 toplatform.claude.com/docs/en/...; the API-reference schema still linksdocs.anthropic.com) (fetched 2026-10-08) - Auth:
x-api-key: <API key>plusanthropic-version: 2023-06-01pluscontent-type: application/json. The overview page listsAuthorization: Bearer <token>as the primary header ("Yes, unlessx-api-keyis set"; the token is an API key or a short-lived token from Workload Identity Federation) and callsx-api-keythe "Legacy fallback ... still supported". The Messages examples on every page still usex-api-key; use that. Optionalanthropic-workspace-id(required only with a multi-workspace key) andanthropic-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(alsoPOST /v1/messages/count_tokens,GET /v1/models). Max request size 32 MB (413request_too_large, returned by Cloudflare before the API servers). Region choice: request fieldinference_geo(string or null, defaults to the workspacedefault_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 acrossinference_geovalues. - Versioning:
anthropic-version: 2023-06-01is the only non-initial version listed ("Anthropic recommends using the latest API version";2023-01-01is 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 withoutdeny_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 ID | Role per docs | Price /MTok in / out | Cache write 5m / 1h / read | Latency 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 / $50 | n/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,temperaturemust be1,top_pmust be0.99(atop_pof1is rejected),top_kany value is rejected, andtemperature+top_ptogether is rejected. (The Messages-create reference wordstop_pas "A value >= 0.99 will be accepted", which disagrees on1: send neither.) For Sonnet 5.5 and Opus 5.5 the docs say generically that Claude 4.7+ models return 400 for a non-defaulttemperature/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
messagesarray must end with a user turn. thinking: {"type":"enabled","budget_tokens":N}is rejected;between_toolsis 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_tierrequest 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"withstop_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 carrydiagnostics.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, orsystem(system only mid-conversation, see below; the schema listssystembut 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(textminLength 1, optionalcache_control),image,document,thinking(signature,thinking),redacted_thinking(data),tool_use(id,name,input),tool_result(tool_use_id,content,is_error), ... An emptytextblock 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 brisvoxToolmessages 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 thetool_resultblocks. - 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 towardmax_tokens"even when the thinking content isn't returned"; Haiku 5.5 guide: "Amax_tokensvalue 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
thinkingtable (doc): Haiku 5.5: nothinkingfield = adaptive thinking;"adaptive"ok;"enabled"+budget_tokens400;"between_tools"400;"disabled"= "Thinking off athigheffort or below" (400 atxhigh/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 athigheffort or below" (400 atxhigh/max). Opus 5.5:"disabled"always 400 (thinking always on). Thinking is ON by default on all three, so a voice adapter must sendthinkingexplicitly or accept a thinking pause before the first text. - Effort levels
low,medium,high,xhigh,max; defaults: Haiku 5.5medium, Sonnet 5.5high, Opus 5.5medium. 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 withmediumorlow." display:"omitted"is the default on Haiku 5.5 and Sonnet 5.5 (emptythinkingtext,signaturestill returned) per the thinking page and the Haiku migration guide; NOTE the Messages-create reference saysdisplay"Defaults tosummarized", which contradicts them: trust the thinking page, confirm in a live run. "Setdisplay: "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
effortbetween requests invalidates the message cache ("Treat any thinking or top-level effort change as starting the cache over"). Per-message effort (system message withoutput_config.effort, beta headermid-conversation-output-config-2026-07-01) keeps the cache but is rejected with 400 on Haiku 5.5 when thinking isdisabledand 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
loweffort. If you see this behavior, switch to adaptive thinking andmediumeffort." 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 boththinkingandeffortconfiguration values. - Haiku 5.5 with thinking off and
output_config.formatJSON 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
thinkingblock"; also Anthropic says tool_choiceany/toolprefill means "the models will not emit a natural language response or explanation beforetool_usecontent 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 eventtypein its data." (Thetypefield in the JSONdatais authoritative for parsing; theevent:line repeats it.) Events are separated by blank lines. There is nodata: [DONE]sentinel in this version (versioning page: "Removed unnecessarydata: [DONE]event"); the adapter should still ignore a[DONE]payload defensively. - Flow (doc): 1.
message_start(aMessagewith emptycontent). 2. A series of content blocks, each withcontent_block_start, one or morecontent_block_delta,content_block_stop; each block has anindex"that corresponds to its index in the final Messagecontentarray". 3. "One or moremessage_deltaevents, indicating top-level changes to the finalMessageobject." 4. A finalmessage_stop.pingevents "dispersed throughout the response". Whether blocks are ever interleaved (deltas of block N+1 beforecontent_block_stopof N): the flow text implies sequential blocks, not stated explicitly: UNVERIFIED; key accumulation byindex. - A server-side-fallback
fallbackcontent block (start+stop pair, no deltas) appears only for models with server-side fallback (not Haiku 5.5); ignore unknown block types.
type | Fields | Use |
|---|---|---|
message_start | message {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_start | index, 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_delta | index, delta | see delta table |
content_block_stop | index | block complete: parse a tool_use input here |
message_delta | delta {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_stop | none | terminal success |
ping | none | ignore (keepalive-like) |
error | error {type, message} | terminal failure after HTTP 200 |
Delta types in content_block_delta.delta (doc):
delta.type | Field | Meaning |
|---|---|---|
text_delta | text | text for a text block -> LlmEvent::TextDelta |
input_json_delta | partial_json | partial JSON string for a tool_use input; "the final tool_use.input is always an object" |
thinking_delta | thinking | thinking text (empty string with display:"omitted") |
signature_delta | signature | opaque signature, "sent just before the content_block_stop event" |
citations_delta | citation | citations (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_deltawithdelta.type == "text_delta"(delta.text).ToolCallDelta { index, id, name, arguments }:index= dense counter overtool_useblocks (the blockindexalso 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 atcontent_block_startwith emptyarguments; everyinput_json_delta.partial_jsonbecomes a pure fragment (the empty first fragment may be skipped). The tool result is keyed by thisidastool_use_id.Done { finish, usage, provider_data }: emit aftermessage_stop(not atmessage_delta), using thestop_reasonfrommessage_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 toStop+ log, or extend the enum);pause_turn-> not expected. A stream that ends withoutmessage_stop/erroris cut off (Disconnected).Usage: Anthropicinput_tokensexcludes 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." SoUsage.input_tokens= sum of the three,Usage.cached_input_tokens=cache_read_input_tokens(usage =message_start.usageoverridden by non-nullmessage_delta.usagefields;output_tokensis cumulative). Tokens that arrive only if the stream completes; a cut stream losesmessage_deltausage.provider_data(opaque JSON text, echoed back on the assistantMessage): the JSON array of the response's assistant content blocks that must be replayed verbatim: everythinkingblock (withthinkingtext andsignatureassembled from deltas), everyredacted_thinkingblock (data), and thetool_useblocks, in the order produced (plus a placeholder where the text block sat, as the OpenAI adapter does).Nonewhen the response had none of these (text only). Streaming shape of aredacted_thinkingblock (start event withdataincontent_block, any delta?): the schema listsRedactedThinkingBlockas a possiblecontent_blockofcontent_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"), optionalinput_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, elseProtocolerror before any I/O). strict: true= grammar-constrained sampling ("Toolinputstrictly follows theinput_schema"). Schema rules (structured-outputs page):additionalPropertiesmust befalseon objects;requiredsupported;anyOfandallOfsupported "with limitations" (allOfwith$refnot);enum,const, local$ref/$def/definitions,defaultand string formats (date-time,time,date,duration,email,hostname,uri,ipv4,ipv6,uuid) supported; NOT supported: recursive schemas,minimum/maximum/multipleOf,minLength/maxLength,minItemsother than 0/1, external$ref, regex lookahead/lookbehind/backreferences/\b/\B;oneOfis 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 (anyOfor 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 keepstrictoff 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 takesdisable_parallel_tool_use(bool, default false, inside thetool_choiceobject, not top level): withauto, at most one tool per response; withany/tool, exactly one.any/toolreturn 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.");autoandnoneare fine; Haiku 5.5 supports forced use. Manual extended thinking (enabled) also forbids forced tool use. Changingtool_choiceordisable_parallel_tool_useinvalidates 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_useblocks in one assistant turn withstop_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
textblock, thentool_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_resultfields:tool_use_id(thetool_use.id),content(optional: a string, a list oftext/image/document/search_resultblocks),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_resultfor eachtool_useblock, 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 syntheticis_errorresult 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 raiseProtocolnaming the call id. - Mapping brisvox history -> Anthropic messages: leading
Systemmessages -> top-levelsystem(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 fortool_calls]}withinputparsed from the argument JSON text; a run ofToolmessages -> ONE{"role":"user","content":[tool_result...]}; user text after tool results goes after them in the same message. ASystemmessage 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 auserturn (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 carriestool_resultblocks, but NOT between an assistanttool_useand itstool_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_resultblock ("the model can then treat it as untrusted text and ignore it"); append it as a text block after the lasttool_resultof 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
thinkingblocks must match what the model generated in the original request: you can't rearrange, edit, or partially drop them. This includesredacted_thinkingblocks." Filtering onblock.type == "thinking"alone "silently dropsredacted_thinkingblocks and breaks the multi-turn protocol". Violations -> 400invalid_request_error, message starts with the block position (e.g.messages.1.content.0) and contains: "thinkingorredacted_thinkingblocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response." Text put in the emptythinkingfield 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 oftools, and every earlier message. Editing/reordering/deleting earlier messages, changingsystem, adding/removing/renaming/editing a tool, or clearing an earliertool_resultmakes later thinking blocks invalid; appending messages, changingeffort/max_tokens/tool_choice/metadata, and adding/movingcache_controlmarkers do not. Result is 400invalid_request_error(messages.{i}.content.{j}: Invalidsignatureinthinkingblock. The block is bound to a different conversation. Remove the block, or setthinking.block_binding.prefix_mismatch_behaviorto "drop_block".) by default for accounts created on or after 2026-08-31 00:00 UTC; older accounts get the error only when they setthinking.block_binding.prefix_mismatch_behavior. Beta headerthinking-binding-controls-2026-08-01addsblock_binding.prefix_mismatch_behavior("error"|"drop_block") and aninput_transformationsarray 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_usethat 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 whosetool_usesurvives 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 isthinking: {"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
thinkingblocks before tool calls (thinking page, "Progress updates between tool calls"; Sonnet 5.5between_toolsreturns 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_controlfield; "The system places the breakpoint on the last cacheable block and moves it forward as the conversation grows." Explicit breakpoints =cache_controlon 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_tokensandcache_read_input_tokensare not both 0. - What is cached: "the entire prompt:
tools,system, andmessages(in that order), up to and including the block designated withcache_control". Cacheable: tool definitions, system blocks, user/assistant text, images/documents (user turns),tool_useandtool_resultblocks. 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_useblocks count as one position, likewise consecutivetool_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_choicechange: messages invalid; images added/removed: messages invalid; thinking parameters andoutput_config.effortchange: 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.usagewhen streaming;message_delta.usagerepeats 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_tokensequals the sum of the values in thecache_creationobject"),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-> responsediagnostics.cache_miss_reasonwith typesmodel_changed,system_changed,tools_changed,messages_changed,previous_message_not_found,unavailable, each of the first four withcache_missed_input_tokens): "null means diagnosis is still pending — the response was serialized before the background comparison completed" (anddiagnosticsitself isnullwhen none was requested or no divergence was detected). Whether this field arrives in a streamedmessage_start/message_delta: UNVERIFIED (the stream schema'smessage_start.messageis a fullMessage, whose schema has thediagnosticsfield, but themessage_deltaschema lists nodiagnostics; the field is likelynullatmessage_starttime). 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 anycache_controlbreakpoint, then returns immediately without generating any output." Billed as a cache write; whether it is allowed withstream: true, its latency and response shape (assume a normal non-streamingMessagewith emptycontent): UNVERIFIED. Combine with a HEAD/TLS warm-up forwarm(). - 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_controlon 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 laterole:"system"message, never in the top-level system prompt; (5) never change model, thinking mode,effort,tool_choiceor tool set mid-call; (6) keep serialisingtools/system/messagesbyte-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: "beginsYou have reached your specified API usage limits"), 401authentication_error, 402billing_error, 403permission_error, 404not_found_error, 409conflict_error, 413request_too_large, 429rate_limit_error, 500api_error, 504timeout_error, 529overloaded_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-streamerrorevent ->LlmEvent::Error:overloaded_error->Rejected 529(retryable),rate_limit_error-> 429,api_error-> 500,invalid_request_error-> 400, other ->Protocolnaming the sanitised type (never copymessage). - 429 variants: rate limit hit (has
retry-after); tier spend cap (monthly cap Start $500 / Build $1,000 / Scale $200,000):rate_limit_errorwitherror.details.error_code: "enforced_spend_limit_reached"and NOretry-afterheader, 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-afterheader". The brisvox adapter does not retry (turn logic owns it) but should read and logretry-afterandrequest-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_tokensandcache_creation_input_tokenscount,cache_read_input_tokensdoes NOT (except Haiku 3.5). ITPM is estimated at request start and adjusted; OTPM is counted as tokens are produced,max_tokensdoes 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
pingevents are the only documented keepalive ("any number ofpingevents"); 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, andpings 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_tokensEXCLUDES cache reads and writes: sum the three fields for brisvoxUsage.input_tokens;cached_input_tokens=cache_read_input_tokens. stop_reasonis only inmessage_delta; usage there is cumulative; usage inmessage_startholds the input side. EmitDoneaftermessage_stop.tool_useblocks:input: {}at start is a placeholder; fragments start with an emptypartial_json; the object is complete atcontent_block_stop; arguments may be partial/invalid witheager_input_streamingor atmax_tokens; validate before dispatch.- Tool results:
tool_resultblocks first in ONE user message, no text before them; pair everytool_useid; synthesiseis_errorresults for cancelled calls. - Thinking is on by default on Claude 5.5 models: send
thinkingexplicitly; Haiku 5.5disabledworks only at effort <=high; Sonnet 5.5 usesbetween_tools; reasoning-like text may leak into speech with thinking off orloweffort 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_kon 5.x; no prefill (final message must be a user turn); forcedtool_choiceis rejected on Sonnet 5.5 and Opus 5.5. tool_choiceordisable_parallel_tool_usechanges invalidate the message cache; changing thinking oreffortinvalidates it too.- Empty
end_turnresponse after tool results is possible (dead air); handle it. - 529
overloaded_errorcan arrive as an in-streamerrorevent after HTTP 200. anthropic-versionmust 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"anddisplay:"omitted"on TTFT in voice turns. - Inter-
pinginterval; whether SSE comments occur; server-side idle/stream timeouts; cost of dropped streams; cancel semantics; the SDK'smax_tokens> 21,333 streaming threshold (third-party sources only). - Whether content blocks can interleave; streaming shape of
redacted_thinking; whetherdiagnostics.cache_miss_reasonappears in streams. - Rules for consecutive same-role messages and first-message role (pages fetched do not say).
- Strict-tool grammar-compile latency;
oneOfsupport in strict schemas. max_tokens: 0withstream: 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_useof the turn is dropped. - Regional hostnames,
inference_geovalues, 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
displayfor thinking (reference sayssummarized, thinking page saysomittedon 5.5 models); exact acceptedtemperature/top_pvalues 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/overviewURL 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.txtof 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 usev1beta;v1is 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}). Withoutalt=ssethe response framing is not described in the pages fetched: UNVERIFIED (we usealt=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).
- Base
Important: API surface decision (generateContent vs Interactions)
Google now documents TWO surfaces on the same host:
generateContent/streamGenerateContent(stateless, resend fullcontentseach turn).- The Interactions API (
POST /v1beta/interactionsper the streaming page; stateful by default viaprevious_interaction_id,store=falsefor stateless; typed SSE eventsinteraction.created,interaction.status_update,step.start,step.delta,step.stop,interaction.completed,error, and a terminalevent: 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 av1beta2/interactionspath in the fetch: UNVERIFIED which is current, use the streaming page). The top-level pagesfunction-calling,thinking,thought-signatures,caching,streaming(no/generate-content/in the path) now describe the Interactions API (the top-levelthought-signaturespage 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:
- The stateless model matches the brisvox invariant "after an interruption the LLM context contains
ONLY what was actually played": we build
contentsourselves. With Interactions the default is server-side state (store=true);store=falseexists but the stateless replay rules there (resend allthoughtsteps exactly as received) are a different, newer shape. - The task text asks for
streamGenerateContentwithalt=sse, and it is fully supported. - 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::ToolCallDeltaper call with the fullargumentsJSON. - 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 ID | Role per docs | Notes |
|---|---|---|
gemini-3.8-flash | Most capable Flash model (long-running coding, agents, enterprise workflows); "latest models: 3.5 Flash-Lite or 3.8 Flash" recommended for new projects | stable; 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 projects | stable; default thinking level minimal; levels minimal/low/medium/high |
gemini-3.7-flash, gemini-3.6-flash, gemini-3.5-flash | previous generations (3.5 labelled legacy) | stable; 3.6/3.5: minimal allowed; 3.7: minimal errors |
gemini-3.1-flash-lite | lite tier | stable; minimal default |
gemini-3-flash-preview | Flash (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-lite | not 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-latest | alias, 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 page | gemini-2.0-flash, gemini-3.1-flash-lite-preview, gemini-3-pro-preview | do 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(enumTHINKING_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 withthought: true). "An error will be returned if [thinkingConfig] is set for models that don't support thinking." Do not sendthinkingLeveltogether with the legacythinkingBudget: 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,
minimalerrors; 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 ("minimaldoes not guarantee that thinking is off, the model may reason very minimally for complex tasks"). WhetherthinkingBudget: 0works ongemini-3.8-flash: UNVERIFIED (the thinking page does not list 3.x models in the thinkingBudget table). maxOutputTokensincludes thinking tokens; hitting it during reasoning givesfinishReason: MAX_TOKENSwith 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 camelCasesystemInstruction; the reference JSON uses camelCase. The proto3 JSON mapping normally accepts both: whether it accepts both here is UNVERIFIED. Use camelCase (reference). Same for thethoughtSignature/thought_signaturespelling, see below. FunctionDeclarationfields (reference):name,description(required),behavior(UNSPECIFIED,BLOCKINGdefault,NON_BLOCKING; the reference says only BidiGenerateContent (Live) supports it),parameters(OpenAPI-subsetSchema),parametersJsonSchema("Describes the parameters to the function in JSON Schema format. This field is mutually exclusive withparameters"),response,responseJsonSchema(mutually exclusive withresponse). BrisvoxToolSpec.parameters_schemais arbitrary JSON Schema, soparametersJsonSchemais the natural target; which JSON-Schema keywords it supports: UNVERIFIED (not on the pages fetched; the function-calling guide only says theparametersform uses a subset of the OpenAPI schema).- Function name: the reference for
FunctionDeclaration.nameallows letters, digits, underscores, colons, dots and dashes, max length 128;FunctionCall.name/FunctionResponse.nameallow 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 inFunctionDeclaration.name,FunctionCall.nameandFunctionResponse.name, so no dots or colons) and exactly the alphabet and length of the agent'sToolRegistry, 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 rejectedget-weatherand 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
ANYmode "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=ssewithx-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 ofGenerateContentResponseinstances" and that each streamed response contains aresponseIdthat ties the full response together. - Each SSE event's
data:line is one completeGenerateContentResponseJSON (illustrative shape from the docs' description, not a verbatim capture):candidates[](withcontent.parts[],content.role: "model",index),usageMetadata,modelVersion,responseId. ThatresponseIdis "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 andevent:names are documented only for the Interactions API. So the adapter must treat the end as: a candidate chunk withfinishReasonset, then the HTTP body closing; it must tolerate a[DONE]data line and SSE comments. Whether chunks carry anevent:line: UNVERIFIED (assume no). The thought-signatures guide says to read the stream until thefinish_reasonis returned. finishReasonis "optional and output-only. It stays empty while the model is still generating";finishMessageis populated only whenfinishReasonis set. Whether the last chunk can arrive with emptyparts(e.g. onlyfinishReasonandusageMetadata): likely, UNVERIFIED.- Text deltas:
candidates[0].content.parts[]entries withtext(and nothought: true) are answer text; parts withthought: trueare thought summaries (only whenincludeThoughts), which brisvox must NOT speak. A text part may be an empty string that only carries athoughtSignature(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
functionCallpart): iterate all parts of every chunk, do not assume one. candidateCountdefaults to 1 (reference): use onlycandidates[0].- Mid-stream error shape: UNVERIFIED (pre-stream errors are HTTP errors with the standard
errorbody, 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_CALLSvalue: a response that calls a function ends withSTOPas far as the list shows (UNVERIFIED in practice). The adapter must deriveFinishReason::ToolCallsfrom "anyfunctionCallpart was seen",LengthfromMAX_TOKENS, elseStop. The other values (safety, recitation, language, malformed call, missing signature) have no brisvox variant: map toStop+ log, orError(Protocol)forMALFORMED_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 havepromptFeedbackand nocandidates.- Troubleshooting page:
RECITATIONcan 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)";candidatesTokenCountexcludes thoughts pertotalTokenCount= prompt + thoughts + candidates). Adapter must default each missing field to 0. - Whether
usageMetadatais cumulative per chunk (last chunk holds the totals) or only on the last chunk: UNVERIFIED (the docs' streaming example shows a truncatedpromptTokenCounton 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/willContinuemechanism is gated byFunctionCallingConfig.streamFunctionCallArguments, which the JS SDK reference says is not supported in the Gemini API. The adapter emits oneToolCallDelta {index, id, name, arguments: <args serialised to JSON text>}perfunctionCallpart.index= dense counter in arrival order;id=functionCall.id, or a synthesised id (e.g.call_<n>) when absent so the brisvoxMessage::tool_result(call_id, ..)pairing still works (replay must then omitidon 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_CALLfinish 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 dedicatedupdate()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; interleavingFC1, FR1, FC2, FR2returns 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 allfunctionResponseparts in oneuserContent or several: UNVERIFIED, use one. - Brisvox
Messagemapping:Role::Toolmessages (consecutive) -> ONEusercontent with onefunctionResponsepart each, keyed bytool_call_id; name must be looked up from the matching assistanttool_callsentry (brisvoxMessagefor tool results carries no name). Content withrole: "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
thoughtSignaturein content parts, such astextorfunctionCall". 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
functionCallpart (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
functionCallin 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 afunctionResponse), and every modelfunctionCallstep 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 atminimalthinking on Gemini 3 Flash. A request missing one can also surface asfinishReason: MISSING_THOUGHT_SIGNATURE(reference). - Editing history manually: keep each signature in its original
Part; always include the exactid; 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 showthought_signaturein places (the OpenAI-compatible format nests it underextra_content.google.thought_signature). The reference Part JSON field isthoughtSignature(opaque base64 string). SendthoughtSignature(proto3 JSON parsers normally accept both: UNVERIFIED). - Streaming: the signature can arrive in a part with empty text; read the stream until the
finish_reasonis 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; keepfunctionCallparts withid,args,thoughtSignature, and any trailing signed empty-text part), stored on the assistantMessage.provider_dataand replayed as themodelcontent in the next request. After an interruption the played text must replace the model'stextwhile thefunctionCallparts 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-litewas not listed: UNVERIFIED; the pricing page says context caching is "not available" on their free tier). Hits are visible inusageMetadata.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 aCachedContentbody:model(required, immutable,models/{model}),contents,systemInstruction(text only),tools,toolConfig(all input-only, immutable),displayName(<= 128 Unicode chars), andttl(duration in seconds ending ins, e.g."300s") ORexpireTime(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. Onlyttl/expireTimecan be updated (PATCH .../v1beta/{cachedContent.name=cachedContents/*}with an optionalupdateMaskquery parameter); delete withDELETE .../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 sendsystemInstruction/toolsnext tocachedContent: 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 forwarm()-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), 400FAILED_PRECONDITION(free tier not available in your country and billing not enabled), 403PERMISSION_DENIED(key lacks permission), 404NOT_FOUND(a referenced image/audio/video file not found), 429RESOURCE_EXHAUSTED(RPM/TPM/RPD or spend-based limit), 500INTERNAL(e.g. context too long), 503UNAVAILABLE(overloaded; switching to another model is advised), 504DEADLINE_EXCEEDED(prompt too large; raise client timeout), 499CANCELLED(client closed early). A 402RESOURCE_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 400INVALID_ARGUMENTwithErrorInfo.reason: "API_KEY_INVALID". - Error body: JSON with an
errorobject:code(integer, HTTP status),message,status(gRPC name in SCREAMING_CASE, e.g.INVALID_ARGUMENT),details[](e.g.ErrorInfowithreason,domain,metadata, orLocalizedMessage). 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-litetext 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 thegemini-3.8-flash-ttsmodel 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: LANGUAGEexists ("unsupported language"). Test Turkish prompts in the live test and treatLANGUAGEas 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-genaiSDK and the samegenerateContent/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 onlylocation="global". Hostnames, URL paths, regional endpoints and Vertex-only features (streamedpartialArgs) 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 =BidiGenerateContentServerContentwithinterrupted: 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;thinkingLevelmust 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-flashdoes 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-emptyparts[].textwiththought != trueincandidates[0].ToolCallDelta {index, id, name, arguments}<- one perfunctionCallpart, whole, in arrival order (arguments= serialisedargsobject).Done {finish, usage, provider_data}<- chunk withfinishReasonset and the stream end:ToolCallsif anyfunctionCallseen,LengthforMAX_TOKENS, elseStop; 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})fromstream(); body ending before afinishReason->Error(Disconnected); in-band error JSON (shape UNVERIFIED) ->Error.promptFeedback.blockReasonwith 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-keyheader +alt=sseaccepted together onv1betafor the chosen model.- Chunk framing (
data: {json}\n\n, noevent:line, no[DONE]),usageMetadataplacement. thoughtSignaturespelling accepted in requests (camelCase), dummy signature accepted for replays without provider data on Gemini 3.finishReasonisSTOPon function-call turns; function calls always whole in one chunk.- A functionCall
idis 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_resultsteps,thinking_level,signaturedeltas belong to Interactions). Thegemini-3.md.txtguide is likewise Interactions-oriented and marked deprecated. - Gemini 3.8/3.7 Flash reject
thinkingLevel: "minimal"with an error; the cheapest valid level islow.gemini-3.5-flash-litedefaults tominimal. maxOutputTokenscounts thinking tokens: with thinking on, a small cap can yield an empty answer withMAX_TOKENS.promptTokenCountincludes cached tokens;thoughtsTokenCountis separate fromcandidatesTokenCount(add both for output).- Do not speak
thought: trueparts; 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 headerx-goog-api-key(marked sensitive; never in the URL, logs, errors orDebug). Default modelgemini-3.5-flash-lite(config). No redirects, no proxy, h2 + http/1.1 via the shared rustls config; plainhttp://only for loopback unlessallow_insecure_http. - Request: leading system messages ->
systemInstruction(joined with a blank line); a late system message -> a user text partSystem note: ...(no system role exists);user/modelroles; tool results ->functionResponseparts of ONEusercontent in call order (results may come back in any order, the adapter sorts them: the guide says "same order as requested"); theresponsemust 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 amodelturn (the agent's greeting) gets a placeholderusercontent(The call has started.)in front (leading_user_placeholder,Nonedisables): 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.generationConfigonly when something is set (maxOutputTokens,temperature,thinkingConfig.thinkingLevelXORthinkingBudget;new()refuses both).toolConfigonly with a configuredfunction_calling_modeand 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 withProtocolbefore any I/O. - Tool schemas (
schema.rs,SchemaMode::OpenApiSubset, default): the JSON Schema is converted to the OpenAPI subset ofparameters. 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 becomenullable; unions of several types becomeanyOf; a schema with no type information becomesSTRING, logged as a warning),description,properties,required(filtered to existing properties),items(defaultSTRING),enum(strings only),nullable,anyOf(oneOftoo),minimum/maximum,minItems/maxItems,formatfordate-time,int32/int64,float/double. Rewritten: local$refinlined,allOfmerged,constas 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 withoutparameters; a NESTED object without properties ({"type":"object","additionalProperties":true}) is never sent as a bareOBJECT(Gemini is believed to rejectOBJECTwith emptyproperties: UNVERIFIED on the fetched pages, and one such node would fail every tool of the request with a 400): it becomesSTRINGwith "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 forwardedToolCallDelta.arguments(RenderedTools::restore_args,schema::restore_objects: walks the converted schema alongproperties,itemsandanyOf, 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 ->STRINGone, which turns e.g. a number-or-object parameter into a string) is logged once per rendering as a warning naming the tool;SchemaMode::JsonSchemaavoids both. Refused withProtocolnaming the tool: external$ref, unknowntype, non-object root, recursion deeper than 24 levels or more than 10 000 nodes (counted separately for the$ref/allOfinlining pass and for the conversion pass, so a small DAG whose$ref/allOffan out exponentially fails fast instead of pinning a worker).SchemaMode::JsonSchemasends the schema verbatim asparametersJsonSchema(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 oneGeminiLlmdo not evict each other) and byte-stable for identicalToolSpecs; a cache miss with more than 4 KiB of schema text, or with any$ref/allOfin 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 SSEdatais oneGenerateContentResponse; onlycandidates[0]is read, allpartsare walked. Text ->TextDelta;thought: trueparts and empty text are never spoken; afunctionCall-> ONEToolCallDelta {index, id, name, arguments}with the wholeargstext as received ({}when absent ornull; a non-object isProtocol).index= dense arrival counter;id=functionCall.id, else the made-upbrisvox_call_<index>, which is recorded without anidand 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, ANDthoughtSignaturestrings, plus 32 bytes per part), SSE event cap (config).[DONE]lines, SSE comments, empty data and unknown fields are tolerated. - Ending:
Doneis emitted whenfinishReasonANDusageMetadatahave been seen (normally the same chunk), otherwise at the clean end of the body, a[DONE]line, a transport error after thefinishReason(all content was delivered; the signature must not be lost), orusage_grace(250 ms) after thefinishReasonwhen the body stays open (the last chunk may not carry usage: UNVERIFIED;Done.usageis thenNoneor the usage seen earlier). A body that ends or drops before anyfinishReasonisDisconnected.STOP->ToolCallsif a call was seen elseStop;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 asStop(logged); if it produced nothing, or already emitted a function call, it isError(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 callError(Protocol)naming the sanitised reason (never a silent empty success).promptFeedback.blockReasonwithout 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 logsRetry-After,error.statusand the firstErrorInfo.reason(sanitised), never the message, and never retries. An in-band{"error": {...}}chunk (shape UNVERIFIED) maps by its HTTP-likecode, else by the gRPCstatusname (RESOURCE_EXHAUSTED429,UNAVAILABLE503,INTERNAL500,DEADLINE_EXCEEDED504, ...); anything else isProtocolwith the sanitised status. - Deadlines (injected clock):
first_byte_timeout(connect -> headers,Core(Timeout)fromstream()),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, emptydata:heartbeats,{}, usage-only and part-less chunks do not count; consumer back-pressure is not charged),usage_grace(after afinishReasonwithout usage),ERROR_BODY_TIMEOUT(1 s, reading a refusal body),DRAIN_TIMEOUT(500 ms) withDRAIN_LIMIT(16 KiB) for the rest of the body after the finish, thewarm()HEAD (bounded byfirst_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 withoutDone),max_stream_duration. Dropping the stream hangs up at once (HTTP/1.1: TCP close; HTTP/2:RST_STREAM, connection kept).warm()= unauthenticatedHEADon 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,functionCallparts withid/args/thoughtSignature),Nonewhen there is no call and no signature. Replay: the recorded parts go back as themodelcontent; 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 fromToolCall, and when no part carries a signature the first call getsskip_thought_signature_validator(GeminiConfig::fallback_signature;Nonedisables; acceptance by the API: UNVERIFIED until a live run).replay_thought_signatures = falseignores provider data. The request key isthoughtSignature(camelCase);thought_signatureandfunction_callare accepted when reading. - Not implemented: explicit context caching (
cachedContent; whethersystemInstruction/toolsmay 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 theh2-crate mock intests/gemini/h2mock.rs. Over h2 the tests prove:HEADwarm-up and streams share one connection, 40 concurrent streams multiplex on one connection, dropping a stream sendsRST_STREAMand 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.rsruns the shared LLM contract suite against a loopback mock;tests/gemini/protocol.rspins the request on the wire, every message shape above, error mapping, timeouts, pooling, cancel, replay and secrecy;tests/gemini/review.rscovers 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.rsalso 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.rsis theBRISVOX_LIVE_TESTS=1+GEMINI_API_KEY(orGOOGLE_API_KEY) check, optionalGEMINI_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,
OBJECTwith empty properties, a leadingmodelcontent, Turkish, time to first token ofgemini-3.5-flash-litevsgemini-3.8-flashatlow) and DECIDE THE DEFAULT SCHEMA MODE from a real capture: the doc lists no keyword subset forparametersand namesparametersJsonSchemaas 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_STREAMon 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 inpartial_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) orX-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(headersAuthorization: Bearer <API_KEY>,Content-Type: application/json,Cartesia-Version: 2026-08-14; body{"grants": {"tts": true, "stt": true}, "expires_in": 60}; response fieldtoken), passed as WS query?access_token=<token>. Grantttsallows/tts/websocket(grants object also has optionalagent). - There is NO documented
api_keyquery parameter for WebSockets (api-conventions page). - Brisvox: server side, so use the
X-API-Keyheader; never log it (Error variants must not contain credentials).
- Server side: header
- 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-DDon the handshake, or query?cartesia_version=YYYY-MM-DD(query takes precedence; api-conventions). The WS reference listscartesia_versionas 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-01introduced structured JSON errors (older versions use a legacy plain-string error envelope). The auth page's WS example usescartesia_version=2026-03-01. Official Python SDK (cartesia-python, Apache-2.0; PyPI latest 4.2.0 as of 2026-10-08) sends the header2026-08-14by default. Recommendation: pincartesia_version=2026-08-14in 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 snapshotsonic-3.6-2026-08-27(never changes).sonic-preview= beta, not for production.- The WS schema enum for
model_idlistssonic-3.6,sonic-3.5,sonic-3,sonic-latest(the reference example uses"model_id": "sonic-latest"). sonic-3.5alias (GA May 2026, no day given; snapshotsonic-3.5-2026-05-04).sonic-3snapshots: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_idstring;transcriptstring (text chunk appended to the context's audio);voicestring voice id, or object with requiredid(embeddings no longer accepted since Jun 1, 2026);context_idstring.output_formatobject, all required:container=raw(only value);encodingone ofpcm_f32le,pcm_s16le,pcm_mulaw,pcm_alaw;sample_rateinteger one of8000, 16000, 22050, 24000, 44100, 48000. Keep voice and output_format identical across a context. Brisvox TtsConfig = PCM16 LE mono ->pcm_s16le; for telephonypcm_mulaw/pcm_alaw@ 8000 are available directly (UNVERIFIED whether any extra latency/quality difference vs resampling).language(e.g.tr) ORlocale(e.g.en-GB,tr-TR; locale needs sonic-3.6 or newer): never both (400 error).accentoptional (must be in the voice'saccents).normalization:auto(default) |off| language/locale code; reused on later requests in the same context if omitted.continueboolean, defaultfalse.true= more transcript will follow in this context.max_buffer_delay_msinteger 0..5000, default 3000.flushboolean.add_timestampsbool default false (word level).add_phoneme_timestampsbool default false.use_normalized_timestampsbool (true normalized, false original text).pronunciation_dict_idstring.generation_config:volume0.5-2.0 (default 1),speed0.6-1.5 (default 1),emotioninneutral|calm|angry|content|sad(omitted = model infers: UNVERIFIED).
- Across inputs on the same context every field except
transcript,continue(andduration) 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;typediscriminates. Examples (shapes per the WS reference; ids and some values illustrative):
(JSON reformatted onto fewer lines; the phoneme example values were not re-verified: UNVERIFIED.){"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"} - Field notes: chunk
data= base64 audio in the requestedoutput_format;step_time= server processing time for the chunk in ms (number);status_code206 on streaming messages (the docs call it "HTTP-like");donetrue on final message of a context;flush_idinteger 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 aflush_id, but the WS reference chunk schema has NOflush_idfield: UNVERIFIED on the wire (read it as optional, default 0/absent).error: requiredtype,title,message,status_code; optionaldone,error_code,doc_url,request_id,context_id.request_ididentifies the connection (api-errors: may be a UUID or a derived per-message string). The SDK exposes base64dataasaudiobytes - the wire field isdata. - Ordering: outputs for a context arrive in input order (audio for input A before B).
done: trueonly after all outputs for the context were returned. Interleaving oftimestampsvschunkmessages is not specified (UNVERIFIED). The adapter handlestimestampsbefore or after the chunk'sflush_done, within the limits written down in the implementation gotchas. Messages of different contexts interleave on the wire; route bycontext_id(UNVERIFIED wording; docs describe multiplexing).
- One WS connection multiplexes many contexts, each identified by a client-chosen
- 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
continuestays 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 freshcontext_idper utterance (ContextId -> string); never reuse one afterdone. - 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 finaldone. (Maps toTtsInput::Flush, i.e. no more text: synthesize remaining, thenTtsEvent::Flushedondone.) The contexts page explicitly documents an empty transcript withcontinue:falseto 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:"", samecontext_id. Server repliesflush_donewith an incrementingflush_id(first = 1); audio chunks carry theflush_idof 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 mentionflush_doneand does not say whetherflush_doneis 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 cancelledcontext_idclient-side (epoch/ContextId filter), (c) emitTtsEvent::Cancelleditself without waiting for the server, and (d) not reuse the id. Whether the server sends adone/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
cancelchannel must be polled with priority overinput(as per the trait docs).
- End an utterance: send the last text with
- Timestamps / interim results / tool-call deltas:
- Enable with
add_timestamps: true(words) /add_phoneme_timestamps: true. Message typetimestampswithword_timestamps: { words: [string], start: [number], end: [number] }(parallel arrays); phoneme variantphoneme_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 secondtimestampsmessage 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_timestampschooses 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).
- Enable with
- 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_limitedis 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 anerror_codefor WS errors, so the exact WS pairingerror_code: "concurrency_limited"+status_code: 429is 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_exceedederror 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_codemay be null and new values may appear: handle unknown ones.status_codeis "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 hasAuthenticationError): UNVERIFIED on the WS handshake (likely HTTP 401 before upgrade).
- Concurrency is counted per unique active
- Turkish support: YES. Sonic 3.6 supports 44 languages (changelog: "44 languages / 61 locales"),
includes Turkish (
tr, localetr-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"(orlocale, with 3.6+). Voice must support the language: a voice has anaccentslist; 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). Errorlanguage_not_supportedif 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 setmax_buffer_delay_ms: 0so 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 withcontinue: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 andcontinue:false. - Do not split decimal values (
1.0into1,.,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).
- Joined transcript chunks must form valid text (stream-inputs guide): keep spacing and
punctuation (
- Mapping to the brisvox trait (crates/brisvox-providers/src/tts.rs) - proposal:
ContextId(u64)->context_idstring (e.g.format!("{session}-{n}")), maintain a reverse map; drop messages whose context id is unknown/cancelled.TtsInput::Text-> generation request withcontinue:true; first request for a context carries all config fields, later ones repeat them unchanged.TtsInput::Flush->continue:false(empty transcript) andTtsEvent::Flushedwhendone:truearrives for that context. (Do not useflush:truefor this; that is the mid-context boundary.)chunk.database64-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-> oneTtsEvent::Wordper word withstartseconds ->Duration; the offset basis question above must be resolved first.cancelchannel -> send{"context_id":..,"cancel":true}, mark context cancelled locally, emitTtsEvent::Cancelled.- Server
errorwithcontext_idmay 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, featureprovider-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 mentionsAuthorization: Bearer; authenticate-your-client-applications lists both. Both accepted. - Docs page
docs.cartesia.ai/<ver>/asyncapi.ymlredirects 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-Keyheader marked sensitive, never in the URL.voiceis sent as the plain string id (the WS reference example), not as an object. languageis optional in the adapter (omitted when unset: server default);max_buffer_delay_msdefaults to 0,add_timestampsis always true,use_normalized_timestampsdefaults to false (words as written, for played-text matching), modelsonic-3.6. All fields excepttranscript,continueandflushare repeated unchanged on every request of a context.- One empty-transcript
flush: truerequest follows every text chunk ("flush marker",CartesiaConfig::flush_after_text, default on). Reason: withcontinue: truethe server sends nothing that says "this chunk is finished", so a silent server cannot be told from one waiting for more text. The marker'sflush_donelets 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_donearrives 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, setflush_after_text = false: then only the first response to a text chunk and the finaldoneare 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_doneacknowledgements; 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 oftimestampsvsflush_done(UNVERIFIED upstream, so both are handled): if a chunk'stimestampsarrive after its ownflush_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 notimestampsat all (no words) followed by a restarting server is shifted by the earlier chunk's start; offsets are monotonic, so the damage is bounded.timestampsbefore the audio need no special handling (only byte counts atflush_doneare used). A word time that is negative (clamped to 0), missing, NaN or absurd is clamped to the previous offset;words/startarrays 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 nextTextopens a new server context (new id) instead of sending to the old one; (2) the server answers input on an expired context with anerror(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 eachflush_done) is sent again, at most twice in a row; (3) the server sends an unsoliciteddone: if nothing is unanswered the server context is marked closed, the engine context lives on (noFlushed), the nextTextopens a new one. If thedonefinds a flush marker unanswered or text not yet confirmed (a soliciteddonealways follows theflush_doneof 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 falseFlushed. A server that keeps answering with such adoneand noflush_donein 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'sflush_doneis 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, soTtsEvent::Wordoffsets stay continuous across the pause (the trait requires this). AFlushfor a context whose server context is closed or expired reportsFlushedat once without sending anything. Messages from the old server context are dropped (routing is by the current server id). - Input closed: dropping
inputcloses every open server context (continue:false, empty transcript), waits for thedones (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 thedoneends the session withTimeoutafterio_timeout), delivers what is left and endsevents;Flushedis 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.
Cancelledis 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::Flushsendscontinue:falsewith an empty transcript;Flushedis emitted on the server'sdone(or any message withdone: true) for a context the engine closed. Adonethe engine did not ask for only closes the server context (noFlushed, see context expiry).Flushfor a context with nothing sent yieldsFlushedimmediately;Textafter theFlushof the same context is ignored (the server context is closed); aContextIdused again afterFlushedstarts 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,
Cancelledis queued first. Barge-in latency does not depend on the server. QueuedText/Flushfor a cancelled id are ignored (last 64 ids remembered); a cancel for an unknown context still yieldsCancelled. Messages are routed in two steps: first onlytypeandcontext_idare 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 servererrormessage for a live context ends the session (the trait'sErrorevent 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_code401/403/408/429/5xx ->Rejected, anything else (model/voice/language problems) ->Protocolwitherror_code, title and message. An error for a cancelled or unknown context is ignored; an error without acontext_idis treated as about the connection. Unparsable text, bad base64 of a live context and binary frames areProtocol. A close or reset while output is owed isDisconnected. 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 withconcurrency_limitedon 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, afterconcurrency_retry_delayx 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 asRejected 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
Textreconnects lazily with its own connection (one retry if the socket turns out dead on that first write); it does NOT take the socket thatwarm()parked for the nextconnect.warm()parks one socket (no task, no keepalive);connecttakes it if it is younger thanwarm_max_age(60 s), still open and the provider'sshutdowntoken is not cancelled, otherwise connects. A cancelled token releases the parked socket the next timewarmorconnectruns (no task watches it while it waits). - Transport:
ws://is accepted only for loopback hosts (tests, a local proxy) unlessallow_insecure_wsis set, because the API key travels in the handshake header; anything other thanwss:///ws://is refused atCartesiaTts::new. - Odd-sized PCM16 chunks are re-aligned across chunk boundaries so every
Audioevent holds whole samples.pcm_mulaw/pcm_alaware available for 8 kHz sessions only; theAudiobytes 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.
- Version: sent as the query
- Still UNVERIFIED on the live service (see the open items below): everything marked ASSUMED
above, the timestamp time base and the order of
timestampsvsflush_done,voiceas plain string withsonic-3.6, the empty-transcript close, whetherflush_donecarriesflush_idsemantics as documented, the concurrency error shape on the socket, what the server does with input on an expired context (error vs ignore vsdone; a silent ignore ends in the stall timeout ofio_timeout, because the adapter then waits for a marker answer), and whether a cancelled but still generating context counts against concurrency.
- Doc inconsistency noted: the WS reference lists the header
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).
Flushedarrived after all audio and all 11 words of the utterance.- Barge-in:
TtsEvent::Cancelledis 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-Keyheader withcartesia_version=2026-08-14in the query,voiceas a plain string id, modelsonic-3.6,add_timestamps, manualflush,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).
- Timestamp time base (per message vs cumulative per context) and whether it spans
continuechunks; relation to total audio duration. - What the server sends after a
cancel(nothing /done/ audio tail) and how much audio still arrives for an already-generating request. - Keepalive: does a WS ping frame reset the 5 min idle timer; what close code/reason is used.
- Max contexts per connection; behavior when a second context starts while the first still generates (ordering across contexts).
- Time to first audio with
max_buffer_delay_ms:0vs default, withpcm_s16le24000/16000/8000. - Turkish voice ids and whether timestamps are produced for
tr. - Whether
flush_donearrives after all chunks of the preceding transcript. - Whether chunk messages really carry
flush_idon the wire; empty-transcript behavior. - 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 tunecontext_expiryand the error classification insession.rs::on_error. - 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'dais read digit by digit (bir dört üç sıfır da),250 lirais read correctly (iki yüz elli). With the repo normaliser (on dört otuzda) the reading is right (WER difference is only the spacingotuz 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 querysingle_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-streamxi-api-keyandauthorizationfields ("only needed if not already provided in the header"); multi-contextxi_api_keyandauthorizationfields (apply to that context's (re)initialisation). The realtime-tts guide Python example sendsxi_api_keyin the first message (underscore), the API reference for the single-stream first message names the fieldxi-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.
- Handshake header
- 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_idpath 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 onapi.elevenlabs.io, check thex-regionresponse header, andapi.us.elevenlabs.iopins 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:
Param Type / default Notes model_idstring, default eleven_multilingual_v2Set it explicitly (see Models) output_formatspec type "Any type", no default, no enum on the WS pages Values 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.audiodescription: "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 itauto_modeboolean, default falseNo description in the spec. Latency guide: "Setting auto_modeto 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_modeattempts to find a good balance automatically by analysing the incoming text". Exact algorithm and whether it overrideschunk_length_schedule: UNVERIFIEDinactivity_timeoutinteger, default 20, max180(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 pages HTTP 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 ofon: not found, UNVERIFIEDlanguage_codestring No 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..4294967295 Determinism not described authorization,single_use_tokenstring See Auth -
output_formatallowed values (HTTP convert page, default theremp3_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_192needs Creator or above;pcm_44100andwav_44100need Pro or above. The page callsulaw_8000"commonly used for Twilio audio inputs". Brisvox TtsConfig = PCM16 LE mono at 8k/16k/24k ->pcm_16000,pcm_24000(andpcm_8000,ulaw_8000for telephony). The docs say only "PCM" (not "s16le", not endianness): thatpcm_*is signed 16-bit little-endian mono raw samples, with no WAV header, is UNVERIFIED and must be checked in the live test.ulaw_8000bytes 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) andeleven_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_v2are 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_v3oreleven_v4(realtime-tts guide: "That endpoint does not support theeleven_v3oreleven_v4models"; 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 addresseleven_v3at all (its examples useeleven_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) andeleven_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: endpointwss://api.elevenlabs.io/v1/text-to-dialogue/stream-input,model_idmust start witheleven_v3oreleven_v4, responses use snake_case (e.g.is_final), the first message carries avoicesarray,eleven_v4_turboallows exactly one registered voice andeleven_v4up 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. Whethereleven_v3_conversationalis 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. Whethereleven_v4_turboalso 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).
- Low-latency, usable on the TTS WebSocket:
- 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 callsis_final(Pythondata.get("is_final"), JSdata.is_final); the API reference schema saysisFinal, and the single-stream guide's Python example usesdata.get('isFinal'). Parse both (UNVERIFIED which one is on the wire for multi-context; the AsyncAPI schemaisFinalis treated as authoritative). - Single-context endpoint (
stream-input), client -> server (specV1TextToSpeechVoiceIdStreamInputPublish):- InitializeConnection (first message): required
text=" "(single space). Optionalvoice_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" } - SendText: required
text("Should always end with a single space string"); optionaltry_trigger_generation(bool, default false: "attempt to immediately trigger the generation of audio, overriding thechunk_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}. - 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).
- InitializeConnection (first message): required
voice_settings(RealtimeVoiceSettings):stability0.5,similarity_boost0.75,style0 (V2+ models only),use_speaker_boosttrue (V2+ only),speed1 (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: withoutauto_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 bycontext_id; fields from the spec, example JSON from the multi-context guide):Message Fields Example (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), optionaltextappended to the buffer before flushing{"context_id": "greeting", "flush": true}(guide: "use theflush: trueflag 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) andcontext_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 initialiseContextas a JSON field:InitialiseContextisthe spec's schema name for "text with a context_id (new or re-init)". The first message with a new context_idcreates the context (guide); no separate create message is needed. - Server -> client, single context (
stream-input):AudioOutput:audio(string, required, base64 partial audio in the selectedoutput_format),alignment(optional),normalizedAlignment(optional).FinalOutput:isFinal: true(always true; "Indicates if the generation is complete. If set toTrue,audiowill be null.").
- Server -> client, multi-context:
AudioOutputMulti:audio(required, "Base64 encoded audio chunk"),contextId,alignment(nullable),normalizedAlignment(nullable);FinalOutputMulti:isFinal: trueandcontextId("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):
Whether{"audio":"<base64>","alignment":{"chars":["H","i"," "],"charStartTimesMs":[0,60,120],"charDurationsMs":[60,60,40]},"normalizedAlignment":{...},"contextId":"greeting"} {"isFinal":true,"contextId":"greeting"}audiomay be the empty string or null on alignment-only messages, whetheralignmentisnullor absent when not produced, and whether a message can carry bothaudioandisFinal: UNVERIFIED (spec saysaudiois 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
isFinalis sent (UNVERIFIED; the multi-context guide explicitly does not state whether a final message follows a flush): the single-stream guide treatsisFinalas the end of the whole stream (Python example stops listening on it), andflush: truekeeps the socket open. For multi-context the signal that all audio of a flushed segment has arrived is therefore UNVERIFIED; there is noflush_done-like message in the spec. Brisvox plan: end a bot turn withclose_context(or flush- close_context) and take
isFinalfor thatcontextIdasTtsEvent::Flushed(to be confirmed live; see open items).
- close_context) and take
- 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).
- Naming warning: client messages use snake_case (
- 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=180in 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 appliesio_timeout.
- 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
- Finalize / flush / cancel semantics:
- Flush:
flush: true(with text, or with onlycontext_idin 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 withflush: true, thenclose_contextfor that context; emitFlushedonisFinalfor thatcontextId. UNVERIFIED thatisFinalis 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 owncontext_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 afterclose_context; whether audio continues to arrive afterclose_contextis sent is not covered), (c) anisFinalfollows. CRITICAL for barge-in: do not rely on the server. Brisvox must (1) sendclose_contextfor the cancelled context, WITHOUT a preceding flush, (2) drop every later message for thatcontextIdclient-side immediately (turn-epoch / ContextId filter), (3) emitTtsEvent::Cancelleditself, (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
flushboundaries is not documented. UNVERIFIED.
- Flush:
- Timestamps / interim results / tool-call deltas:
- Alignment objects on audio messages (both endpoints):
alignment(input text) andnormalizedAlignment(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 groupingcharsbetween spaces (the realtime-tts guide says "You can usealignmentto 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 emptyisFinal), and the statement above holds only withsync_alignment=false(the adapter's default, the server default). Withsync_alignment=truethe 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]; withfalse: [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 thepcm_24000byte count (2 bytes/sample at 24 kHz) against a last character start of 2.8 s, so thepcm_*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; fixturesSYNC_RECORDEDandCHUNK_RECORDEDin crates/brisvox-providers/tests/elevenlabs.rs).normalizedAlignmenthad one more character thanalignmentin 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 unsolicitedisFinalis not measured (assumed, as the id is a new context). - Which text is spoken: use
normalizedAlignmentwhen present for timing of what was actually spoken, but played-text truncation matches against the LLM text, which corresponds toalignment(input text). Choosealignmentfor matching; handlenormalizedAlignmentbeing the only one present. Whether chars ofalignmentcan map 1:1 onto the original text (leading spaces, SSML, trailing-space convention): UNVERIFIED. - No interim results or tool-call deltas apply (TTS).
- Alignment objects on audio messages (both endpoints):
- Concurrency / rate limits:
-
Plan concurrency (help-center TTS page and the rate-limiting blog agree), per model family:
Plan Flash and Turbo models All other models (e.g. Multilingual v2) Free 4 2 Starter 6 3 Creator 10 5 Pro 20 10 Scale 30 15 Business 30 15 Enterprise custom (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
coderate_limit_exceededorconcurrent_limit_exceeded;system_busy(typerate_limit_error, 429): "The system is currently busy. Try again later." Response headerscurrent-concurrent-requestsandmaximum-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 mentiontoo_many_concurrent_requests(not re-checked); the official errors page usesconcurrent_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_contextfinished 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, typepayment_required) exists on the errors page; WS shape UNVERIFIED.
-
- Errors:
- HTTP error body (errors page): JSON object with
detailcontainingtype,code,message,status(legacy, no longer used),request_id,param; types/codes seen:authentication_errortype (codesinvalid_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/isFinaland contains anerror/message/detailkey leniently as an error (log shape), map handshake HTTP failures toRejected { status }, unexpected close while output is owed toDisconnected, and record the real shapes from the live test back into this file.
- HTTP error body (errors page): JSON object with
- Turkish support: YES on
eleven_flash_v2_5andeleven_multilingual_v2(models page: multilingual v2 language list includestr, and the models table marks Turkish for both; Flash v2.5 = "Alleleven_multilingual_v2languages plus:hu,no,vi" = 32 languages).eleven_flash_v2is English only (en), so it is unusable for Turkish.eleven_v4_turboandeleven_v3_conversationalalso 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_codecan force it on models that support it (ignored otherwise; not supported for multilingual_v2 per the HTTP page). Whetherlanguage_code=tris 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; useapply_text_normalizationexperiments 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 smallchunk_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) withflush: trueon each segment so audio is generated promptly (realtime-tts guide: useflush: true"along with the text at the end of conversation turn" andflush: 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.
- Default behaviour waits for 120 characters before first audio, which is bad for first-audio
latency. Options:
- Mapping to the brisvox trait (crates/brisvox-providers/src/tts.rs) - proposal:
- Use
multi-stream-input, one socket perconnect(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 orpcm_22050/pcm_44100etc., 44.1 kHz needs Pro). ContextId(u64)->context_idstring; maintain a reverse map; drop messages whosecontextIdis 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 plainInitialiseContextmessage with a realcontext_idper utterance may avoid this.)TtsInput::Text->{"text": "<text> ", "context_id": id}(first one withvoice_settings), optionallyflush: trueper segment.TtsInput::Flush->{"context_id": id, "flush": true}then{"context_id": id, "close_context": true};FlushedonisFinalfor that id (UNVERIFIED).audiobase64-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::Wordwith start = chunk byte offset +charStartTimesMsof the word's first char (time base above; word grouping on spaces; handlenullalignment and empty arrays). cancelchannel -> sendclose_context, forget the context locally, queueCancelledfirst; must be polled with priority overinput(as per the trait docs).
- Use
- Official SDKs / licenses: ElevenLabs Python SDK
elevenlabs/elevenlabs-pythonis 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-sdk0.1.0 (docs.rs; Apache-2.0; repolongcipher/elevenlabs-sdk-rs, crates.io owner Akagi201; describes "WebSocket streaming" incl. TTS input-streaming) andelevenlabs_rs0.7.1 (released 2026-07-27, reporwxbytes/elevenlabs_rs, "An unofficial Rust crate for ElevenLabs", optionalwsfeature 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;normalizedAlignmentonly 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 aTimeoutafterio_timeout.ElevenLabsConfig::completion_by_alignment = falsetimes only the first response after each text instead. Confirm live (open items 1 and 2). Flushedis delivered onisFinalfor the context afterflush+close_context. If noisFinalfollows (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 yieldFlushedearly and drop the rest of the audio).- Every text frame carries
flush: true(and ends in a space); notry_trigger_generation, noauto_mode.chunk_length_scheduleis 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 andchunk_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()needsElevenLabsConfig::warm_target = (voice, rate); without itwarm()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=truethe origin is the audio delivered before the server context opened. Words may span messages. - Unsolicited
isFinalwhile nothing is owed closes the server context (the adapter also sendsclose_contextfor 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 sendisFinalper 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 aTimeout(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 aTimeout. A mixed chunk (Hi 😀) is sent whole; the emoji is then an alignment shortfall, which is tolerated (see above). - After the
Flushedof 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'sisFinalhad 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 withFlushedonce. - 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/normalizedAlignmentwith more than 16384 entries ends the session withProtocol. Without the caps one message could queue millions ofWordevents (and reserve a 12 MiB decode buffer) for every call in the container. The alignment characters are parsed as borrowed strings (oneVecper array, noStringper 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
isFinalthe oldest of those, which then gets itsFlushedat 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.
- 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
Open items to settle with a live test
- Time base and sync of
alignment: SETTLED 2026-10-13 (per message whensync_alignment=false, from the server context start whentrue; alignment and audio arrive in the same message; see the Time base entry). Still open: whether alignment arrives for Turkish;alignmentvsnormalizedAlignmentpresence whenapply_text_normalizationisauto/on/off; the sync clock after a server context restart. isFinalsemantics (HIGH RISK: an unsolicited one while text is owed is ignored, see the gotchas): afterflush, afterclose_context, afterclose_socket; is it per context; key spelling (isFinalvsis_final); is audio still sent afterclose_context(and how much); message order of final audio vsisFinal.- Behaviour of
try_trigger_generation,auto_mode, andchunk_length_schedulein multi-context; time to first audio foreleven_flash_v2_5from Turkey/Europe withpcm_16000/pcm_24000/ulaw_8000and withauto_modeon/off. - Exact
output_formatsupport on the WS (pcm_*encoding, header,ulaw_8000), default when omitted. - 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.
- Whether a keepalive message resets the socket-level or only the context timer; whether the
initial
{"text":" "}creates a counted default context. - Whether many
flush: truecalls in one context hurt prosody; effect on billing. - Turkish:
language_code=treffect, text normalization of numbers/dates, a good Turkish voice id. - 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_alignmentdefault false, no description;output_formatuntyped) - 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_24000is 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'dais 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 sendsClear. -
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. -
Flushis 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 exactlyAuthorization: Token YOUR_DEEPGRAM_API_KEY; the "Sending LLM outputs to a WebSocket" page shows a raw Python WebSocket client against/v1/speakpassingadditional_headers={"Authorization": f"Token {DEFAULT_TOKEN}"}. The speak-streaming reference only lists "anAuthorizationheader" (type string) with no prefix. - Temporary/short-lived tokens exist (Auth API,
/reference/auth/tokens/grant); whether they are accepted on the/v1/speakWebSocket, 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) anddg-error(only on a failed upgrade; detailed reason, also in the response body) (TTS troubleshooting page). HTTP error table (errors page): 401INVALID_AUTH("Invalid credentials."); 401INSUFFICIENT_PERMISSIONS("User does not have sufficient permissions."); 403INSUFFICIENT_PERMISSIONS("Project does not have access to the requested model."). Map these toRejected { status }. That these exact bodies occur on the WS upgrade (rather than only on REST) is UNVERIFIED.
- HTTP handshake header
-
Endpoint(s) / regions:
-
wss://api.deepgram.com/v1/speak(AsyncAPI serverwss://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):
Name Type Allowed values Default modelstring enum of Aura-1, Aura-2 English and Aura-2 multilingual voices (see Models) aura-asteria-enencodingstring linear16,mulaw,alaw(streaming only; compressed formats are REST-only)linear16sample_ratestring 8000,16000,24000,32000,48000(per encoding, see below)24000speednumber 0.7 .. 1.5 ("Not yet supported in all languages") 1mip_opt_outany schema type "Any type" false -
Encoding/sample-rate combinations (media-output-settings page, "Audio Format Combinations"):
linear168000, 16000, 24000 (default), 32000, 48000;mulaw8000 (default), 16000;alaw8000 (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=nonefor streaming to an output device (clicks/static), butcontaineris not a documented query parameter of the streaming reference: whether it is accepted/ignored/rejected on/v1/speakis UNVERIFIED; do not send it. -
Brisvox
TtsConfig= PCM16 LE mono at aSampleRate:encoding=linear16&sample_rate=<rate>(8000/16000/24000/32000/48000 available). For G.711 telephonymulaw/alawat 8000 are available directly; the events then carry G.711 bytes, not PCM16 (caller must know). Whether a rate the brisvoxSampleRatetype 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 writesflux-{voice}-{language}), Aura-2aura-2-{voice}-{language}(/v1/speak), Auraaura-{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 inTtsConfig.voiceis the model string (it carries voice and language together); voice and output settings cannot change after connecting ("one WebSocket per conversation", getting-started page).
- 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
-
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: requiredtype,text. Behavior ofSpeakwithout aFlush(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 consecutiveSpeaktexts 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):
(JSON reformatted onto fewer lines; the angle-bracket values are the schema's placeholders.{"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>"}Metadatarequirestype,request_id,model_name,model_version,model_uuid;additional_model_uuidsoptional.Flushed/Clearedrequiretype,sequence_id(integer).Warningrequirestype,description,code.) Binary audio: "Receive audio chunks as they are generated", no JSON wrapper. Schema quirk: the spec'stypeenum forFlushedandClearedlists both values; use the per-message values. sequence_idsemantics (a counter of flushes/clears? per connection? starts at 0?): only0appears in examples; meaning is not documented: UNVERIFIED. Do not rely on it for routing; treat as opaque and log it.- When
Metadatais sent: the migration page says v1 "sendsMetadata" on open (v2 sendsConnected); exact timing relative to the first audio is UNVERIFIED. Handle it at any time and ignore unknown message types. Warningcodevalues: 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
Flushthe server processes the remaining text, returns the final audio, and confirms with aFlushedmessage (Flush page; the exact "It confirms with" wording was not re-found verbatim). Read as: all audio for text sent before the flush arrives beforeFlushed. A strict "all binary frames of that text precedeFlushed" guarantee is not worded as a guarantee: UNVERIFIED (verify live; the adapter should fail safe by treatingFlushedas the end marker and discarding audio that arrives after it only if a laterFlush/Cleardemands it). - After
Clearthe server "will stop sending new audio chunks as soon as possible"; it "will process all remaining audio data" before it sendsCleared(Clear page). So audio frames CAN still arrive between sendingClearand receivingCleared. - 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).
- After
- 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 describesCloseas "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 useFlushand wait forFlushed; useCloseonly 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
KeepAlivemessage exists in the v1 speak schema and none is mentioned (reference: KeepAlive "not mentioned"). Idle timeout for/v1/speak: not documented. A close code1011 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 lastSpeak. The docs state: "It is required that the last fragment of speech beFlushed when the LLM is at the end of the LLM response" (send-LLM-outputs page). Server returns remaining audio, thenFlushed. EmitTtsEvent::FlushedwhenFlushedarrives. - Flush limits: at most 20
Flushmessages 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 yieldsFlushed: the adapter must not wait forever (stall timeout) and should count its own flushes (rate guard). "Very frequent flushes can affect audio output quality." WhetherClearcounts 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 repliesClearedafter 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) sendClear, (b) from that moment drop every binary frame andFlushedthat arrives for the cancelled utterance (the consumer already treats the epoch as dead), (c) emitTtsEvent::Cancelleditself immediately without waiting forCleared, (d) resume mapping new audio to the next utterance only afterClearedhas arrived (frames beforeClearedare 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 preferClear). Text sent for the NEXT utterance afterClearbut beforeCleared: whether it is kept or cleared is UNVERIFIED; hold the next utterance's text untilClearedarrives (costs one round trip, avoids the ambiguity). Clearwhen nothing is queued: behavior not documented (presumably stillCleared): 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).
- End of an utterance (maps to
-
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,Warningonly, andMetadatahas no timing data (reference page, checked).TtsEvent::Wordtherefore cannot come from the provider. Played-text options (design choices, not doc claims): (1) emit noWordevents 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) atFlushed, 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_msper turn,audio_played_mson interrupt, andtotal_audio_duration_ms), but it reconciles interruptions server-side (text_spoken).
- Word, character or any timestamps are NOT offered on
-
Concurrency / rate limits (api-rate-limits page, "Rate limits apply per project, not per account or API key"):
Plan REST (Aura / Aura-2) Streaming (Aura / Aura-2) Pay as You Go 15 45 Growth 15 60 in North America, 45 in Europe/Australia/India Enterprise starting at 25 Aura: 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 toRejected { 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
Speakmessage 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 has1009 BIG-0001"The input text has too many characters" and1009 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 singleSpeaktext to <= ~1000 characters at sentence/space boundaries (a design choice). The 2400 chars/min throttle is believed to be the cause of1008 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 code Payload Meaning 1003 MESSAGE-0000 message type not supported (send only text messages) 1008 DATA-0000 message is not a recognized command 1008 DATA-0001 too many characters submitted within a recent time window 1009 BIG-0000 message too large 1009 BIG-0001 text exceeds the character limit 1011 NET-0000 internal server error (retry) 1011 NET-0001 the server failed to receive a message 1011 NET-0002 server failed to send a message to the client 1011 NET-0003 time limit exceeded Map: 1008/1009/1003 ->
Protocol(with code and payload; never credentials); 1011 NET-0000/0002 -> retryableDisconnected; 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'sWarningis 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 includestr; 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, headerAuthorization: Token ...(Flux quickstart). Params:model(required,flux-*),encoding(linear16default,mulaw,alaw),sample_rate(linear16 8000, 16000, 24000, 32000, 44100, 48000; mulaw/alaw 8000 or 16000),speed0.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), allflux-{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; codesMESSAGE-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 (theInterruptround trip is for context reconciliation), and frames arriving between sendingInterruptandSpeechInterrupted"were already on the wire. Drop them." - GA/beta status of Flux TTS: not stated on the fetched pages (only
expressivityand 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; querymodel=<TtsConfig.voice>&encoding=linear16&sample_rate=<TtsConfig.sample_rate>; headerAuthorization: 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 awaitingFlushed.Text{ctx}->Speak(if a different context's text arrives while another is unflushed, queue it until that context'sFlushed; 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 countingFlusheds; 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 untilCleared; a cancel for an already-flushed (finished) or unknown context still yieldsCancelled; 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
Wordevents 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 (errorProtocolor merge flushes); stall deadline only while aFlushedis owed. - Reconnect: socket closed while idle -> lazy reconnect on next
Text; 60 min limit -> same.
- One connection per
-
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;
Clearon cancel with local-firstCancelled, frames andFlusheddropped untilCleared, the next context's text held until then; aClearedthat does not arrive withinio_timeoutof the last frame is given up on with a warning (UNVERIFIED #2 above: if the server does not answer aClearon an empty buffer this costs one deadline once; if it answers late, a staleClearedcan be matched to a laterClear). AClearis only sent when the server may hold text (written on the current connection, unflushed or aFlushedowed), so the empty-buffer case is not provoked by a cancel during a reconnect or of an answered context. AFlushedthat followsCleareddirectly (ordering UNVERIFIED, open item 1) would be taken for the answer to the next utterance's flush if that was already sent. Metadatamay 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 countMetadata. - Not implemented / left out:
mip_opt_out; Flux TTS (/v2/speak);Closeis sent only at the end of a session after the input was dropped (the Flush-vs-halt ambiguity ofClosedoes not matter there because everything is flushed first); no keepalive message (no documented idle timeout); no JWT/Bearer auth. A WebSocketPingis sent only as a liveness probe while unflushed text is on the server and nothing is owed (half anio_timeoutafter 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 thanio_timeoutbetween the first text and the flush ends the session with aTimeout: switch toPerText. - Guessed around (UNVERIFIED in the docs): whether
SpeakwithoutFlushstarts generation (both are handled: the mock has anautoswitch andFlushPolicypicks the strategy), whetherFlushedalways 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), whetherClearcounts 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 isError::Protocoleven while idle (it means we sent something wrong); 1011 and every other close areDisconnected, 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-TRis 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 intests/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 discardSpeaktext that arrives betweenClearandCleared(the docs allow it); the barge-in tests send the next utterance immediately and still get complete audio, so the hold untilClearedis proven, not assumed. A connection lost while aClearis pending resets that state (the next utterance does not wait for the stall deadline).- Dead socket: the first
Speakof 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. Theexactly one retrycheck 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 perSpeak, 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). PerTextafterFlushed: a pause far aboveio_timeoutis neither a timeout nor a ping (nothing is owed); thePerTurnpause 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.
- The mock-free reading of the docs is implemented as: one FIFO of engine contexts, only the head on the wire;
Open items to settle with a live test
Run with BRISVOX_LIVE_TESTS=1 DEEPGRAM_API_KEY=... (never print the key).
- Whether all binary frames of a flush precede
Flushed, and whethersequence_idcounts flushes/clears (per connection or per kind). - What
Cleardoes to text sent betweenClearandCleared; how much audio arrives afterClear; whetherClearwith an empty buffer still answersCleared. - When the server starts generating without
Flush(sentence-boundary auto-trigger?), and whetherSpeaktext needs leading spaces. - Idle timeout of
/v1/speakand whether a ping frame resets it; which close code/payload the 60 min limit uses. - The warning
codethe server sends when the 20 flushes/60 s limit is hit, and whether the connection survives; whetherClearcounts. - 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). - Time to first audio with
linear16at 16000/24000 andmulaw8000; whether frames are whole-sample aligned. - Whether
container=noneor unknown query params are accepted/ignored/rejected; whether a JWT (Bearer) is accepted on/v1/speak. - Whether concurrency (429) is counted per connection or per active generation.
- 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: Tokenheader 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: Tokenscheme (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_urlcarries the/v1prefix. - 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.mdsection 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
| Server | Route and fields | pcm? | Streams? | Sample rate | Errors | Cancel |
|---|---|---|---|---|---|---|
| Kokoro-FastAPI | POST /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_options | yes, media type audio/pcm | yes: StreamingResponse, headers Transfer-Encoding: chunked, X-Accel-Buffering: no, Cache-Control: no-cache; one body chunk per generated audio chunk, empty ones skipped | 24 kHz (StreamingAudioWriter(.., sample_rate=24000)); README's PCM example: 16-bit mono 24000 Hz | before 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 change | stream_audio_chunks checks client_request.is_disconnected before each chunk and stops; generators are closed |
| Speaches | POST /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/pcm | always a StreamingResponse | model default unless sample_rate is sent (the adapter sends the session rate) | 422 for unsupported speed; others not shown | not 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 buffer | from 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) | yes | yes (card) | 24 kHz | not read | not 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.
| Preset | model | format | other |
|---|---|---|---|
generic | required | pcm | nothing extra |
kokoro | kokoro | pcm | stream: true; example voice af_heart |
chatterbox | chatterbox | wav | header read, rate checked; one whole segment per response |
speaches | speaches-ai/Kokoro-82M-v1.0-ONNX | pcm | sample_rate = session rate (any rate) |
vllm_omni | mistralai/Voxtral-4B-TTS-2603 | pcm | weights 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 unauthenticatedHEADthat parks a connection in it. A refusal or an unreachable server therefore shows up asTtsEvent::Errorafter the first text, and the session ends (the engine reconnects at its next round). To let the contract suite judge that,TtsHarnessgotfaults_at_connect()(defaulttrue); this adapter saysfalse, 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
Textand aFlushper 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 tocoalesce_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.Flushis 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
Audioevents as they arrive, cut to whole samples (the one trailing byte of an odd chunk waits for the next; a copy only then).pcmand awavdata section are PCM16 mono; rate must be 24000 (or the Speaches preset'ssample_rate):connectrefuses another, the WAV header's rate is checked against the session, and the server config rejectstts.sample_rate != 24000at load. The engine resamples to the call. -
Words. None requested: no
Wordevents, 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_speechreturns 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?JSONStreamingResponseis in a module not read), nor theWordTimestampfields, and the route is under/dev. Left as a follow-up that needs a live server: then addformat = "captioned"parsing base64 audio and mapping timestamps ontoWord. -
Cancel. Served before everything (also during send and body read): the context's queued text and undelivered events are dropped,
Cancelledis 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 isProtocol. -
Timeouts (injected clock):
connect_timeout3 s;first_audio_timeout15 s from sending to the first audio byte (a CPU Kokoro needs seconds; Chatterbox answers only after synthesizing the whole segment: raise it);idle_timeout8 s between chunks;max_request_duration120 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://orallow_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 ismin(connect_timeout, 400 ms), tts.rswarm_provider), one tinyPOSTwithwarm_textwhose audio is drained and dropped.connectstarts the same warm-up (the first call of a process has no voice atwarm()time). One is in flight at most; the next starts only afterwarm_interval(default 60 s, injected clock, one shared state per provider, so many calls do not each fire one); it is bounded bywarm_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, elsewarm_voice. Default on for thekokoropreset ("Hi.", voiceaf_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 withwarm_textand a longwarm_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
- All wire shapes above were read from source summaries, none observed: confirm
pcmbytes are headerless 24 kHz on Kokoro-FastAPIlatest-cu128. - 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.
- Chatterbox-TTS-Server: the output sample rate, the real voice names (
GET /v1/audio/voices), Turkish selection throughlanguage(the server mapping sends the TTS language's primary tag, e.g.tr, for thechatterboxpreset only; the accepted codes are unconfirmed), and its time per segment; its/ttsroute withstream: true(chunk-level WAV) might beat/v1/audio/speechand is not used. - Speaches: PCM rate with
sample_ratesent, auth, Blackwell build. - Word timestamps through Kokoro's captioned route (see "Words").
- 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.pyraw.githubusercontent.com/remsky/Kokoro-FastAPI/master/api/src/routers/development.pyraw.githubusercontent.com/remsky/Kokoro-FastAPI/master/api/src/structures/schemas.pyraw.githubusercontent.com/remsky/Kokoro-FastAPI/master/README.mdraw.githubusercontent.com/devnen/Chatterbox-TTS-Server/main/server.pygithub.com/devnen/Chatterbox-TTS-Server(README; the page was truncated before the/v1/audio/speechsection, so the route facts come fromserver.py)raw.githubusercontent.com/speaches-ai/speaches/master/src/speaches/routers/speech.pydocs/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):
| state | first audio |
|---|---|
| after 420 s idle, no warm-up | 524 ms, 464 ms |
| right after, same request again | 56 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-up | 687, 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.hfor the defaults), result JSON insherpa-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.py | C++ sherpa-onnx-online-websocket-server | |
|---|---|---|
| client -> server audio | binary 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 audio | text Done | text Done |
| result message | text 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 |
| endpoint | none 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 + 1 | is_final: true on the message that carries the endpointed text; the stream is reset |
after Done | the 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 connection | end-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 503 | none 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_ratewhen the rates differ (brisvox_audio::StreamResampler, 1–2 us per 20 ms frame,docs/LATENCY.md) and converted tofloat32LE in a reused buffer (frames are cut off the front of oneBytesMut; 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
Doneexchange is open the hypotheses are not forwarded: the answer carries the text. - Server endpoint = final. Python: a result with a higher
segmentends the previous segment, whose last hypothesis becomes a final transcript withspeech_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++: theis_finalmessage is the final. Both flavours are told apart by the presence ofis_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 inwsstt(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:
Done, then a new connection (implemented).FinalizesendsDone. The server's last result is the answer (from_finalize,speech_final), delivered when the server closes (Python) or marksis_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. AFinalizeon a connection that has heard no audio is answered empty without a round trip. FurtherFinalizerequests queue and are answered in order.- 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 aFinalizecould 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). - 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
Doneon a stream that is already at an endpoint, and whether it closes the socket afterDone!(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).
| utterance | words | WER 16 kHz | finalize ms | WER 8 kHz mu-law | finalize ms |
|---|---|---|---|---|---|
| tr_two_utterances #0 | 11 | 0.000 | 24.5 | 0.182 | 30.5 |
| tr_two_utterances #1 | 15 | 0.000 | 21.8 | 0.000 | 24.2 |
| tr_long_0 #0 | 11 | 0.000 | 25.4 | 0.091 | 20.5 |
| tr_long_0 #1 | 15 | 0.000 | 19.0 | 0.000 | 27.1 |
| tr_long_0 #2 | 13 | 0.154 | 25.0 | 0.154 | 23.7 |
| tr_long_1 #0 | 11 | 0.000 | 15.4 | 0.000 | 15.9 |
| tr_long_1 #1 | 14 | 0.143 | 24.0 | 0.143 | 24.9 |
| tr_long_1 #2 | 18 | 0.111 | 25.7 | 0.444 | 14.1 |
| total / mean | 108 | 0.056 | 22.6 | 0.139 | 22.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 asHELLOat every trailing length; "Hi there." (0.97 s) came back asHIE. 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 CHin some runs,HIEfor "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
| OpenAI | NVIDIA Speech NIM | Speaches | vLLM | |
|---|---|---|---|---|
| URL | wss://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 1008 | ws://<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 |
| auth | Authorization: Bearer <key>; no beta header | none 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/403 | none |
| audio in | input_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 append | same event; the base64 is read as 24 kHz and resampled to 16 kHz server side (source) | same event, base64 PCM16 16 kHz mono |
| session message | session.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 VAD | none (null) | the page documents vad_states in results and zero-default endpointing_config; whether server endpointing runs and can be disabled: UNVERIFIED | on 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 refused | none |
| interims | conversation.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 turn | transcription.done {text, usage} |
| commit | input_audio_buffer.commit = "end of the audio turn" with client VAD; input_audio_buffer.committed may be returned | input_audio_buffer.commit sends the buffer for inference; input_audio_buffer.committed {previous_item_id, item_id}; input_audio_buffer.done = no more audio | input_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.failed | same shape (invalid_request_error, server_error) | {"type":"error","error":"<message>","code":"<code>"} (string message) |
| limits | 60 minutes per session | page lists both 100 and 10,000 connections (inconsistent) | 30 minutes per connection (source: OPENAI_REALTIME_SESSION_DURATION_SECONDS) | not stated |
| models | gpt-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.
Dialectfixes 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;
Finalizeisinput_audio_buffer.commit(vLLM: thefinal: truecommit, with the audio gate shut untiltranscription.doneand a new generation started by a plaincommit; audio that arrives meanwhile is held in the ring and sent after it). Server-side turn ends (Speaches' VAD, NIM's endpointing) arrive as additionalspeech_finaltranscripts that are notfrom_finalize; the engine closes them with the nextUtteranceFinal(crates/brisvox-engine/src/stt.rs). - Which transcript answers a commit. The
item_idofinput_audio_buffer.committedpairs the commit with itscompleted; acompletedof another item is a server turn. Servers that send nocommitted(vLLM has no items): the first transcript after the commit answers it. - Nothing to flush. A
Finalizewith 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; anerrorevent 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 othererrorevent 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 plainws://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), thecommittedevent and the error codes on a too-short commit. - NIM: whether a delta is an increment (
delta_replaces = trueif not), whatcommitdoes exactly (onecompletedper 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
commitbeforesession.update's model check, behaviour atrealtime_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).
| Need | Start with | Verified live (2026-10-14) |
|---|---|---|
| English speech recognition, no GPU | sherpa-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 GPU | sherpa-onnx with atasoglu/seda-v0.1, port 6006 | Yes (WER 0.00 on the two fixture utterances, finalize in 1 to 21 ms); read speech only, 8 kHz phone audio unmeasured. |
| English voice | Kokoro-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 voice | Nothing live-ready. Chatterbox-TTS-Server works (preset = "generic") but takes about 3 s to the first audio | Measured, 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 here | Not 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.updatedoes not acceptturn_detection: null), and vLLM's realtime route starts a generation with a plaincommitand ends it withcommit {final: true}(connection.py). - Docs: see "Sources" at the end (fetched 2026-10-14).
- Auth: local servers mostly none (Kyutai
kyutai-api-keyheader defaultpublic_token; Speaches optional Bearer /X-API-Key/api_keyquery; 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:
| Dialect | URL | Audio in | Events out | Finalize |
|---|---|---|---|---|
OpenAI (cloud, from developers.openai.com/api/docs/guides/realtime-transcription) | see docs/providers/openai-realtime.md | input_audio_buffer.append {audio: base64 PCM16}; guide example audio/pcm rate 24000 | conversation.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 append | conversation.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}, error | input_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 pcm16 | input_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/realtime | base64 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 / model | S? | Protocol and finalize | Latency claim | Turkish | License (code / weights) | Hardware | Status |
|---|---|---|---|---|---|---|---|
| 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 only | see dialect table; Fin = commit | none 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 MIT | CPU 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 server | No (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 page | n/a | Whisper multilingual | code license UNVERIFIED (not fetched) | CPU/GPU | latest release v1.9.5 (2026-10-06) |
| WhisperLive (Collabora) | Near-live (Whisper re-decoding with VAD), "nearly-live" per README | WS: 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_rest | none stated | Whisper multilingual | MIT / Whisper MIT | faster_whisper (CPU/GPU), TensorRT, OpenVINO; images ghcr.io/collabora/whisperlive-gpu:latest, -cpu:latest, -openvino | 4.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 below | Apache-2.0 / per model | CPU first-class, also GPU | v1.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 ITN | see above | Yes: 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 normalisation | acoustic 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 MB | trained mostly on read speech; telephone / noisy / spontaneous speech UNMEASURED (card) |
... sherpa-onnx + duxx/turkish-stt-zipformer | Yes (23.6M, ~27 MB int8) | endpoint detection + hotwords per card | not stated | Turkish; dev-set WER 13.09 (CV17), 17.39 (daily-use), 20.16 (FLEURS valid) | CC BY-NC 4.0 (non-commercial) | CPU | not usable commercially |
Kyutai STT (kyutai/stt-1b-en_fr, stt-2.6b-en) via moshi-server | Yes (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 3x | No (English, French only) | Python MIT, Rust Apache-2.0 / weights CC-BY 4.0 | GPU (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) | Yes | WS (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/a | vosk-model-small-tr-0.3 (35 MB, Apache-2.0, WER "TBD") - weak small model | Apache-2.0 / Apache-2.0 per model | CPU; image alphacep/kaldi-en:latest port 2700 | 1.3k stars, 81 open issues; last commit date not seen |
NVIDIA Nemotron ASR Streaming (3.5 multilingual) nvidia/nemotron-3.5-asr-streaming-0.6b | Yes (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 LangID | NeMo 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 practice | HF card released 2026-06-04; NIM tag 1.4.0 (NGC, updated 2026-10-03) |
... nvidia/nemotron-speech-streaming-en-0.6b | Yes | same 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 mono | see above | No | NVIDIA Open Model License Agreement | NeMo, Transformers, NeMo-Speech.cpp, NIM | |
nvidia/parakeet_realtime_eou_120m-v1 | Yes (EOU token) | 120M, ~80-160 ms, English, <EOU> token, no punctuation (search-result summary, primary card NOT fetched) | No | NVIDIA Open Model License (secondary) | UNVERIFIED | ||
| NVIDIA Parakeet-TDT-0.6B-v3 / Canary-1b-v2 | Parakeet: not natively streaming (NeMo chunked-inference script) ; Canary: not documented | NeMo | No (25 European languages, Turkey's language not among them) | CC BY 4.0 | Parakeet card dated 2025-08-14 | ||
| vLLM Realtime + Voxtral-Mini-4B-Realtime-2602 | Yes | vllm 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 1 | configurable delay 80 ms-2.4 s (card; intro says 240 ms), recommended 480 ms | No (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.7B | Streaming 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 UNVERIFIED | no latency figures (README) | Yes (tr in the 30-language list); no Turkish WER published on the card | Apache-2.0 / Apache-2.0 | GPU (qwenllm/qwen3-asr image; qwen-asr-serve) | experimental candidate | |
| Moonshine Voice | Yes (streaming models Tiny/Small/Medium, English only) | in-process library (Python, C++, mobile...); no server/WebSocket documented | "very low latency", no numbers | No Turkish STT (en, es, zh, ja, ko, vi, uk, ar) | MIT; non-English legacy non-streaming models under the non-commercial Moonshine Community License | CPU, edge | |
OpenAI whisper itself | No | library | yes (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 / model | Streaming in / out | Protocol | Time to first audio (claim) | Turkish | License (code / weights) | Hardware | Status |
|---|---|---|---|---|---|---|---|
| Kokoro-FastAPI (Kokoro-82M v1.0, 54 voices) | in: whole text per request; out: chunked audio | POST /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 MIT | CPU 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 8880 | v0.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: StreamingResponse | POST /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) | none | only via Piper voices (below) | MIT | CPU/GPU |
Piper (OHF-Voice/piper1-gpl) | out: whole WAV | python3 -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 documented | n/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 speaker | engine 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 licenses | CPU | repo 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 library | library; wrapper below | open-source models: no figure (Nano "about 3x faster than realtime on 8 CPU cores"); "sub-200 ms" is the paid hosted service only | Yes (23 languages incl. Turkish; V3 list not itemised separately, V2 list includes Turkish) | MIT / MIT; all output carries a Perth watermark | Multilingual: CUDA/CPU/MPS examples; VRAM not stated | |
... Chatterbox-TTS-Server (devnen) v2.0.0 | out: 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/Turbo | no figure | Turkish via multilingual model; exact language code not on page (check /docs) | MIT | Blackwell/RTX 50 (incl. 5070): docker-compose-cu128.yml, CUDA 12.8, driver >= 570; CPU image exists | no TTFA data; needs a live measurement |
Kyutai TTS (kyutai/tts-1.6b-en_fr, ~1.8B total) via moshi-server | in: incremental text (one word per message); out: streamed audio | WS 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 figure | No (en, fr) | Python MIT, Rust Apache-2.0 / weights CC-BY 4.0 (card); no watermark; voice cloning only from the provided embeddings | GPU (pretrained on H100; inference minimum not stated) | Rust-server install "a bit tricky" (README) |
| Kyutai Pocket TTS | streaming out | serve 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 cores | No (en, fr, de, pt, it, es, nl; community adds cs, hi, ko, el) | MIT code; each voice has its own license | CPU, ~100M params | |
| Voxtral-4B-TTS-2603 (Mistral) | streaming | vLLM-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/opus | 70 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-v2 | inference_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-TTS | benchmark only | Triton/TensorRT-LLM, Gradio, Docker | 253 ms avg at concurrency 2 on one L20 (README, client-server) | not listed on README | code MIT; pretrained models CC-BY-NC (Emilia data) | GPU | |
| Fish Audio S2 Pro | streaming 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 license | 5B params | |
| Orpheus-TTS | streaming (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) | streaming | qwen-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.0 | GPU | |
| OmniVoice (k2-fsa) | none documented | model.generate() returns 24 kHz arrays | RTF 0.025-0.09 on H100 | "600+ languages" claimed; Turkish not confirmed; voice design trained on zh/en only | Apache-2.0 | GPU | |
| sherpa-onnx TTS | non-streaming API shown | library | no 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.0 | CPU | the full >100 models / 40 languages list was NOT enumerable | |
| MeloTTS | zh+en only in the sherpa-onnx listing; repo not fetched | UNVERIFIED | |||||
Meta MMS-TTS facebook/mms-tts-tur | VITS 36.3M | transformers | yes | CC-BY-NC 4.0 (non-commercial) | |||
| FreyaTTS (arXiv 2607.09530) | not stated | paper only | RTF 0.11 on RTX 4090 | Turkish-first, 183.2M, WER 8.0 % on their own benchmark | authors state Apache-2.0 weights+code will be released; no repository link on the abstract page and none found | UNVERIFIED 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
| Need | Commercial-safe, verified on paper | Notes |
|---|---|---|
| STT streaming | atasoglu/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 |
| TTS | Chatterbox Multilingual (MIT) via Chatterbox-TTS-Server | quality and TTFA on Turkish unmeasured |
| TTS non-commercial only | Piper 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 Turkish | Kokoro, Kyutai STT/TTS, Parakeet v3, Canary v2, Voxtral STT/TTS, Moonshine STT, Qwen3-TTS, Orpheus |
5. Gotchas found
- 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). - 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; WhisperLiveEND_OF_AUDIOends the session; vLLM mid-session flush unclear. - Speaches Realtime is not a streaming-transcript server: no deltas, its own server VAD
(threshold 0.9 default) unless
turn_detectionis disabled (how to disable: UNVERIFIED), and it transcribes the committed buffer in one call (source). Its docs say real-time use needs CUDA. - 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.
- 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).
- 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).
- 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.
- 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
OpenAI /v1/audio/speechstreaming-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.OpenAI Realtime transcriptionSTT 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.- A native sherpa-onnx streaming WebSocket STT client (tiny protocol: float32 binary,
"Done", JSON{text, segment}) for CPU-only / Turkish. - 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-dfkionly 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-cu128on 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 todevelopers.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.comAPI reference pages answer 403 to an unauthenticated fetch; thedevelopers.openai.comreference 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 subprotocolopenai-insecure-api-key.<key>; Brisvox is a server, so it uses the header. OptionalOpenAI-Safety-Identifierheader (a stable, hashed end-user id) on the connection request. The GA interface must NOT sendOpenAI-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.1is the model of every current doc example;gpt-realtime-2andgpt-realtimeare named (image input section);gpt-realtime-2025-08-28appears in asession.createdexample.gpt-realtime-whisper/gpt-live-transcribeare transcription-only models (turn detection must be omitted ornull). Exact pricing/latency: UNVERIFIED (not read). The model is chosen in the URL query and cannot change insession.update. - Voices:
alloy ash ballad coral echo sage shimmer verse marin cedar(marin/cedarrecommended).voicecannot 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_limitreports 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):messageitems withrolesystem/user(contentinput_text) /assistant(contentoutput_textoroutput_audio; "the type depends on the session's output_modalities"; the event "cannot populate assistant audio messages"),function_call(name,argumentsJSON string, optionalcall_id) andfunction_call_output(call_id,output);statushas no effect; the item is appended unlessprevious_item_idis given. The adapter sends one event per history item aftersession.updated, before any audio. UNVERIFIED: whether anassistantmessage withoutput_textis accepted in a session whoseoutput_modalitiesis["audio"](the guide has no example);seed_assistant_as_systemswitches such turns tosystemmessages ("The assistant said earlier: ...") if the server rejects them. Rejections arrive aserrorevents (delivered as warnings); the session still works. - Protocol: JSON text messages both ways, every event has
typeand an optional client-chosenevent_id. Audio is base64 inside JSON (input_audio_buffer.append, up to 15 MB per chunk; no server acknowledgement). Server sendssession.createdright after the upgrade.
Client events (we send)
| event | fields (verified) |
|---|---|
session.update | session: {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.append | audio (base64) |
input_audio_buffer.commit | none. Creates the user message item from the buffer; with server VAD it commits automatically; fails on an empty buffer. |
input_audio_buffer.clear | none; server answers input_audio_buffer.cleared |
conversation.item.create | item, 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.truncate | item_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.delete | item_id |
response.create | optional response overrides (instructions, tools, conversation:"none" for out of band, output_modalities, input, metadata) |
response.cancel | optional response_id (omitted: the in-progress response of the default conversation) |
output_audio_buffer.clear | WebRTC/SIP only. Not used. |
Server events (we handle)
| event | fields (verified) |
|---|---|
session.created / session.updated | session (id, model, audio.input.format, audio.output.{format,voice}, tools, ...) |
error | error:{type, code, message, param, event_id} (event_id is the client event that failed) |
input_audio_buffer.speech_started | audio_start_ms, item_id |
input_audio_buffer.speech_stopped | audio_end_ms, item_id |
input_audio_buffer.committed | item_id, previous_item_id |
response.created | response:{id, status:"in_progress", output:[], ...} |
response.output_audio.delta | response_id, item_id, output_index, content_index, delta (base64 PCM) |
response.output_audio.done | response_id, item_id, output_index, content_index |
response.output_audio_transcript.delta / .done | same ids; delta / transcript |
response.function_call_arguments.done | response_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.done | response_id, output_index, item (a function_call item has call_id, name, arguments) |
response.done | response:{id, status: completed|cancelled|failed|incomplete, status_details:{type,reason,error}, output:[items], usage} |
conversation.item.truncated | item_id, content_index, audio_end_ms |
conversation.item.added / .done | item |
rate_limits.updated | rate_limits:[{name,limit,remaining,reset_seconds}] |
conversation.item.input_audio_transcription.completed | UNVERIFIED (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}; thesession.createdexample showsaudio/pcmat 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/pcmaand 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 messagesession.start); for the Realtime API this is UNVERIFIED. The adapter requestsaudio/pcmat 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 ANDOpenAiRealtimeConfig::transcription_modelis set; it is off by default. The completion eventconversation.item.input_audio_transcription.completedis 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 thesession.createdexample, 500 in the VAD guide example),create_response(bool),interrupt_response(bool).idle_timeout_ms: UNVERIFIED (not on the guide page).semantic_vad:eagernesslow|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.donewithstatus:"cancelled"; the guide also names aresponse.cancelledevent that the server-events reference does not list: the adapter treats it as a synonym of a cancelledresponse.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.truncatewithaudio_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.cancelon a response that already finished answers anerrorevent (code UNVERIFIED, probablyresponse_cancel_not_active); the adapter treats any error that carries theevent_idof one of ourresponse.cancel/conversation.item.truncateevents as benign.
Function calling
- Tools in
session.update(or perresponse.create):{type:"function", name, description, parameters:<JSON Schema>},tool_choice:"auto". - The model's call arrives as a
function_callitem (type, name, argumentsJSON string,call_id) inresponse.output_item.done/response.done.response.output[], and asresponse.function_call_arguments.done(+.deltastream). - We run the tool, then send
conversation.item.createwith{type:"function_call_output", call_id, output:"<string>"}, thenresponse.createso 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
thappens 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_mslarger 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.createfor a tool result of a superseded response (the user is already speaking again); only add the output item. - Every
function_callshould get afunction_call_output, alsointerruptedones, 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/Blobare defined on the GenerateContent reference, which was NOT read (see UNVERIFIED below).
- Auth: API key as the
keyquery 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-keyas a header on the WebSocket upgrade is UNVERIFIED (the adapter offers it as opt-inkey_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 withAQ.; 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;thinkingLevelmust be omitted),gemini-3.8-live-extended-thinking(supportsthinkingLevel),gemini-3.1-flash-live-preview(legacy, "migrate to 3.8"). Themodelfield ismodels/<name>. The adapter default isgemini-3.8-live. - Protocol: JSON messages; each client message has exactly one of
setup,clientContent,realtimeInput,toolResponse. The first message issetupand the configuration cannot be changed afterwards; wait forsetupCompletebefore 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 ingenerationConfig:responseMimeType,responseSchema,stopSequenceand 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 {roleuser|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"}inresponseand (non-blocking functions only)schedulinginside it (INTERRUPT,WHEN_IDLE,SILENT).- Server messages (each may also carry
usageMetadataandvoiceActivity):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{typeACTIVITY_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, soaudio/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 afterkeepalive_intervalof inbound silence and fails the session when nothing arrives withinio_timeout. - Finalize / flush / cancel semantics:
- Turn detection: automatic activity detection by default (
silenceDurationMsdefault about 800 ms, 500 to 800 recommended). With it disabled the client sendsactivityStart/activityEnd(silence threshold at least 500 ms recommended). Hybrid: client VAD plusaudioStreamEnd. - 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).generationCompleteis 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 clientactivityStartinterrupts: UNVERIFIED but implied byactivityHandling: START_OF_ACTIVITY_INTERRUPTS. - Tools: "the Live API doesn't support automatic tool response handling": the client answers
every call.
gemini-3.8-liveis asynchronous by default (behaviorNON_BLOCKINGorBLOCKING);3.8-live-extended-thinkingnon-blocking only; the legacy 3.1 model is sequential ("the model will not start responding until you've sent the tool response").
- Turn detection: automatic activity detection by default (
- Timestamps / interim results / tool-call deltas: transcripts arrive as chunks in
inputTranscription/outputTranscription;interimInputTranscriptionalso exists. Tool calls arrive whole in onetoolCallmessage (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 ignoresS2sConfig::language. Quality: UNVERIFIED . - Gotchas found during implementation (design decisions, see
docs/S2S.md):- No response or item ids exist; the adapter mints
gr<n>andgr<n>-audio. A response ends atturnComplete,interrupted, or when atoolCallarrives (the model then waits, the answer begins a new response). UNVERIFIED whether the server also sends aturnCompleteafter atoolCall; aturnCompletewith no open response is ignored. Truncatehas 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.CancelResponsehas 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 nextactivityStartinterrupts the server.- After
activityEndand after atoolResponsethe server answers by itself, so the adapter absorbs the engine's followingCreateResponse. ACreateResponseafter only a note is sent as{"clientContent":{"turns":[],"turnComplete":true}}(UNVERIFIED that this starts generation). - Tool answers are batched: one
toolResponsecarries the answers of all calls of onetoolCallmessage (sent when the last one is answered or atCreateResponse). Results of calls the server cancelled or interrupted are not sent. - Notes (
Note) go as aclientContentuser turn"[note] ..."withturnComplete: false. WhetherclientContentis accepted in the middle of a conversation by 3.8 is UNVERIFIED (the guide describes it for seeding the context). - History seeding: after
setupComplete, oneclientContentwith the turns (user/model),turnComplete: false, andhistoryConfig.initialHistoryInClientContent: truein 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. ThehistoryConfigsemantics 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;additionalPropertiesand 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_limitreportsconnection_lifetime(10 minutes), the engine opens a replacement session seeded from its mirror a margin before it (2 minutes) or atgoAway, 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. voiceActivitymessages (ACTIVITY_START/END) becomeSpeechStarted/SpeechStopped. Whether the server sends them by default is UNVERIFIED; when it does not,interruptedyields aSpeechStartedand the start of the next response yields theSpeechStopped.
- No response or item ids exist; the adapter mints
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
keyquery parameter works, and so doesx-goog-api-keyon the upgrade (key_in_header = true). Keys may contain '.' (the adapter rejectedAQ.keys before this). voiceActivityis sent by default: orderSpeechStarted, SpeechStopped, UserTranscript (input transcription), ResponseStarted, Audio/AssistantTranscript deltas, AudioDone, final AssistantTranscript, ResponseDone(Completed).- Tools: the declaration the adapter sends is accepted;
toolCallarrives BEFORE the turn'sturnComplete(ToolCall then ResponseDone(Completed)); aftertoolResponse+ 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
parametersJsonSchemafor full JSON Schema;parameters(OpenAPI subset) rejected a schema withadditionalProperties/minimum, and the server refused the whole setup (every connect failed). Fixed ingemini_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
CallEndsboundary ofcrates/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::MediaAddedis not emitted by the firstpoll_outputafteraccept_offer; find the audio section in the offer text (a=mid) and askrtc.media(mid).- ICE-lite declares a silent browser gone only after 15 s without a STUN binding request
(
RECENT_BINDING_REQUESTin theiscrate): hang-up byDELETEor DTLS close is immediate, a vanished browser takes about 15 s plusdisconnect_grace. RtcConfig::buildpanics on failure (expect); the DTLS certificate takes 20-36 ms to generate (rcgen): one certificate per server, setup onspawn_blocking.- With
rust-crypto,dimplstill buildsaws-lc-rs(throughrcgen) for certificate generation; it compiled here without NASM. opus0.4.0 /opusic-sys0.7.5 build libopus with cmake; it works on this machine because thecmakecrate finds the Visual Studio copy, but the build fails when the target directory path is long (MSBuild 260-character limit inside.../out/build/CMakeFiles/...): keepCARGO_TARGET_DIRshort 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
413reaches 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
| Option | Version (crates.io) | Last release | License | Repo activity (GitHub API) | Downloads (total / recent) |
|---|---|---|---|---|---|
| str0m | 0.24.1 | 2026-10-03 | MIT OR Apache-2.0 (crates.io; GitHub repo metadata shows "MIT") | pushed 2026-10-04, 634 stars, 36 open issues | 2,428,919 / 1,002,780 |
| webrtc (webrtc-rs, async layer) | 0.21.0 | 2026-09-19 | MIT/Apache-2.0 (crates.io; GitHub shows Apache-2.0) | pushed 2026-10-03, 5,158 stars, 7 open issues | 7,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.0 | 2026-09-19 | MIT/Apache-2.0 | pushed 2026-10-04, 164 stars, 10 open issues | 948,062 / 921,281 |
| livekit + libwebrtc (Google libwebrtc via FFI) | livekit 0.9.4, libwebrtc 0.3.51 | 2026-10-07 | Apache-2.0 | pushed 2026-10-07, 495 stars, 157 open issues | libwebrtc 3,254,718 / 1,439,204 |
| datachannel (libdatachannel wrapper) | 0.16.1 | 2026-07-27 | MPL-2.0 | pushed 2026-07-27, 151 stars | 178,803 / 19,034 |
| gstreamer-webrtc + gst-plugin-webrtc (webrtcbin) | gstreamer-webrtc 0.25.4; gst-plugin-webrtc 0.15.4 | 2026-09-21; 2026-09-22 | MIT OR Apache-2.0 (bindings); MPL-2.0 (gst-plugin-webrtc) | n/a | gstreamer-webrtc 1,156,344 / 208,114 |
| rustrtc (restsend) | 0.4.2 | 2026-10-07 | MIT | crate created 2025-11-27 | 15,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";
Rtchas no internal threads or async tasks and never callsInstant::now()itself (time is passed in). Drive loop: after any mutation (handle_input,Writer::write,Channel::write, SDP apply, add candidate) drainpoll_outputuntilOutput::Timeout. Outputs areOutput::Transmit,Output::Event,Output::Timeout(Instant). Fits the repo's injected-Clockrule and deterministic simulation. Not a W3CRTCPeerConnectionAPI (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_unwindas 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 theice_lite()getter; anRtcin 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 onRtcConfig). 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::acceptsrecognises validated remote addresses in ICE-lite mode before nomination. - DTLS-SRTP: DTLS via the
dimplcrate (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: receiveEvent::MediaData, sendWriter::write(pt, wallclock: Instant, rtp_time: MediaTime, data). Audio uses a 48 kHz media clock (README: "48_000Hz for audio"). Per docs.rsWriter::write: media written beforeIceConnectionStateisConnectedis dropped.MediaDatafields: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=falsemeans loss ("usually irrelevant for audio"); for Opus PLC we would detect loss viaseq_range/contiguousand 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;Nonedisables; 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-postexample (browser POSTs a JSONSdpOfferdeserialized with serde_json; the answer is returned in the HTTP response; host candidates are added beforeaccept_offerso there is no trickle; one thread and one UDP socket per connection) is a direct starting point. Example uses JSON, notapplication/sdp; we adapt usingSdpOffer::from_sdp_string(&str) -> Result<SdpOffer, SdpError>andSdpAnswer::to_sdp_string(&self) -> String(both verified on docs.rs 0.24.1). Example bug to avoid: it callsprocess::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-rsneeds a C/C++ compiler (CMake never required for non-FIPS per aws-lc-rs requirements page; NASM/Windows specifics UNVERIFIED).wincryptoavoids C toolchains on Windows;rust-cryptois 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:
webrtcis "a thin async layer overrtc", 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).webrtchasPeerConnection(async handle),PeerConnectionDriver(background loop owning sockets, timeouts, event dispatch) and aRuntimetrait. Defaultruntime-tokio; alsoruntime-smol,runtime-mock(virtual clock). Corertcexposessansio::Protocol(handle_read,poll_write,poll_read,poll_event,poll_timeout/handle_timeout,handle_write,close); no ambient clock: methods takeInstant. 0.21: "The core no longer reads a clock. Time comes from Runtime::now()". Usingrtcdirectly would give the same determinism as str0m with a W3C-shaped API; usingwebrtcputs 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 traitPeerConnectionEventHandlerhason_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().awaityieldsTrackRemoteEvent::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) orTrackLocalStaticRTP(pre-packetized RTP). Opus registration in thereflectexample (examples/reflect/reflect.rs):MIME_TYPE_OPUS, clock rate 48000, 2 channels, payload type 120;register_default_interceptors(registry, &mut media_engine). Thereflectexample 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 thewebrtccrate forwards it viawith_setting_engineis 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; defaultring(no C toolchain), optionalaws-lc-rs(crypto-aws-lc-rsfeature); provider selectable per connection (SettingEngineBuilder::with_crypto_provider). Cipher suites and SRTP profiles configurable. - Interceptors (
rtc-interceptor0.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). Whetherregister_default_interceptorsenables 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;
rtcrequires Rust edition 2024 (we have it).ringdefault needs no C toolchain.
2.3 LiveKit Rust SDK / libwebrtc (https://github.com/livekit/rust-sdks)
libwebrtccrate (Apache-2.0) = "Livekit safe bindings to libwebrtc" (Google's C++ WebRTC): modules includepeer_connection,peer_connection_factory,audio_source,audio_stream,data_channel,rtp_*.livekitcrate = client SDK to connect to LiveKit rooms (needs a LiveKit server URL and token; README examples are server-based). Whetherlibwebrtccan 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_WEBRTCoverrides. 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.tomlmust 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
datachannel0.16.1 (MPL-2.0, 2026-07-27, 151 stars, 1 open issue): README shows only data-channel usage; amediaCargo feature exists ("Enables media support through libdatachannel") but the media API is undocumented there. Needs CMake + OpenSSL (vendoredfeature 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,latencyproperty = 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-webrtc0.15.4 (2026-09-22, MPL-2.0): the docs.rs crate page lists onlywebrtcsink/webrtcsrc; the GStreamer rswebrtc plugin page (12 elements) additionally listswhipclientsink,whipserversrc,whepclientsrc,whepserversink, plus LiveKit/Janus/AWS KVS/Unreal signaller elements (no element literally namedwhepsrc). 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
t38feature, not atransport_modevariant), 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) | str0m | webrtc 0.21 (async) | rtc 0.21 (direct) | libwebrtc (LiveKit) | rustrtc | libdatachannel | GStreamer |
|---|---|---|---|---|---|---|---|
| Maturity / maintenance (20) | 4 | 3 | 3 | 4 | 1 | 3 | 4 |
| Sans-IO / determinism / injected Clock (20) | 5 | 3 | 5 | 0 | 3 | 1 | 0 |
| Opus path (payloading, loss, DTX/RED) (15) | 4 | 4 | 3 | 5 | 3 | 1 | 4 |
| ICE incl. ICE-lite and TURN client (15) | 3 | 4 | 4 | 5 | 5 | 3 | 4 |
| Build on Windows + Linux, no network at build (10) | 4 | 5 | 5 | 2 | 4 | 2 | 1 |
| Performance / scalability headroom (10) | 4 | 3 | 4 | 3 | 3 | 3 | 2 |
| License fit (MIT/Apache/BSD) (5) | 5 | 5 | 5 | 5 | 5 | 2 | 3 |
| WHIP / signaling effort (5) | 3 | 3 | 3 | 2 | 2 | 2 | 4 |
| Weighted total (max 500) | 405 | 360 | 395 | 315 | 305 | 210 | 265 |
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
POSTto the endpoint,Content-Type: application/sdp, body = SDP offer. Success =201 Created, body = SDP answer (Content-Type: application/sdp),Locationheader = session URL. Wrong content type / malformed SDP = 4xx. Client SHOULD offersendonly(MAYsendrecv), MUST NOT offerinactiveorrecvonly; the endpoint MUST answerrecvonly. (For a bidirectional voice agent we need to send audio back: plain WHIP is ingest only. Options: (a)sendrecvoffer, which the RFC permits for the client but whose answer direction the RFC says must berecvonly, so the agent's reply audio would need a WHEP-style second session or a documented Brisvox extension; (b) our own endpoint that answerssendrecv, i.e. "WHIP-style" as the project brief says. UNVERIFIED which browsers'RTCPeerConnectionaccept 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 documentedsendrecvdeviation.) OPTIONS: required for CORS; 200 response SHOULD includeAccept-Post: application/sdp.DELETEon the session URL terminates the session.- ICE (4.3): Trickle ICE and ICE restart RECOMMENDED, via
PATCHwithapplication/trickle-ice-sdpfrag; ETags required (428 if missing, 412 on mismatch); support for only one of the two returns422. The endpoint gathers all candidates before answering (4.3.2), so non-trickle is sufficient for a server. Adding candidates without restart returns204 No Content; restart returns200 OKwith 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 offeractpass; 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-Afterunder 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):
Linkheaders in the 201 response withrelprefixurn: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: askstereo=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
| Crate | Version / date | License | Nature | Build requirements | Notes |
|---|---|---|---|---|---|
opus | 0.4.0, 2026-08-23 (0.3.1 on 2026-01-03; 2,662,657 downloads, 1,415,281 recent) | MIT/Apache-2.0 | Safe 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 compiler | Decoder::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-sys | 0.7.5, 2026-08-06 (1,562,959 downloads) | BSD-3-Clause | libopus FFI | bundled 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_PATH | No Windows-specific notes on the page. |
audiopus / audiopus_sys | 0.3.0-rc.0 / 0.2.2, both 2021-04-22 | ISC | libopus bindings | audiopus_sys build-deps: cmake, pkg-config, log (+optional bindgen 0.58) | Stale (no release since 2021). Do not use. |
rusty-opus | 1.0.0, 2026-10-05 (created 2026-07-29; 51,981 downloads) | BSD-3-Clause | Pure Rust encoder+decoder, SILK/CELT/Hybrid, DTX, FEC, PLC, SIMD | none (zero deps), MSRV 1.85 | Claims 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-rs | 0.1.37, 2026-10-07 (created 2026-02-28; 186,746 downloads) | BSD-3-Clause | Pure Rust port of libopus 1.6 (restsend, repo restsend/opus-rs, single owner) | none | README 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. |
ruopus | 0.1.2, 2026-07-01 (2 releases; 7,391 downloads) | MIT | Pure Rust decoder + encoder | none | Too 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):
crates/brisvox-transport/src/webrtc/(new module or cratebrisvox-transport-webrtc): aWebRtcTransportimplementingTransport::accept. The adapter per call keepsCallEnds: pushesWireEvent::Audio(decoded caller PCM),Hangup; consumesWireOut::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 throughCallInfo::metadata[CallInfo::SAMPLE_RATE_KEY](CallRates { input, output }).- Signaling endpoint (axum/hyper, whatever
brisvox-serveralready uses):POST /whip(or/webrtc/offer) withContent-Type: application/sdpand optionalAuthorization: Bearer. Steps: validate size and content type; create anRtc(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 withSdpOffer::from_sdp_string, answer returned viaSdpAnswer::to_sdp_string); return201 Created,Content-Type: application/sdp,Location: /whip/<session-id>, SDP answer body.DELETE /whip/<session-id>cancels the session token and returns 200/204;OPTIONSreturns CORS headers andAccept-Post: application/sdp.PATCHcan be 405/422 at first (non-trickle). - One peer per call: one
Rtc+ one task per call, child of the call'sCancellationToken. Sockets: a single shared UDP socket (or small pool) with a demux map (remoteSocketAddror ICE ufrag -> call).handle_input/poll_outputloop per call, timeouts via the injectedClock(neverInstant::now()in logic, per CONTRIBUTING.md).catch_unwindaround 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. - Media: negotiate Opus (48 kHz clock, 2 channels in SDP; send mono content). Inbound:
Event::MediaData.datais 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 whenseq_range/contiguousshows 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 isConnected. Drop writes while not connected. Barge-in: onWireOut::Cleardiscard queued PCM/frames immediately; RTP timestamps keep advancing by elapsed time so the stream stays valid. - Hang-up: on
IceConnectionState::Disconnectedor DELETE, emitWireEvent::Hangup; the transport supervisor maps it to stop reasons. - 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 withtower::ServiceExt; protocol fixtures for the WHIP shapes in section 5. A live browser test isBRISVOX_LIVE_TESTS=1/ a pending follow-up. - Browser demo page (served by the same server at
/demo/webrtcas static HTML; microphone needs a secure context: HTTPS,localhostorfile:///; otherwisenavigator.mediaDevicesis undefined, source MDN getUserMedia page):
(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 namesconst 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();echoCancellation/noiseSuppression/autoGainControl/channelCount(they belong to MediaTrackConstraints; UNVERIFIED here). Browser host candidates may be mDNS.localnames; the server learns the real address from the STUN binding request, not from the SDP: UNVERIFIED behaviour in str0m, test it.) - 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
opuscrate passesu32through, validation UNVERIFIED; otherwise 48 -> 16 kHz with rubato, see docs/providers/rubato.md) and TTS 24 kHz -> 48 kHz output. The adapter writesCallRates { input: 16000-or-48000, output: 48000 }intoCallInfometadata. Keep all resampling in the existing pipeline (StreamResampler), not in the adapter. - 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
webrtc0.21 crate forwards ICE-lite (with_lite) throughPeerConnectionBuilder::with_setting_engine, and whetherregister_default_interceptorsenables 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 foropusic-sysbundled (cmake generator, MSVC);wincryptoandrust-cryptoas C-free alternatives not yet built by us. opuscrate:set_inband_fec/set_packet_loss_percsignatures,Applicationenum variants, and whether the crate itself restricts sample rates (docs page did not show them).- Whether
libwebrtccan run as a standalone peer without a LiveKit server; the GStreamer webrtc plugin licenses;datachannelcrate 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
sendrecvanswers 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:
- 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).
- 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.
- 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.
- str0m NACK-timer and RTCP receiver-report fixes (commits 2026-10-04) are under "Unreleased" in the changelog, not in 0.24.1.
- 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). - str0m TURN quote replaced with the verbatim docs.rs sentence.
- 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. - rustrtc: T.38 is a separate feature, not a transport_mode variant; WHEP is mentioned (UDP mux), WHIP is not.
- WHEP draft: WG state also "Revised I-D Needed - Issue raised by WG".
- MDN: secure context also includes
file:///; the constraint names are NOT on that MDN page (previous text said they were). - libopus decoder sample rates (8000/12000/16000/24000/48000) now verified from opus-codec.org API docs (previously "from memory").
- opus-rs: "Older releases lacked Hybrid" is not supported by the README (Hybrid documented as supported); replaced with README maturity facts.
- 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. - 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.
- 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.
- str0m
SdpOffer::from_sdp_string/SdpAnswer::to_sdp_stringverified to exist (previously UNVERIFIED); DTMF in 0.24.0 is opt-in negotiation; RFC 7587maxaveragebitraterange is "SHOULD ignore outside" rather than a default. - 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 onlyLICENSE-MIT.txt(MIT, Copyright 2022 Martin Algesten) whileCargo.tomlsaysMIT OR Apache-2.0. Treat it as MIT, whichdeny.tomlallows (cargo deny check: advisories, bans, licenses, sources ok). - Default features are
aws-lc-rsandexamples(the examples pullrouille): usedefault-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::buildcallsexpecton its own errors; everything else returnsResult. Event::Connectedfires when ICE and DTLS are up;Event::MediaAddedcomes frompoll_outputonly after that (it is not available right afteraccept_offer), so the audiomidis read from the offer text.MediaDatatimestamps are 64-bitMediaTime(numerator = RTP ticks at 48 kHz for Opus);contiguousis 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_fromcan fail withConnectionReset(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):
| setting | encode per frame | decode per frame |
|---|---|---|
| complexity 9, fullband | 376 us | 44 us |
| complexity 5, superwideband (the default) | 185 us | 44 us |
| complexity 3, superwideband | 148 us | 41 us |
| complexity 5, wideband | 135 us | 26 us |
| complexity 2, wideband | 93 us | 25 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;Leadis not reported. A data-channel echo from the demo page would make it exact (not done). Cleardrops 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) andplan_packetis 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
Audiocommand 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 testfirst_bot_audio_reaches_the_browser_fast_at_the_medianasserts 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,
getUserMediaconstraint 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_graceof the test config) and the engine seesPeerDisconnected. The testa_vanished_browser_frees_its_slot_and_ends_the_engines_callruns 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 asInstants) 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 thedisconnect_graceand 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
.localname, 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 eachMediaDatabefore the decode job; the excess is dropped and counted inWebRtcStats::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_limitedcounts 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_concealedequals 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_framesis 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, pooltimed_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 CreatedasLinkheaders, 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:);usernameis the TURN user,credentiala long-term password (RFC 8489 section 9.2).credential-typeis not defined by the RFC, so the server does not send it. Returning the servers inOPTIONSis "NOT RECOMMENDED"; a CORS preflight SHOULD NOT carry them. Implemented: oneLinkper URL on the201;Access-Control-Expose-HeaderslistsLinkso 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:), passwordbase64(hmac-sha1(secret, username)). Only HMAC-SHA1 is documented for this scheme.--tls-listening-portdefaults to 5349;--cert/--pkeyare PEM files. Implemented withring::hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY; the timestamp is the unix expiry (wall clock of the injectedClock), the user id is the call's session id (so one credential maps to one call). Reference vector in the unit testthe_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 parameterTtl(seconds; default and maximum 86400); response JSON hasice_servers(entries withurl/urls, TURN ones withusername/credential), plususername,password,ttl. TURN URLs:turn:global.turn.twilio.comon 3478 (udp, tcp) and 443 (tcp); noturns:. The parser acceptsurlsas a string or an array. - str0m ICE-TCP (read in
is-0.11.1andstr0m-proto-0.7.0sources, no network): a passive TCP host candidate isCandidate::builder().tcp().host(addr).tcptype(TcpType::Passive).build();Protocol::Tcpis 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_candidaterejects only non-host ones). A STUN request is accepted only whenReceive.destinationequals 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-servicesaddress (replies must come from it), a[[services]]block withprotocol = "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 asPOST;Cache-Control: no-store; CORS likePOST):{"iceServers":[...]}in the browser'sRTCIceServershape,[]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'ssid(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>/icebeforecreateOfferand pass the list toRTCPeerConnection; 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-serversresponse shape foriceServersas 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 aRefreshwithLIFETIME0 deletes the allocation (section 7); a permission is installed byCreatePermissionfor 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) andChannelBindalso installs a permission; data goes asSend/Dataindications 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 inrelay.rsfollows this and uses only URLs that mean TCP (transport=tcp, orturns: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-TRANSPORTis 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 asstun_proto::Instant(sans-io-time: nanoseconds on a caller-chosen time line, so the injectedClockmaps straight onto it). TLS is not in the crate (separateturn-client-rustls); the TCP client takes plaintext bytes. Deviations found by running it against a TLS-less server and reading the source:- it writes ChannelData unpadded and parses
4 + lengthbytes on receipt; RFC 8656 12.5 requires padding on a stream.relay.rsframes 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.) - with
anonymous_usernameonAutoit switches fromUSERNAMEto the RFC 8489USERHASHwhen the server advertisesPASSWORD-ALGORITHMS; the server side cannot then see the REST user name.relay.rssets it toDisabled; coturn and the managed services useUSERNAME. - its
Debugoutput (TurnCredentials, client, config) contains the TURN password: never formatted. Itstracingoutput (targetsturn_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 attrace, a whole call): the password and the shared secret never appear, but attraceevery received STUN message is logged in full (NONCE, REALM, the response's MESSAGE-INTEGRITY HMAC), and atinfothe 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 belowwarnwhateverRUST_LOGsays (brisvox_core::telemetry::turn_client_log_allowed, a per-layer filter intelemetry::build_subscriber;BRISVOX_LOG_TURN_CLIENT=1lifts 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 byFilterExt::and).
- it writes ChannelData unpadded and parses
12.2 Why the design is shaped this way
-
str0mICE-lite refuses non-host local candidates (is-0.11.1agent.rsadd_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
CreatePermissionfor 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.rsmodule docs): at mostmax_permissions(8), only the allocation's address family, never unspecified, multicast, broadcast, loopback or link-local addresses (loopback and link-local only withRelayConfig::allow_local_peers, for tests). The cost of a hostile offer is up to 8CreatePermissionrequests 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;str0msends 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
Refresh0; 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_usandrelay_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
.mdvariant) - 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 objectc8d5d28c..., commit1559a684b1ee9771b36454b72418d7364b518e7f(commit hash not re-checked in the fact-check)).mainHEAD on 2026-10-08 wasa3b149f834d1791249b8b258960b111318d119b4(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, commit936217a73c17db46f64154e4879c8218cc7bd55d): 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, commitb1cd6483a555f7d56144f0f5bdfb64d565394442): 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 HEAD93c0bd5f31e18d5d182a49f4a5eeead60b640db6, 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.
- RTVI standard (docs): https://docs.pipecat.ai/client/rtvi-standard (also
- Licenses: Pipecat (Python), the JS client and the WebSocket transport are BSD-2-Clause
(SPDX headers in the files; npm registry says
BSD-2-Clausefor bothclient-jsandwebsocket-transport). If the.prototext 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
tokenconnect 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 examplebot.pycontains no token handling). - Endpoint(s) / regions: any WebSocket URL the client is given:
connect({ wsUrl })(ws_urlis a deprecated alias) orstartBotAndConnect({ endpoint }), where the HTTP endpoint returns connection params ({ wsUrl }; shape UNVERIFIED in this pass). The example README says the client callsPOST /startonhttp://localhost:7860before opening the WebSocket; the WebSocket path is not given there (UNVERIFIED; an earlier draft said/wsand/api/start, not confirmed). No path convention is required by the client.
Versions (current on 2026-10-08)
| Component | Version | Date | Notes |
|---|---|---|---|
| RTVI protocol constant | "2.1.0" | v1.12.0 | PROTOCOL_VERSION in models.py, RTVI_PROTOCOL_VERSION in client-js rtvi/messages.ts. Both identical. |
| Pipecat (Python) | v1.12.0 | 2026-09-26 | GitHub latest release. LEGACY_SUPPORTED_MAJOR = 1: 1.x clients are accepted. |
@pipecat-ai/client-js | 1.13.1 | 2026-09-04 | npm latest. BSD-2-Clause. Deps: uuid, bowser, events, clone-deep, typed-emitter, @types/events. |
@pipecat-ai/websocket-transport | 1.7.2 | 2026-09-15 | peer 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 logIgnoring not RTVI message, no reply) incoming messages with another label.type: kebab-case string (tables below).id: the JS client generatesuuidv4().slice(0, 8)(8 hex chars) for each message. The Pipecat baseMessagemodel that validates ALL incoming client messages declaresid: strwith no default, so a client message withoutidfails 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 inmodels.pywith NOidfield, so Pipecat v1.12.0 emits noidon them. The JS client never readsidon server events.data: for payload-less server events (e.g.user-started-speaking) the Pipecat model has nodatafield andpush_transport_messageusesexclude_none=True, sodatais omitted. Incomingdisconnect-botfrom the JS client sendsdata: {}; 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
type | data | Pipecat 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-context | deprecated; JS appendToContext() logs a deprecation warning on every call | NO 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-group | UI 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
type | data | Seen 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 id | yes (rejects the pending request, onMessageError) |
server-response | { "t": "<client-message t>", "d": <any> }, id = request id | yes (resolves the pending request) |
server-message | any JSON value | yes (onServerMessage) |
user-started-speaking, user-stopped-speaking | none | yes |
vad-user-started-speaking, vad-user-stopped-speaking | none (default off in the observer) | no case in handleMessage (debug log) |
bot-started-speaking, bot-stopped-speaking | none | yes |
bot-interrupted | none (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-stopped | none | yes |
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 ends | yes (onBotTranscript) |
bot-llm-started, bot-llm-stopped | none | yes |
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-stopped | none | yes |
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_usage | yes |
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.102 | yes |
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-group | UI worker protocol | yes (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
- Client opens the WebSocket (
ReconnectingWebSocket;ws.on("open")only logs). PipecatClient.connect(): init devices (mic) if needed,transport.connect()(WebSocket + media manager, transport stateconnected), thentransport.sendReadyMessage(). The WebSocket transport'ssendReadyMessage()doesthis.state = "ready"and sendsclient-readyimmediately (the transport state becomesreadyat that moment, NOT whenbot-readyarrives).- Mic audio captured before state
readyis queued (unbounded queue) and flushed afterwards. Audio therefore starts flowing right afterclient-ready, beforebot-readymay have arrived. The server must tolerate audio frames before it replies. - Server replies
bot-readywith the sameidasclient-ready. In Pipecat the app must callawait rtvi.set_bot_ready()(typically inon_client_ready); the processor does not send it by itself (sourceprocessor.py; the docs: "The server must mark the bot as ready before it can process client messages"). Itsversionis the server protocol version, except that a 1.x client gets its own declared version echoed back. - The JS
connect()promise resolves on the firstbot-readyand has NO timeout inclient.ts(it waits indefinitely). The JS client does not check the id ofbot-ready(confirmed inclient.ts). - Ordering observed in the Pipecat observer (v1.12.0): aggregated bot text is queued until
BotStartedSpeakingFrame; thenbot-started-speakingis sent first, then the queuedbot-outputmessages; while the bot is speaking, aggregated text is sent immediately.bot-llm-textis sent perLLMTextFrame; the deprecatedbot-transcriptionbuffer is built fromLLMTextFrametext separately (relative send order between the two: UNVERIFIED). Broadcast frames are handled downstream only. - Shutdown: the JS
disconnect()awaitstransport.disconnect()(statedisconnectingthendisconnected; closes the WebSocket; no RTVI message sent).disconnect-botis an explicit "end the bot" request with no reply. Afatal: trueerrormakes the JS client calldisconnect()itself. In the WebSocket transport,close,error,connection-timeoutandreconnect-failedsocket events each surface as a fatalerrorto the client. - Client-request correlation:
sendClientRequest()(client-message) waits forserver-responseorerror-responsewith the same id;client.tsapplies no default timeout (an optionaltimeoutis passed through); any default lives inMessageDispatcher(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 JSdeserializerequiresdata 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 protobufMessageFrameinside binary frames. - RTVI message from either side:
Frame{ message: MessageFrame{ data: "<JSON string of the RTVI envelope>" } }. JS sendsdata: JSON.stringify(msg). Python wraps outgoing transport messages (OutputTransportMessageFrame/OutputTransportMessageUrgentFrame) withMessageFrame(data=json.dumps(frame.message))and parses incoming withjson.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 (id0 is not encoded). - Audio, server to client:
Frame{ audio: AudioRawFrame{ audio: <PCM16 LE mono bytes>, sample_rate, num_channels } }. The JS client IGNORESsample_rateandnum_channelson receipt (the transport passes the parsed audio tobufferBotAudio; it plays at its configuredplayerSampleRate, default 24000, configurable only in theWebSocketTransportconstructor). 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 asInt16Array: confirmed in the serializer.) - The JS client discards decoded frames that are neither
audionormessage.TextFrameandTranscriptionFrameare in the schema but the TSdeserializedoes not return them. - Python
deserializeof client frames: onlytext,audio,transcription,message,interruptionare accepted;audiobecomesInputAudioRawFrame;messagebecomesInputTransportMessageFrame; theid/name/ptsfields are copied when truthy. PythonserializesendsOutputAudioRawFrameasaudioand 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 baseTransportclass; 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 inReconnectingWebSocket, not fetched). - Connection params accepted by
connect(): onlywsUrl/ws_urlandtoken(snake_case keys are converted to camelCase; each must be a string); any other key throwsUnrecognized connection parameter: <key>.(the docs say anRTVIError).
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):
- Accept a WebSocket upgrade at the URL given in
wsUrl(any path; optional?token=). No subprotocol requirement was found (UNVERIFIED that none is requested). - Speak ONLY binary frames, each a protobuf
pipecat.Frameusing fields 2 (audio) and 4 (message). Decodeaudio(PCM16 mono at the client's recorder rate, 16 kHz by default,sample_ratein the frame is authoritative) andmessage(JSON string, envelope WITH anid). - On
client-ready, reply withbot-readyechoing the requestid(data.version = "2.1.0"(a major >= 2 avoids the client's warning), optionaldata.about). Withoutbot-ready,connect()never resolves (no timeout) and all@transportReady-gated calls (sendText,sendClientMessage, ...) stay unusable. - Stream bot speech as
audioframes: PCM16 LE mono at the client'splayerSampleRate(default 24 kHz). Pace at roughly real time: the transport has no way to clear audio already handed to the browser'sWavStreamPlayer(see interruption below), so over-buffering makes barge-in sound late. - 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-textorbot-output,error. - Answer
client-messagewithserver-response(same id,data: {t, d}) orerror-response, otherwisesendClientRequest()never resolves unless a timeout is set (default UNVERIFIED). disconnect-botand the WebSocket close both mean "end the call". No reply is defined.- Barge-in:
bot-interruptedis not handled by client-js 1.13.1, and the websocket transport source has no interrupt/clear logic (confirmed inwebSocketTransport.ts; it does not referenceWavStreamPlayer; bot audio goes tomediaManager.bufferBotAudio).DailyMediaManager.userStartedSpeaking()callingWavStreamPlayer.interrupt()was not re-fetched in the fact-check (UNVERIFIED who triggers it and whetherinterrupt()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 (protobufFramein 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
prostmessage structs (#[derive(prost::Message)]) matching the field numbers above need noprotoc.prost0.14.4 (updated 2026-06-07, Apache-2.0, 624,317,919 total downloads, repo tokio-rs/prost);prost-build0.14.4 (Apache-2.0, 2026-06-07). Theprotobufcrate (BSD-3-Clause per crates.io API for the newest version) is an alternative; its newest listed version was4.36.2-release, max stable3.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'splayerSampleRate(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 (tokiobroadcast), 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'sRtviSender, in order, on one queue. The bridge switches the adapter's ownauto_eventsoff 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'ssend-textneither).user-transcription: every STT transcript (interimfinal: false, final segmentsfinal: true),user_idempty,timestampISO-8601 UTC from the injected clock.user-llm-text: the whole text of the turn the LLM answers (sent beforeuser-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-outputper TTS chunk (aggregated_by: "sentence",will_be_spoken: true), held untilbot-started-speakingas 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, thenbot-stopped-speaking,bot-tts-stopped,bot-llm-stoppedfor what was running; events of the ended epoch that arrive later are dropped.metricsonce per turn, right afterbot-started-speaking:ttfbofllm(time to first token) andtts(time to first byte),processingofstt(VAD stop to final),turn(end-of-turn delay) andresponse(server-side voice-to-voice), in seconds,modelomitted.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: falseholds 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: falseis not supported (answered with audio, counted).client-message: answered by the application's handler (server-responseechoingtwith the handler'sd), orerror-responseon its error, afterreply_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 latersend-text; a request beyond 8 in flight gets anerror-responseat 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 forbot-ready. - Server (Pipecat
FastAPIWebsocketParams, v1.12.0transports/websocket/fastapi.py):session_timeout(default None),ws_close_timeout(default 0.5 s),allowed_origins(default from envPIPECAT_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_timeoutequivalent (e.g. time to receiveclient-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 protobufinterruptionframe (field 5, unknown to the JS client schema).send-textwithrun_immediately=trueinterrupts and flushes the pipeline before appending. disconnect-bot: ends the pipeline worker; no response.
Timestamps / interim results / tool-call deltas
user-transcription.timestampis a string;final=falsefor interim results (InterimTranscriptionFrame).bot-outputword-level progress:spoken_progress{accumulated_text, remaining_text}("Word-level TTS progress within a spoken segment"); no per-word timestamp fields appear inmodels.py.- Tool calls:
llm-function-call-started/in-progress/stopped(not deltas of arguments); client results return viallm-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_rateon incoming bot audio is ignored by the JS client; 24 kHz assumed by default.- Transport state becomes
readywhen the client SENDSclient-ready, not whenbot-readyarrives; user audio flows beforebot-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-contextare deprecated;bot-outputandllm-function-call-in-progressreplace them.- Server doc claims config/actions in
bot-ready; the model has onlyversionandabout. - Client messages MUST carry an
id(Pipecat'sMessagemodel requires it).
UNVERIFIED (collected)
- Whether any WebSocket subprotocol or specific
binaryTypeis required by the client. - Behavior of the JS client on receiving protobuf field 5 (
interruption) and who callsDailyMediaManager.userStartedSpeaking()/ whatWavStreamPlayer.interrupt()does. - The default timeout of
sendClientRequest()(MessageDispatcher not fetched). - Daily, SmallWebRTC, openai-realtime-webrtc, gemini-live-websocket, moq RTVI carrier details and existence (not fetched).
- Byte-exactness of the constructed JSON examples above and the
timestampstring format. - How Pipecat's example consumes the
tokenquery parameter (the example bot has no token code). - An end-to-end run with a real JS client (not possible in this session); the implementation must do it.
- The example server's WebSocket path and the
startBotAndConnectresponse shape; the effect of_maxMessageSize; baseTransportdefault max size; whether the JSws.on("message")handler catches deserialize errors; relative send order ofbot-llm-textvsbot-transcription; whether the v1.12.0 server ever emitsbot-llm-search-response; license ofprotobuf3.7.2; commit hash1559a684...of tag v1.12.0.
Fact-check log
Fact-checked 2026-10-08: 98 claims checked, 15 corrected. Corrections:
- 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).
raw-audiodata fields: singlebase64Audio, batch usesbase64AudioBatch(list); no model inmodels.py; "Field semantics UNVERIFIED" replaced with the processor code facts.append-to-context: now CONFIRMED absent from the processor dispatch (was inferred from a summary).- Server-initiated events: CONFIRMED to have no
idfield inmodels.py(was UNVERIFIED); payload-less events omitdata(was UNVERIFIED); client messages requireid(new fact). - Reference example endpoints: README says
POST /start;/wsand/api/startcould not be confirmed (moved to UNVERIFIED). frames.proto(server) was labeled verbatim but omitted the regeneration comment block; now stated.- "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. - JS
bot-ready: "no id check" CONFIRMED (was UNVERIFIED). sendClientRequest/connect(): no default timeout inclient.ts; the note that requests "reject only by timeout" corrected (they never resolve without a timeout).dtmf: "needs protocol 2.x" corrected to 2.1.0+ for thebuttonsform; the earlier "button (2.0)" history is only a JS code comment about legacy bots; DTMF is not on the docs page.- Connection param error: confirmed text is
Unrecognized connection parameter: <key>.; theRTVIErrorclass comes from the docs, not from the transport source. - Max message size: base
Transport64 KiB claim not confirmed (UNVERIFIED); the 1 MiB value is never read after being set in the transport file. client-readyerror texts and the exactly-three-integers version format added; the label-drop warning text added.- 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 citetransports/websocket/fastapi.py, which was previously uncited. - Ordering of
bot-llm-textrelative tobot-transcriptionbuffer update was not confirmed by the observer summary; downgraded to UNVERIFIED.bot-llm-search-responseis not inmodels.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-vad6.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)loadssilero_vad.onnx. Other opsets (only 15 and 16 are allowed) loadsilero_vad_16k_op{N}.onnx(16k only).sequence=Trueloadssilero_vad_16k_sequence.onnx.silero_vad_op18_ifless.onnxis 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]. Sosilero_vad.onnxaccepts both. - Opset: 15 or 16 (wiki: "ONNX opset 15 and 16").
- Runtime: onnxruntime is optional. PyPI extra
onnx-cpuinstallsonnxruntime>=1.16.1and 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_commitishmasterat publish time). - Tag
v6.2.3resolves to commit5cd7945676eb32225748052e2e6a0580e4686a08(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 SHA80c5592ef1f4c9ede3e357bbd02eb863358a6a9d(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.onnxat 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, runsha256sum silero_vad.onnx, confirm size 2327524 and git blob hash (git hash-objectgives the blob SHA-1 for the same bytes; it should equal80c5592e...), 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_nameandproducer_versioncould 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 32rule applies to whatever file is loaded, but the code does not prove which generationsilero_vad.onnxis. - 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.sris consumed by nodeEqual_0; outputs come from nodeIf_0(If_0_outputs_0,If_0_outputs_1). The branch onsris inside the graph; theelse_branchholds the STFT (stft.forward_basis_buffer), encoder conv+ReLU blocks (encoder/0..3) and a decoder with anrnnsub-graph. - Therefore
session.run(None, {'input': ..., 'state': ..., 'sr': ...})returns[output, stateN], consistent with the wrapper'sout, 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/outputandonnxruntime.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)
| Name | Direction | Shape | Dtype | Notes |
|---|---|---|---|---|
input | in | (batch, context + window) = (1, 576) at 16 kHz; (1, 288) at 8 kHz | float32 (inferred from torch.zeros/.float(); graph dtype UNVERIFIED) | name CONFIRMED in graph; context prepended each call |
state | in | (2, batch, 128) = (2, 1, 128) | float32 (inferred) | name CONFIRMED; reset_states: torch.zeros((2, batch_size, 128)).float() |
sr | in | scalar | int64 | name CONFIRMED; np.array(sr, dtype='int64'); 16000 or 8000 |
output | out | (batch, 1) per code path | float32 (inferred) | name CONFIRMED; speech probability; returned without reshaping |
stateN | out | (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); thenself._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
Validatorclass in the same file runs the session with onlyinput(nostate/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_thresholdis 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:thenself.triggered = Trueand 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:thenself.temp_end = self.current_sample - Confirm end:
if self.current_sample - self.temp_end < self.min_silence_samples:returns None (keep waiting); otherwiseself.triggered = Falseand returns{'end': ...}withspeech_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 clearstriggeredby itself.reset_states()is called only from__init__or explicitly by the caller. - Consequence: if the stream stops while
triggeredis True, VADIterator emits NOendevent. Onlyreset_states()clearstriggered, and it emits nothing. - Brisvox decision (implemented in the review fixes): the VAD adapter owns the stop-on-close rule.
VadStream::finish()(andVadDetector::finish()) reports aStopwhen speech is in progress, dated to the end of the last speech window plusspeech_pad, never past the end of the last window fed, then resets the stream (reset_states()on the model). Close with no open speech returnsNone.VadStream::reset()discards speech in progress without an event and is for a new, unrelated stream only. The synthetic stop has one event kind: theEndOfStream/Cancelledreason 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 intests/silero.rscloses each fixture withfinish(). - UNVERIFIED: whether any upstream release (including Python
get_speech_timestamps) defines an end-of-stream rule. Only masterutils_vad.pywas 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
ort2.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, withcargo 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 perSileroVad::infercall. 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 theortwrapper'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/cpuinfomodel and cgroup CPU quota). Also batch=1 and N=8/32/128. - (b) Rust
ortbuild, 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 tobrisvox-inference.
- (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
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
stateandcontextper session (never share across sessions). Usesras 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.onnxat 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.onnxat 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.onnxis 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.onnxat v6.2.3: 2327524 bytes, git blob80c5592ef1f4c9ede3e357bbd02eb863358a6a9d(matches the GitHub API), SHA-2561a153a22f4509e292a94e67d6f9b85e8deb25b4988682b7e174c65279d8788e3.- 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 - theIfselects the 16 kHz or 8 kHz subgraph fromsr):- inputs:
inputfloat [batch, samples],statefloat [2, batch, 128],srint64 scalar; - outputs:
outputfloat [batch, 1] (speech probability),stateNfloat [2, batch, 128].
- inputs:
- 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, wrongstateNlength) are errors, not silence or a stale state (silero::tests). - Runtime choice: ONNX Runtime via the
ortcrate.
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>= thresholdcancels it. The fetched reference (above) runs its end trigger only on windows belowneg_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_padis at least half ofmin_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.mdfields 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_ratemust be 8000 or 16000. Frames: 256 samples at 8 kHz, 512 samples at 16 kHz (32 ms).VADParamsdefaults (source constantsVAD_CONFIDENCE,VAD_START_SECS,VAD_STOP_SECS,VAD_MIN_VOLUME):confidence= 0.7 ("higher values make detection more strict", 0..1)start_secs= 0.2stop_secs= 0.2min_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 belowmin_volumecounts as non-speech. - State machine (source): QUIET -> STARTING (on first speaking frame) -> SPEAKING (after
start_secsof speaking frames). SPEAKING -> STOPPING (first non-speaking frame) -> QUIET (afterstop_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_secsdefault 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 assumestop_secs=0.2. - The fetched guide does NOT say
stop_secsis 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".
- No Brisvox measurement exists; UNVERIFIED for our container. Details and the measurement plan:
- 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 asx[..., -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.
- Hidden state:
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.cccontains 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-rscrate 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-vad0.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:
LocalSmartTurnAnalyzerV3available 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.pyat 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"viaort.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)afterexpand_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_endpointreturns a dict with keyspredictionandprobability. - 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-steptruncate_audio_to_last_n_seconds(audio, n_seconds=8)lives inaudio_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.
- Reference code loads
- 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-inferencepool. - 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
trtag 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>.jsonlists speech segments (start,endin 16 kHz samples). Committed:en_two_utterances(2),tr_two_utterances(2),en_tr_mixed(2). Local-onlyfixtures/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.confidence0.7 [V, section 1].
- Label gap: hand-label the 6 committed speech onsets/offsets (10 ms resolution), e.g.
hand_start/hand_endin 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
datefield of commitf766f81, 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 ofmainas "Jan 7" (year omitted there). - Head of
main:f766f81is 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 commit100f018, Sep 11, 2025.
- Release date: 2026-01-07T18:00:39Z (CONFIRMED, HF API
-
HF repo
pipecat-ai/smart-turn-v3main branch files (https://huggingface.co/pipecat-ai/smart-turn-v3/tree/main):File Size Note smart-turn-v3.2-cpu.onnx8.68 MB int8-quantized per README (recommended for CPU) smart-turn-v3.2-gpu.onnx32.4 MB fp32 unquantized; README says ~1% better accuracy than CPU smart-turn-v3.1-cpu.onnx/-gpu.onnx8.68 MB / 32.4 MB older smart-turn-v3.0.onnx8.76 MB oldest -
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 hashaca6a9dc57f624c40984caae7a92965eee9850a034565a189d3e3969e0eb4080(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 referencessmart-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_pathis unset, the code setsmodel_name = "smart-turn-v3.2-cpu.onnx"andpackage_path = "pipecat.audio.turn.smart_turn.data", then loadsimpresources.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-2562bb026316b14...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.onnxrather than requiring a separate download (pyproject/manifest not fetched). - Gotcha (CONFIRMED):
inference.py(smart-turn repo) setsONNX_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.onnxexist), so theinference.pydefault 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
TurnAnalyzerUserTurnStopStrategywithLocalSmartTurnAnalyzerV3, "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 defaultTranscriptionUserTurnStopStrategy; 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-12never mention SmartTurn or bundling. Nearby: 0.0.77 addedLocalSmartTurnAnalyzerV2; 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 Licensein Pipecat'slocal_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_modelfield 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-*.onnxin 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 = 16000in 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.padwith zeros at the start, audio at the end. - Feature extraction (two code paths; equivalence UNVERIFIED):
- Upstream
inference.py:WhisperFeatureExtractor(chunk_length=8), called withsampling_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 vendoredcompute_whisper_log_mel_features(audio_array, do_normalize=True), thennp.expand_dims(log_mel, axis=0).
- Upstream
- 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_lengthdefault 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 filtersmel_filters.T @ magnitudes;clamp(min=1e-10).log10(); floor atmax - 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) oroutputs[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_secsdefault 3.0 s (silence fallback),pre_speech_msdefault 500,max_duration_secsdefault 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: inputaudiofloat32[1, 128000](16 kHz mono, zeros at front), outputprobabilityfloat32[1, 1], opset 18. Different contract frominput_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). Noprovidersargument (ORT default CPU EP). - Reference
inference.pybuild_session: same options but nointra_op_num_threadsset. 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
multilingualbut 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
- Confirm the Pipecat wheel's packaged copy matches the HF file byte for byte (UNVERIFIED).
- Measure Turkish accuracy on our own recorded utterances; the 0.5 threshold is untuned.
- Latency on the target container (Linux, CPU limits), cold and warm.
- 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.
- 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.
- 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-2562bb026316b14a660486a75b1733cd3fbab8c2fd0314dc9af7be49f8cca967e4f(matches the HF LFS oid); fetched by scripts/models/fetch_models.py. - Graph (onnxruntime 1.30.0): input
input_featuresfloat [batch, 80, 800], outputlogitsfloat [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. Backendsmart_turn::ort_backend::SmartTurnOrt(featuresmart-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
ort2.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'sx / 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):
| runtime | cases exact (< 1e-6) | off | verdict flips |
|---|---|---|---|
ort on Windows x86_64 | 12 | none | 0 |
ort on Linux x86_64, AVX-512 + VNNI (WSL2, and the GitHub ubuntu-latest runner for the one case its log shows) | 10 | en_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) | 0 | 4e-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.136 | 2 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):
| plan | max abs difference to the goldens | cases > 1e-3 | verdict flips |
|---|---|---|---|
| ORT_ENABLE_ALL, 4 intra-op threads | 0.0000 | 0/12 | 0 |
| ORT_ENABLE_EXTENDED (QDQ fused to integer QLinear kernels) | 0.0000 | 0/12 | 0 |
| ORT_ENABLE_BASIC / ORT_DISABLE_ALL (Q/DQ kept as float nodes) | 0.1307 | 8/12 | 0 |
| ONNX reference evaluator (numpy, literal semantics; opset raised to 21) | 0.0593 | 8/12 | 0 |
tract 0.23.8, typed plan, no declutter (smart_turn_tract example) | 0.1769 | 9/12 | 0 |
tract 0.23.8, default into_optimized (declutter incl. the Softmax lookup-table fusion, below) | 0.8332 | 12/12 | 10 |
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 inmodels/); it runs the 12 goldens through the crate's bit-exact feature extractor in two plans,typed(into_typed()only: no declutter, no optimizer) andoptimized(into_optimized(), tract's default), and prints max |d| to the goldens, cases > 1e-3, verdict flips and median latency.BRISVOX_TRACT_JSON=tract.jsonmakes 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 -- --bisectruns tract's declutterOptimizerone 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; caseen_tr_mixed/0/complete: golden 0.9403, typed plan 0.8858, fully optimized 0.2492):step probability operator change 1-50 0.8858 (unchanged) constant folding, AddAxis/Cast clean-up 51 0.9466 (+0.061) one Divbecomes aMul(GELUx / sqrt(2)->x * (1/sqrt(2)))57 0.3747 (-0.572) DequantizeLinear -> Softmax -> QuantizeLinearbecomes aLookupTable70 0.2798 (-0.095) the second attention Softmax, same fusion 83 0.4803 (+0.201) the third 96 0.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, whenOp::quantizehas no answer for them, evaluates the whole chain on the 256 possible inputs and stores the result as aLookupTable; it continues through a node whenaxes_mapping(..).is_element_wise_unary()is true.Softmax::axes_mapping(src/ops/nn/softmax/mod.rs) returnsAxesMapping::natural, for whichis_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.passescould 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,DequantizeLinearorLayerNormalizationarm, 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
ortAPI 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-importis 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.
- 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
-
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_versionof 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
mainbranch 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.1as 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. Callort::init_from(path)or setORT_DYLIB_PATHBEFORE any other ort use. RelativeORT_DYLIB_PATHis 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=1for dynamic link. Static is preferred if both exist. - rpath note from docs: Linux needs
-Clink-args=-Wl,-rpath,$ORIGINfor 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 setsnone,webgpu,nvrtx,cuda13,tensorrt,nvrtxaarch64-unknown-linux-gnu:noneonlyx86_64-pc-windows-msvc:directml,webgpu,nvrtx,directml,cuda13,tensorrt,nvrtx,directml(nononerow)aarch64-pc-windows-msvc:directmlonly- No
*-muslrows (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>(requiresstd)pub fn commit_from_memory(&mut self, model_bytes: &[u8]) -> Result<Session>(not on wasm)pub fn with_intra_threads(self, num_threads: usize) -> BuilderResultpub fn with_inter_threads(self, num_threads: usize) -> BuilderResultpub fn with_parallel_execution(self, parallel_execution: bool) -> BuilderResultpub fn with_memory_pattern(self, enable: bool) -> BuilderResultpub fn with_allocator(self, info: MemoryInfo<'_>) -> BuilderResultpub fn with_config_entry(self, key: impl AsRef<str>, value: impl AsRef<str>) -> BuilderResultpub fn with_independent_thread_pool(self) -> BuilderResultBuilderResultis 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];runis&mut self(reason not stated). - Outputs:
let predictions = outputs["output0"].try_extract_array::<f32>()?;(ort.pyke.io home page). run_asyncexists (std feature) and requireswith_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)))?— requiresndarrayfeature (default on).TensorRef<'v, T>=ValueRef<'v, TensorValueType<T>>;TensorRefMutanalogous.
Zero-copy input (CONFIRMED signature, copy behaviour UNVERIFIED)
pub fn from_array_view(input: impl TensorArrayData<T> + 'a) -> Result<TensorRef<'a, T>>inimpl<'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]>orArc<Box<[T]>>. - Ndarray inputs must be standard layout; otherwise an error. Lifetime
'aties 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"; useOMP_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_threadsis ignored andOMP_NUM_THREADSdecides. 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), sowith_intra_threadstakes effect there (docs/LATENCY.md; ignored testmore_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
runon oneSessionfrom 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, orMutex<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=1in 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) aftercommit_from_file. - Inference runs on the dedicated
brisvox-inferencepool, 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; preferbind_outputwhen the shape is known.- Guide: bindings apply to individual
IoBindinginstances, so create one per session worker, not per call. The guide's example usesTensor::<f32>::new(&Allocator::default(), ...), which is NOT the rc.13Tensor::from_arrayAPI; 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-onnx0.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.
- Silero VAD v5 on tract: NOT VERIFIED. All found Rust Silero wrappers (silero, silero-vad-rs, silero-vad-rust) use
- candle:
candle-core0.11.0 (2026-06-26),candle-onnx0.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)
- Runtime version print: rc.13 = ORT 1.28.0 per the tag's dist.tsv; the runtime print is NOT executed.
- Silero opset 16 load test against the bundled runtime (not yet run).
- Actual CI download/link per target, CPU-only Windows selection, libc++ in target images; no musl rows.
- Whether pyke CDN binaries are OpenMP builds; ort default thread counts. Official ORT defaults are confirmed (see Threading section).
- Measured 1-thread session latency and thread count on the target container: NOT DONE.
- Concurrent
runon one session: treat as unsupported (one session per thread). - Per-run allocations: whether
from_array_viewcopies, and whether warmrun_bindingis allocation-free (counting-allocator test above). - 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 (featurelog, 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:allocfeature 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, needsfft_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_sizeis "size of input data in frames" for Async (docs), "desired chunk size in frames" for Fft.PolynomialDegree: Cubic, Quadratic, Linear.Fft::newuses BlackmanHarris2 window and targets sub-chunks of ~256 frames (sub_chunks = (chunk_size/256).max(1)in source), "which keeps the resampler delay low".Fftalso hasfft_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; invalidchunk_sizeis 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 readinput_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_relativefinite and >= 1.0 (use 1.0 for fixed ratio). - Recommendation for Brisvox (fixed ratios, mono, 20 ms):
FftwithFixedSync::Input(orBoth; 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). UseAsync::new_sinconly 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;Defaultimplemented; fluent setters exist (4.0.0 changelog; exact names UNVERIFIED). PassingNoneinstead ofSome(&indexing)is allowed by the signature.- Fixed input chunk: feed exactly
input_frames_next()frames per call. Partial last chunk: setindexing.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). DefaultNone= read the full required count. - Zero-alloc steady state:
process_into_bufferdoes 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 unlessactive_channels_maskis set. Never useprocess/process_allon 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.SincInterpolationParametersfields:sinc_len("256 is a good starting point"),f_cutoff: Option<f32>(None = auto viacalculate_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 usesnew(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
Sampleis implemented only forf32andf64. No direct i16 processing.- Convert via audioadapter-buffers
number_to_float::InterleavedNumbers(alsoSequentialNumbers): wraps&[i16]/&mut [i16]and exposesAdapter<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 viaAdapterMutuseswrite_sample_unchecked()returning abool(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 overT: 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:
Fftlists Send + Sync auto traits (docs);AsyncUNVERIFIED.SamplerequiresSend. - Not fetched: audioadapter 5.0.0 trait method signatures in detail,
SizeError/ResamplerConstructionErrorvariants, benchmark numbers (none in fetched pages).
Risk register (each risk → mitigation → proving test)
| # | Risk | Mitigation | Proof |
|---|---|---|---|
| R1 | Cross-session state mix-up in inference | per-session state, no batching | inference pool and VAD tests |
| R2 | CPU work stalls tokio → latency for all | dedicated pool; performance review | inference pool tests, in-process load simulation |
| R3 | Provider concurrency limits cap capacity before our code | per-provider semaphores, admission | admission and budget tests, capacity benchmark |
| R4 | One crash drops all calls | per-session supervision, panic isolation, drain | session manager isolation and drain tests |
| R5 | False barge-in from echo/backchannels | echo guard, min words, backchannel lists | interruption policy tests |
| R6 | Speculative run triggers side effects | hold SideEffect until commit | speculation tests |
| R7 | Context contains unplayed text after interrupt | word-timestamp truncation | played-text truncation tests |
| R8 | Stale audio after interrupt (transport buffers) | epochs + transport clear/mark | turn epoch tests, telephony mark tests |
| R9 | Context growth in long calls | windowing/summarization | long-call context tests |
| R10 | Turkish quality (turn model, STT/TTS, normalization) | verify support, Turkish fixtures | Smart Turn tests, Turkish quality pass |
| R11 | API facts from memory are wrong/outdated | facts fetched from current provider documentation and cited in docs/providers/ | all provider adapters |
| R12 | Benchmark unfairness vs Pipecat | identical providers/settings, published scripts | Pipecat baseline bot |
| R13 | Privacy/compliance of recordings | opt-in recording, redaction, retention | recording and privacy tests |
| R14 | SDK hop adds latency | out-of-process but off audio path; measured | agent protocol and SDK benchmarks |
| R15 | Twilio 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 text | max(lead, lead_margin) is the fallback rule; stale Mark after Clear to be stopped in the transport stage | live Twilio measurement (needs keys) |
| R16 | One 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 path | measure 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) |
| R17 | A provider closes an idle TTS socket (Cartesia: 5 min) and the next turn pays a connect | reconnect 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) |
| R18 | A provider outage or a flaky provider fails every call, and retries multiply the load on it | per-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 `[stt | llm |
| R19 | The 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 differ | measured 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 measured | brisvox-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.
| Kind | Turkish | English |
|---|---|---|
| Integers | 1.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 |
| Decimals | 3,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ş dokuz | 3.5 -> three point five (fraction digit by digit) |
| Negative | -5 -> eksi beş | minus five |
| Ordinals | 3. 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ında | 3rd -> third, 22nd -> twenty-second, 1000th -> one thousandth |
| Percent | %15 / 15% -> yüzde on beş | 15% -> fifteen percent |
| Currency | 150 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 |
| Dates | 12.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 |
| Times | 09: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 otuzda | 9: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ört | 555-123-4567 -> five five five, one two three, four five six seven |
| Versions / addresses | sürüm 2.0.1 -> sürüm iki nokta sıfır nokta bir; v2.0.1 unchanged | version 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 otuzda | not 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 write3.5(3.5->üç virgül beş).1.250is 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 readbir 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. Capitalas a sentence end (the pipeline's rule, deliberately unchanged), sokızım 5. Cadde üzerindeis one chunk only while the segmenter does not cut there. When it cuts (pinned bya_capitalised_ordinal_noun_split_by_the_segmenter_is_a_documented_limit), the first chunk ends in5.and is readbeş.; lowercase nouns (5. cadde) are never split. - Suffix after a currency is dropped (
150 TL'ye->yüz elli lira). The suffix ofTL'yeis inflected for the letters "te-le"; re-inflectinglira(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 ...); Englishapprox.,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.2024is 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-15is 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.2026and12/03/2026-15/03/2026read 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 followssaat(saat 09.00-17.00->saat dokuz-on yedi); withoutsaatboth 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:9is not a time. The Turkish dotted form14.30is also a decimal point (10.30 TL), so it is a time only after the wordsaat(Saat 14.30 gibi) or with a time case suffix (10.30'da,'dan,'a,'ye...). Ambiguity kept:Fiyat 12.50'yereads as a time;7.30alone isyedi virgül otuz. - Phone numbers are recognised by shape:
+and 8-15 digits, a leading0with 10-11 digits, in Turkish a 10-digit mobile number starting with5(bare or532 123 45 67) and a 7-digit444call-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 (1234567890is 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 000and 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 TLfor 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 thanUS(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,beforeorafter, not before a unit,%or currency, are read as a year (in 1999->in nineteen ninety-nine);1990s,1990's,1900s,2000sare decades. Other four-digit numbers stay cardinals (1995 people). - Large numbers go up to
kentilyon(10^18) in Turkish andquintillionin English (u64::MAXis 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). FeedPlayedTextthe 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: feedingPlayedTextthe 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,PlayedTextneeds 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 spacingotuz daalone). Aura-2 was not checked (no Turkish voice). Without the normaliser, Cartesia reads14.30'dadigit 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, licensecc-by-4.0, 16 kHz audio, transcripts in TSV. - Files are public over plain HTTPS
resolveURLs (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
| Source | Languages | License | Anonymous HTTPS | Smallest unit to fetch | Transcripts | Verdict |
|---|---|---|---|---|---|---|
| FLEURS (HF google/fleurs) | en_us, tr_tr (+100) | CC-BY-4.0 (card); text from FLORES CC BY-SA 4.0 | yes, not gated | dev.tar.gz 171 MB (en) / 199 MB (tr); read only a prefix | yes, TSV | PRIMARY |
| Common Voice (Mozilla Data Collective) | en, tr | CC0 historically (NOT re-verified now) | NO, account + terms | n/a | yes | rejected |
| LibriSpeech (OpenSLR 12) | en only | CC BY 4.0 | yes | dev-clean.tar.gz 337 MB | yes (in tar) | English fallback |
| MediaSpeech (OpenSLR 108) | fr, ar, es, tr (no en) | CC BY 4.0 | yes | TR.tgz 618 MB | yes | Turkish fallback, YouTube copyright caveat |
| ISSAI Turkish Speech Corpus (HF) | tr only | HF card says MIT; arXiv/ISSAI says CC BY 4.0 (inconsistent, UNVERIFIED which is right) | yes | one 218 h tarball (size not fetched) | yes | rejected (huge, license inconsistency) |
| Tatoeba audio | many | per-contributor, mixed | yes, per file | single mp3 | sentence text separate | rejected (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",sha70bb2e84...,lastModified2026-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 servesaudio/wav). UNVERIFIED: 16-bit PCM mono (assumed from FLEURS WAV convention; check withwavemodule). num_samplesis 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 useraw_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:
| Path | Bytes | sha256 (LFS oid) |
|---|---|---|
| data/en_us/audio/dev.tar.gz | 171,250,900 | 2658fda72f199e12676ecac9415094667a4e14e149b146e568ea00b2a2f0954c |
| data/en_us/audio/test.tar.gz | 289,851,356 | d9c2e37b41aacd41bc283554a0a82b5476b36887049774ecb2819dcaaa55a356 |
| data/en_us/audio/train.tar.gz | 1,380,572,241 | 5f4491948c2bd29ac00f4b8afae2378f0a1dcdde4041b5cd284a80dff01fa9f5 |
| data/tr_tr/audio/dev.tar.gz | 198,683,867 | 7476fc01bc50750f2854dbc1b4fa285f00092808e7e43244e0b978e031cbe712 |
| data/tr_tr/audio/test.tar.gz | 464,259,241 | a7db7a681e4460d2922626bf2037ddc1a9146efba27d78bfc9543250eabc3a09 |
| data/tr_tr/audio/train.tar.gz | 1,468,477,481 | bb8f81d19282cab8068b915265f60147f84127d37c71ad920fd45409ba5072aa |
| data/en_us/{dev,test,train}.tsv | 213,065 / 367,864 / 1,414,907 | not fetched |
| data/tr_tr/{dev,test,train}.tsv | 191,300 / 447,909 / 1,460,325 | not 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:
- Tar prefix streaming (recommended).
.tar.gzis a sequential stream, so the first N MB decompress to the first complete members. Fetch with stdliburllib.request(follows the 302 to the CDN), read only the first ~12-16 MB, close the connection, decompress withzlib.decompressobj(wbits=31), parse withtarfile.open(mode="r|")and ignore the truncation error on the last partial member. Optional headerRange: 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 scriptfleurs.pymatches onaudio_path.split("/")[-1], so members may sit under adev/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 indev.tsvand keeping those withnum_samples <= 96000. - Dataset viewer API (anonymous, per-row):
https://datasets-server.huggingface.co/first-rows?dataset=google%2Ffleurs&config=en_us&split=validationreturned JSON withaudio[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 audiosrcis a signed URL that expires and cached assets may be emptied ("you may have to call /rows again"). The/rowsendpoint (offset/length, max 100) returned HTTP 500 once in our test andfirst-rowsworked. The WAV is re-rendered by the viewer, so its bytes are NOT guaranteed stable: only use for exploration, never for checksummed fixtures. - 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 tagmit. 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>; exporthttps://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
- Run the fetch script once; record: HTTP status chain for the
resolveURL, whether Range is honoured, tar member names, WAV header (channels, bit depth), first-member order, leading/trailing silence of chosen clips. - Pick clips by
num_samples <= 96000from the dev TSV, balance genders where possible, compute end-of-speech by energy gate, writemanifest.json. - 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.
- 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
| Artifact | Where | Name | Built and published by |
|---|---|---|---|
Prebuilt brisvox-server | GitHub Release assets | brisvox-server-vX.Y.Z-<target>.tar.gz (.zip on Windows), SHA256SUMS | release.yml: binaries, github-release |
| Container image | ghcr.io/bilal07karadeniz/brisvox-server | tags vX.Y.Z and latest, linux/amd64 + linux/arm64 | release.yml: docker-build, docker-push, docker-manifest |
| Rust crates | crates.io | eleven crates, see below; brisvox is the umbrella | release.yml: crates-dry-run, publish-crates |
| Python SDK | PyPI | brisvox-agent, then the umbrella brisvox | release.yml: python-dist, publish-pypi |
| TypeScript SDK | npm | @brisvox/agent | release.yml: npm-pack, publish-npm |
| Browser module | npm | @brisvox/web | release.yml: npm-pack, publish-npm |
| npm umbrella | npm | brisvox (after the two above) | release.yml: npm-umbrella, publish-npm-umbrella |
| The book | GitHub Pages | https://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:
| Registry | Package | Source | What it is |
|---|---|---|---|
| crates.io | brisvox | crates/brisvox | Re-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. |
| PyPI | brisvox | sdk/python-umbrella | Depends on brisvox-agent==X.Y.Z and re-exports its public API (from brisvox import Agent). |
| npm | brisvox | sdk/npm-umbrella | Depends 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):
brisvox-corebrisvox-audiobrisvox-inferencebrisvox-agentbrisvox-providersbrisvox-simbrisvox-transportbrisvox-enginebrisvox-agent-linkbrisvox-serverbrisvox(the umbrella: it depends onbrisvox-agent,brisvox-core,brisvox-providersandbrisvox-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
- Bump the version in one commit:
versionin the workspaceCargo.tomland theversion = "..."of the elevenbrisvox*entries in its[workspace.dependencies];sdk/python/pyproject.tomland__version__insdk/python/brisvox_agent/__init__.py;sdk/python-umbrella/pyproject.toml(itsversionand thebrisvox-agent==X.Y.Zpin) and__version__insdk/python-umbrella/brisvox/__init__.py; and insdk/typescriptandsdk/webrunnpm version X.Y.Z --no-git-tag-version(it also updates the lock file). Insdk/npm-umbrellarun the same and set both entries underdependencies(@brisvox/agent,@brisvox/web) toX.Y.Z. Thencargo checksoCargo.lockfollows. - Write the changelog. In
CHANGELOG.mdmove the entries of[Unreleased]under a new## [X.Y.Z] - YYYY-MM-DDheading and update the two compare links at the bottom. The text of that section becomes the GitHub Release notes. - 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 - Merge to
mainand wait for thecirun on that commit to finish green. The release workflow refuses a tag whose commit has no successfulcirun. - 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. - Tag and push the tag.
A version with a hyphen (git tag -a vX.Y.Z -m "brisvox X.Y.Z" git push origin vX.Y.Z0.2.0-rc.1) is a pre-release: the GitHub Release is marked as one and the image does not get thelatesttag. - 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 successfulcirun 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 ofbrisvox-agentand of the umbrellabrisvox,twine check --stricton both, and runs the umbrella's tests with both wheels installed (the umbrella's pin onbrisvox-agentmust resolve to the wheel of this release).npm-pack: tests andnpm packofsdk/typescriptandsdk/web.npm-umbrella: the static checks ofsdk/npm-umbrella,npm packof it, and a fresh install of its tarball together with the two tarballs ofnpm-pack, importingbrisvoxandbrisvox/web.binaries:cargo build --release --lockedon a native runner per target, strip, a smoke test (--version,checkof a reference definition), andscripts/package_release.py(archive plus.sha256).docker-build: the Dockerfile forlinux/amd64andlinux/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 andSHA256SUMS.docker-pushanddocker-manifest: build each platform again (from the build cache), push it by digest to GHCR with the repository'sGITHUB_TOKEN, then create one multi-platform image with the tagsvX.Y.Zandlatest.publish-crates:scripts/publish_crates.sh, which publishes the crates in the order above (the umbrellabrisvoxlast), skips versions that already exist and waits out crates.io's limit on new crates.publish-pypi:pypa/gh-action-pypi-publishwith trusted publishing, environmentpypi:brisvox-agent.publish-pypi-umbrella: the same forbrisvox, environmentpypi-brisvox, afterpublish-pypi(PyPI allows one pending publisher per repository + workflow + environment).publish-npm:npm publish --provenance --access publicof the tarballs made bynpm-pack(@brisvox/agent,@brisvox/web).publish-npm-umbrella: the same forbrisvox, only afterpublish-npmsucceeded (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.mdpoints 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-serverstarts 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 theorg.opencontainers.image.sourcelabel).
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.
-
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. -
Create a token at https://crates.io/settings/tokens: scopes publish-new and publish-update, crate patterns
brisvox-*andbrisvox(the patternbrisvox-*does not match the umbrella crate's plain name), an expiry of a few weeks. -
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. -
Push the first tag.
publish-cratespublishes 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.) -
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:Field Value Repository owner bilal07karadenizRepository name brisvoxWorkflow filename release.ymlEnvironment leave empty -
Delete the secret
CARGO_REGISTRY_TOKENand revoke the token. From the next release the job authenticates withrust-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:
| Field | Value |
|---|---|
| PyPI project name | brisvox-agent for the first, brisvox for the second |
| Owner | bilal07karadeniz |
| Repository name | brisvox |
| Workflow name | release.yml |
| Environment name | pypi 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).
-
Create the organization
brisvoxon https://www.npmjs.com/org/create (free for public packages); that owns the@brisvoxscope. -
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
@brisvoxscope and the unscoped packagebrisvox: a token limited to@brisvoxcannot publishbrisvox, which is outside the scope. A granular token can only name packages that already exist, andbrisvoxdoes 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 secretNPM_TOKEN. -
Push the first tag.
publish-npmpublishes@brisvox/agentand@brisvox/webwith--provenance --access public, thenpublish-npm-umbrellapublishesbrisvoxthe same way. -
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):Field Value Organization or user bilal07karadenizRepository brisvoxWorkflow filename release.ymlEnvironment name leave 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/webandbrisvox). -
Delete the secret
NPM_TOKENand 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'srepository.urlmust match this repository exactly;package.jsonhas 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.