Full-Duplex Guide#
This page consolidates three parts of the SGLang-Omni developer reference on real-time voice conversation, covering three layers: how the system works (the Full Duplex Interactive Model), how to benchmark it (the Full-Duplex-Bench runbook), and how to interpret the benchmark numbers (the v1.0 unified metrics). Together they form the complete chain: Service → Measurement → Interpretation.
This page covers full-duplex serving and benchmarking in SGLang-Omni in three parts: the Full Duplex Interactive Model, the Full-Duplex-Bench runbook, and the v1.0 unified metrics specification.
Full Duplex Interactive Model#
SGLang-Omni serves a live voice conversation on /v1/realtime in two ways. A native full-duplex model such as MiniCPM-o 4.5 listens and speaks at the same time and decides itself when to talk. The streaming cascade composes independent speech recognition, language model and speech synthesis services into a turn-based conversation, and the server adds voice activity detection, the conversation history, turn taking and interruption. Both run as a Session on the server, a stateful conversation described under Session Graph.
Model |
Interaction |
Launch |
|---|---|---|
MiniCPM-o 4.5 |
Native full duplex, audio and camera input |
|
PersonaPlex |
Native full duplex, audio input |
|
Streaming cascade |
Turn-based, with interruption |
Native Full Duplex: MiniCPM-o#
MiniCPM-o 4.5 is served for full duplex with its session variant; MiniCPM-o covers its installation and chat usage.
python -m sglang_omni.cli serve \
--model-path openbmb/MiniCPM-o-4_5 --variant session \
--enable-realtime --port 8000
--variant session selects the full-duplex pipeline, which serve refuses to start without --enable-realtime. Every setting below is changed with a flag of the same name, for example --max_sessions 4, and config resolve with the same arguments prints the whole configuration:
sgl-omni config resolve --model-path openbmb/MiniCPM-o-4_5 --variant session
Serving |
Flags |
|---|---|
Normal |
None. The defaults match the MiniCPM-o demo |
Repeatable output for regression and parity recordings |
|
Setting |
Default |
Meaning |
|---|---|---|
|
2 |
Conversations at the same time. Further connections get HTTP 503. The thinker and talker reserve GPU memory for this many full-length conversations, so raise it only as far as the GPU has room. Startup warms up perception at each batch size up to this value |
|
|
Perception and speech handle the ready second of every conversation in one call. |
|
checkpoint default |
Voice used when a session sends no reference |
|
2 GiB |
Memory the speech stage may hold per conversation. A conversation that needs more is closed and the others keep running |
|
|
Precision of the voice decoder’s flow model: |
|
|
Compile the voice decoder’s flow model with |
|
5 |
Flow-matching steps per audio chunk; fewer steps decode faster at some cost in voice quality |
|
|
Replay the voice decoder’s flow from the CUDA graphs described below; |
|
22.5 |
How long an idle speech stage waits for the other conversations’ next second so it can decode them together; the wait ends once every open conversation has one. |
|
|
Compiles every decode graph batch size; adds minutes to startup |
|
|
Default sampling when a session does not set its own; |
|
|
Camera-frame limits per unit (1 s of audio); |
While the server starts, the speech stage records the voice decoder as CUDA graphs for up to 8 conversations decoded together, or max_sessions if lower; the graphs hold GPU memory and add to startup time, and a voice whose reference audio is longer than the default voice’s is decoded without them. speech.enable_chunk_cuda_graph: false turns them off.
Speech settings also accept dotted CLI flags, such as --speech.dtype float16 --speech.n_timesteps 10. Stage settings use --speech.factory.<field> or --speech.process. The automatic thinker KV budget follows thinker.engine.dtype and thinker.engine.kv_cache_dtype; block-quantized KV caches require an explicit thinker.engine.kv_cache_bytes budget.
A session holds at most 8192 tokens of history, which is the model’s trained context length. When that fills, the server sends context_exhausted and closes the session.
The full-duplex server is ready when this returns JSON containing "native_full_duplex":true:
curl --fail http://localhost:8000/v1/realtime/capabilities
The results should be as follows:
{"model":"openbmb/MiniCPM-o-4_5","interaction":"native","native_full_duplex":true,"proactive_output":false,"turn_control":[null],"client_commit":false,"input_modalities":["audio","image"],"output_modalities":["audio","text"],"input_audio_format":{"type":"audio/pcm","rate":16000},"output_audio_format":{"type":"audio/pcm","rate":24000},"native_unit_ms":1000,"first_unit_ms":1000,"microturn_ms":"variable","tail_policy":"pad","supports_server_interrupt":false,"supports_truncate":true,"supports_cancel":true,"supports_resume":false,"partial_style":"append_only","pressure_policy":"reject","strict_order":true,"sampling_parameters":["greedy","temperature","top_k","top_p","repetition_penalty","listen_prob_scale","force_listen_count","max_new_tokens_per_unit","repetition_window_size","talker_temperature","talker_repetition_penalty"],"supports_reference_audio":true,"supports_instruction_updates":false,"supports_sampling_updates":false,"input_image_format":{"types":["image/jpeg","image/png"],"max_bytes":524288,"max_frames_per_unit":4,"max_slice_nums":9},"limits":{"max_input_bytes":1920000,"max_output_bytes":33554432,"max_history_chars":65536,"cleanup_timeout_s":30,"session_update_timeout_s":60,"idle_timeout_s":300}}#
The duplex server only accepts /v1/realtime requests; other chat completions or voice cloning against it fail.
Browser demo
With the full-duplex server running, start the demo page in another terminal:
python playground/realtime/app.py --api-base http://127.0.0.1:8000 --port 8080
Thanks to the OpenMoss team for the demo page.
Once the terminal prints Running on, open http://localhost:8080 and click Start talking to talk through your microphone; the camera can be turned on during the call. If the server is remote, first run ssh -N -L 8080:127.0.0.1:8080 USER@SERVER on your own machine. Open the page through localhost, or the browser will not grant microphone access.
The settings panel shows only the controls the deployment declares in /v1/realtime/capabilities: the system prompt, the presets and a reference voice when supports_reference_audio is true (a preset pairs a MiniCPM-o duplex prompt with a voice, so no other backend gets them), a named voice when voices is listed, turn detection when turn_control lists server_vad or semantic_vad, camera detail when input_image_format allows more than one slice, and one numeric field per key in sampling_parameters, labelled for that backend (max_new_tokens_per_unit is tokens per 1 s unit on a native model and the reply token limit on a turn-based one). An empty field is not sent, so the deployment’s default applies. The page sends every setting in its first session.update, so a change applies to the next conversation. Everything under sglang (sampling, voice, reference audio, image detail) is fixed once the session is open. Turn detection and output_modalities may change in a later session.update, and instructions only on a deployment that supports instruction updates, which is the cascade with a system_prompt node; MiniCPM-o, PersonaPlex and Qwen3-Omni freeze them with the first session.update. Settings are kept in the browser per deployment model. The download button in the top bar saves the conversation trace; attach it when reporting a problem.
The stop button in the call dock follows supports_cancel from session.updated. Where it is granted, as on MiniCPM-o, the Qwen3-Omni session graph and the cascade, the button sends response.cancel, shows Stopping until the server’s response.done for the reply arrives, and Stopped when that status is cancelled. A reply that ended on its own in the meantime shows no stop. This page sends no conversation.item.truncate, so a turn-based history keeps what the server delivered. Where it is not granted, as on PersonaPlex and Nemotron VoiceChat, the button is labelled as a local mute: it sends nothing and the reply is not cancelled on the server. In both cases the page then plays no model audio, under any response id, until you speak again: on a turn-based deployment the server’s input_audio_buffer.speech_started lifts the mute, on a native one the microphone’s own level does, since a stopped model may continue its answer under a new response and a new response id alone is no proof of a new answer. Speaking over the model is left to the model on a native deployment.
An empty field falls back to the deployment’s default, which lives here:
Backend |
Sampling |
Turn detection and voice |
|---|---|---|
MiniCPM-o |
|
No turn detection. The voice is the deployment’s |
PersonaPlex |
|
No turn detection. Voice |
Qwen3-Omni |
|
|
Streaming cascade |
The profile’s node options; |
The |
Full-duplex protocol
Clients connect to /v1/realtime over WebSocket. They send 16 kHz mono PCM16 audio in input_audio_buffer.append; the server returns 24 kHz audio in response.output_audio.delta and text in response.output_audio_transcript.delta. The model decides once per second of audio whether to keep listening or to speak.
After
session.created, sendsession.updateand wait forsession.updated.Send audio with
input_audio_buffer.appendat the pace it is captured; replies arrive while you send.When the audio ends, send
sglang.input_audio.end, wait forsglang.input_audio.drained, then sendsession.close.
When the session ends, the server closes the WebSocket with one of these codes:
Close code |
Meaning |
Close reason |
|---|---|---|
|
Normal close |
|
|
A server limit was reached |
|
|
Server error |
For example |
If a session has no input and no output for realtime_idle_timeout_s, the server closes it with idle_timeout. The pipeline configuration defaults to 300 seconds for native sessions and 6 hours for audio Session graphs, including the streaming cascade. WebSocket pings do not count as activity. Set realtime_idle_timeout_s in the pipeline YAML or override it with --realtime-idle-timeout-s. The server also waits for the first session.update up to realtime_session_update_timeout_s (default 60 seconds), which can be overridden with --realtime-session-update-timeout-s.
For a Python client, see warmup in playground/realtime/app.py.
Session settings go in the sglang field of session.update, before the first audio packet, and stay fixed for the session:
Setting |
Field |
Notes |
|---|---|---|
Voice |
|
|
Sampling |
|
For example |
Image detail |
|
Higher is sharper but accepts fewer frames per second |
Send camera frames with sglang.input_image.append: a base64 JPEG or PNG in image, and its position on the audio timeline in sglang.t_ms. By default up to 4 frames per second are accepted; session.updated reports the actual limit.
response.cancel ends the reply in progress: the turn ends, the server drops the audio of that reply it has not sent yet, and response.done arrives with status cancelled and reason interrupted about one second later, once the unit that was running completes. The unit the client did not receive is dropped from the session history, and the thinker then listens on every unit until the user speaks again, so the model does not resume the stopped answer. A cancel with no reply in progress, or a second cancel of the same reply, does nothing. conversation.item.truncate with audio_end_ms cuts a reply the client stopped, or one the model ended, at the last unit whose audio fully played, never past what the client received.
Full-Duplex-Bench measures a native full-duplex deployment.
Native Full Duplex: PersonaPlex#
PersonaPlex is served for full duplex with its session variant; PersonaPlex covers its installation and offline usage.
python -m sglang_omni.cli serve \
--model-path nvidia/personaplex-7b-v1 --variant session \
--enable-realtime --port 8000
Setting |
Default |
Meaning |
|---|---|---|
|
1 |
Conversations at the same time. Further connections get HTTP 503. The LM keeps |
|
|
Packaged voice name, or the path of a voice recording, used when a session names no voice |
|
the offline role prompt |
Role prompt used when a session sends no |
|
the offline defaults |
Default sampling when a session does not set its own: |
|
2 |
Mimi decode replays CUDA graphs for units of up to this many frames, for up to |
|
1 |
Conversations whose audio Mimi encodes at the same time |
|
512 MiB |
Memory for the voice prompts loaded so far |
Every 80 ms of input has to pass Mimi encode, the LM and Mimi decode within 80 ms, or the conversation falls behind; concurrent conversations share that budget, and the LM decodes synchronously. The first conversation after the server starts waits several seconds while the LM runs its first steps. A conversation holds at most the LM context, 8192 positions or about 10.8 minutes (--lm.engine.context_length changes it); the unit that would pass it ends the session with context_exhausted.
The full-duplex server is ready when this returns JSON containing "native_full_duplex":true, "native_unit_ms":80 and the packaged voices under voices:
curl --fail http://localhost:8000/v1/realtime/capabilities
The protocol follows the MiniCPM-o steps, with these differences:
Setting |
Field |
Notes |
|---|---|---|
Audio |
|
24 kHz mono PCM16 in, 24 kHz out; the server pads a trailing partial 80 ms unit |
Role prompt |
|
Replaces the deployment’s role prompt |
Voice |
|
One of the |
Sampling |
|
The fields listed under |
The whole session is one response: response.created arrives with the first output, each unit brings 80 ms of agent audio (160 ms for the first), and response.done follows the end of the input. The transcript is the words the model speaks, streamed word piece by word piece. response.cancel and conversation.item.truncate are answered with not_supported, and the capabilities grant neither supports_cancel nor supports_truncate: a client can only mute its own playback, and the model keeps talking. The session variant only accepts /v1/realtime requests; use the default pipeline for /generate and chat completions.
Streaming Cascade#
A profile names the services the cascade calls; one command starts them and the cascade, and the conversation is served on /v1/realtime and /v1/session.
Prerequisites
Install sglang-omni by following Installation, then install the network clients the nodes use:
python -m pip install websockets httpx
The profile qwen3-asr-qwen3-tts-base-clone runs three Qwen models on one GPU: Qwen3-ASR for recognition, Qwen3-4B for the answers and Qwen3-TTS Base speaking in a voice cloned from reference_zh.wav in the profile’s directory, with Qwen3-ForcedAligner finding where each word of the reply ends in its audio and Smart Turn v3.2 deciding when the user has finished speaking. Install the two speech models’ dependencies as their cookbooks say, then download the checkpoints (all public, no token):
hf download Qwen/Qwen3-ASR-1.7B
hf download Qwen/Qwen3-4B-Instruct-2507
hf download Qwen/Qwen3-TTS-12Hz-1.7B-Base
hf download Qwen/Qwen3-ForcedAligner-0.6B
hf download pipecat-ai/smart-turn-v3 smart-turn-v3.2-gpu.onnx
Launching
The profile’s aligner node asks a forced-alignment worker where each word of the reply ends in its audio, so an interrupted reply can be cut at the last word heard. Start the worker first, from a separate virtual environment set up as The forced-alignment worker describes:
PYTHONPATH=. python examples/streaming_cascade/alignment_worker.py --model Qwen/Qwen3-ForcedAligner-0.6B --device cuda:0 --language English --port 8010
Then start the cascade:
sgl-omni serve --config examples/streaming_cascade/profiles/qwen3-asr-qwen3-tts-base-clone/launch.yaml --enable-realtime --port 8765
Then open the browser page and talk, as Voice Conversation describes:
python playground/cascade/app.py --api-base http://127.0.0.1:8765 --port 8090
One command starts everything: the profile’s services are started in order and waited for, a service already answering on its port is reused, and the cascade starts once every service and the worker answer; stopping the server stops the services it started. The profile’s turn_detection node ends a turn only when Smart Turn takes a pause for its end, so a pause in the middle of a sentence does not cut it, as Turn Detection describes. When an interrupted reply is cut, the profile’s aligner keeps the words the listener heard: the forced aligner’s word ends for the sentences whose audio was complete, and its own recognizer’s transcript of the delivered audio for the sentence it was cut in. The page sends the position it played with conversation.item.truncate and marks the heard part of the reply. Profiles lists the other bundled profiles, such as qwen3-asr-qwen3.8-27b-vivian with Qwen3.8-27B on two GPUs.
To use services you already run, drop their services entries and point the nodes’ base_url at them; a command-line override such as --session_nodes.llm.base_url http://127.0.0.1:9000/v1 changes one field without editing the file. The rest of this section lists every launch mode and what each profile needs started beforehand; Configuration, Node Reference and Compatibility and Constraints list every setting and the settings that cannot be combined.
One command with a profile
The default CustomVoice cascade uses Qwen3.8-27B with thinking off on GPU 0 and Qwen3-ASR/Qwen3-TTS on GPU 1. The audio-file example in examples/streaming_cascade/run.py uses this profile when --config is omitted.
sgl-omni serve --config examples/streaming_cascade/profiles/qwen3-asr-qwen3-tts-vivian/launch.yaml --enable-realtime --port 8765
--enable-realtime mounts /v1/realtime, /v1/realtime/capabilities and /v1/session; without it the server starts the cascade but serves none of them.
The server starts the profile’s services one after another, in the order they are listed, before it starts the cascade. For each entry it first requests http://127.0.0.1:<port>/v1/models: a service that answers 200 is reused and left running when the server stops, and the log shows service 'tts' already serving on :8005. Otherwise it runs command with its own environment plus the entry’s env, in the directory sgl-omni serve was started from, and polls the port every second for up to 600 s. A service that exits first fails the start with service '<name>' exited with code <n> before :<port> answered; one that does not answer in time fails it with service '<name>' did not answer on :<port> within 600 s. The cascade itself must then start within SGLANG_OMNI_STARTUP_TIMEOUT seconds (default 600).
While the server runs, a service it started that exits stops the server with service '<name>' exited with code <n>. Stopping the server sends SIGTERM to every service it started and SIGKILL to any still running 15 s later.
Relative paths in a service command and in a node’s reference_audio_path resolve against the directory sgl-omni serve was started from; the bundled profiles assume the repository root.
A service’s port appears in three places that must agree: the --port inside its command, its port, and the base_url of the node that calls it. To move a service, change all three in the profile. If the server’s own --port is taken, the server logs Port <n> is already in use and serves on a free port it names in Using port <m> instead; set SGLANG_OMNI_STRICT_PORT=1 to fail instead.
Services you run elsewhere
Remove the service’s entry from services and set the node’s base_url and model to the running service. openai_transcription, openai_chat and openai_speech work with any OpenAI-compatible transcription, chat completions and speech endpoint; api_key_env names the environment variable holding its bearer token. A services entry is only for a service on this machine, since its readiness check requests 127.0.0.1. The node still probes the service at startup, so a wrong base_url fails the start (see Verifying a Deployment).
A model inside the server as a stage
A node written stages: [...] runs a model inside this server instead of calling a service. Two stage routes exist: vad, the voice activity detector, and asr, Nemotron 3.5 streaming ASR. Any other stage name fails the load with no stage group serves the stages [...].
nemotron-stage-qwen3-tts runs Nemotron 3.5 ASR as the asr stage node reading input:pcm16, and a running_transcript node turns its running text into utterances:
stages:
asr:
gpu: 0
session_nodes:
asr:
stages: [asr]
inputs: ["input:pcm16"]
transcript:
kind: running_transcript
inputs: [input, "asr:text", "vad:session"]
tail_ms: 400
stages.asr.gpu (or --asr.gpu 1 on the command line) picks the GPU. Install the Nemotron dependencies as Nemotron 3.5 ASR describes.
Several GPUs
Each service picks its GPUs through env, as qwen3-asr-qwen3.8-27b-vivian does with env: {CUDA_VISIBLE_DEVICES: "0"} for the language model and "1" for the two speech models; a stage picks its GPU with gpu. A service’s memory share is the --mem-fraction-static inside its own command. The cascade server has no SGLang engine stage, so sgl-omni serve --mem-fraction-static on a cascade profile fails with --mem-fraction-static requires a pipeline with at least one SGLang engine stage.
The forced-alignment worker
Word boundaries use Unicode character offsets into the original reply, not into the aligner’s normalized text. The worker maps removed punctuation and Japanese compatibility normalization back to the source and rejects missing or changed tokens instead of searching ahead. An expanded number or compatibility character becomes heard only after its last aligned token finishes. The worker’s tests use the actual language-specific tokenizer with simulated timestamps; real-model playback remains a separate validation step.
The heard-ASR matcher accepts joined spellings such as long-term / long term, one-time / onetime, and U.S. / US, but commits only at complete units on both sides. Thus can does not match cannot, and now here does not match nowhere. English and Chinese requests also compare the existing spoken number readings, preserving original offsets: 350 matches three hundred fifty, but 21 does not commit after only twenty. Both the original-spelling and expanded-reading comparisons must match a strict prefix; the former preserves counting sequences that the TTS reader treats as grouped digits. This change does not extend the TTS reading rules for contractions, percentages, versions or decades.
An alignment node with method: forced sends each finished sentence and its audio to a worker that returns word boundaries. The worker cannot be a services entry: it answers /health and /align but not the /v1/models request the server waits for, and it needs the qwen-asr package, which pins a transformers release of its own, so it runs from a separate virtual environment. In that environment install qwen-asr, fastapi, regex and uvicorn, plus httpx, msgpack, msgspec, numpy, pydantic and websockets for the modules it shares with the server, then start it from the repository root before sgl-omni serve:
PYTHONPATH=. python examples/streaming_cascade/alignment_worker.py --model Qwen/Qwen3-ForcedAligner-0.6B --device cuda:0 --language English --port 8010
Flag |
Default |
Effect |
|---|---|---|
|
required |
The forced-aligner checkpoint. |
|
|
The device the aligner runs on. |
|
|
The language of a sentence sent without one; a sentence carries the language of its reply once the recognizer has named the user’s language. |
|
|
The bind address. |
|
required |
The port; the node’s |
curl http://127.0.0.1:8010/health answers {"status":"ready"} once the model is loaded. To run a Qwen3-ASR profile without the worker, switch its aligner to the asr method on the command line: --session_nodes.aligner.method asr --session_nodes.aligner.alignment_url "". The Nemotron profiles have no transcription endpoint and switch to the sentence method: --session_nodes.aligner.method sentence --session_nodes.aligner.alignment_url "".
Browser playground
python playground/cascade/app.py --api-base http://127.0.0.1:8765 --port 8090
It needs aiohttp, serves the page on --host (default 127.0.0.1) and --port (default 8080), and forwards /v1/realtime and /v1/realtime/capabilities to --api-base (default http://127.0.0.1:8000). Its settings panel is the browser demo’s: the system prompt, turn detection (server_vad with its silence, semantic_vad with its eagerness, as turn_control lists them) and one field per key in sampling_parameters, labelled for the cascade (max_new_tokens_per_unit is the reply token limit). It offers no text-only replies, because its captions and conversation.item.truncate follow audio playback. The fields are empty until you fill them, kept per deployment model in this browser, and cleared by Reset to defaults. While they are empty the page sends only output_modalities in its session.update, so the profile’s prompt and turn detection apply; a filled field is sent in the first session.update and applies to the next conversation, with sampling fixed for the session as Session settings describes. Its stop button sends response.cancel. When a reply is interrupted, by the button or by speaking over it, the page sends conversation.item.truncate with the audio it played, and it marks the heard part of the reply from sglang.response.progress and the audio it has played, word by word through the stretch being played, at an even pace between two progress points; a cut reply then shows the part the history kept, from sglang.response.heard. A long reply’s caption grows up to the room under the orb and then scrolls to keep the line being heard in view; the transcript holds the whole reply. Browsers grant the microphone only to localhost or HTTPS pages, so open the page on the same machine or forward its port.
An OpenAI Realtime client or the Session API
Any OpenAI Realtime client connects to /v1/realtime with the session flow in Voice Conversation. /v1/session serves the same Session without the realtime protocol. examples/streaming_cascade/run.py runs a profile in its own process instead of connecting to a server; it does not start the profile’s services, so start them first.
Profiles
Every profile is a launch.yaml under examples/streaming_cascade/profiles/.
Profile |
Recognizer |
Language model |
Speech |
GPUs |
Nodes |
Start beforehand |
|---|---|---|---|---|---|---|
|
Qwen3-ASR-1.7B service on 8002 ( |
Qwen3.8-27B on 8003, thinking off |
Qwen3-TTS-12Hz-1.7B-Base on 8005, cloning |
2: the language model on GPU 0, both speech models on GPU 1 |
|
Alignment worker on 8010 |
|
Qwen3-ASR-1.7B service on 8002 |
Qwen3.8-27B on 8000, thinking off |
Qwen3-TTS-12Hz-1.7B-Base on 8005, cloning |
2: the language model on GPU 0, both speech models on GPU 1 |
|
Alignment worker on 8010 |
|
Qwen3-ASR-1.7B service on 8002 started with |
Qwen3.8-27B on 8000, thinking off |
Qwen3-TTS-12Hz-1.7B-Base on 8005, cloning |
2: the language model on GPU 0, both speech models on GPU 1 |
|
Alignment worker on 8010 |
|
Qwen3-ASR-1.7B service on 8002 |
Qwen3-4B-Instruct-2507 on 8000 |
Qwen3-TTS-12Hz-1.7B-Base on 8005, cloning |
1 |
|
Alignment worker on 8010 |
|
Nemotron 3.5 streaming ASR service on 8001 ( |
Qwen3-4B-Instruct-2507 on 8000 |
Qwen3-TTS-12Hz-0.6B-Base on 8004, cloning |
1 |
|
Alignment worker on 8010; a recording at |
|
Nemotron 3.5 ASR as the |
Qwen3-4B-Instruct-2507 on 8000 |
Qwen3-TTS-12Hz-0.6B-Base on 8004, cloning |
1 |
|
Alignment worker on 8010; a recording at |
qwen3-asr-qwen3-tts-vivian and qwen3-asr-qwen3-tts-base-clone publish their aligner’s session, so their clients receive sglang.response.progress. Every profile cuts an interrupted reply at the word ends of the sentences whose audio was complete; the Qwen3-ASR profiles also send the delivered audio of the sentence at the cut to their own recognizer, so a sentence cut before its audio was complete keeps the part the listener heard.
Voice Conversation
With the page from Launching running, open http://127.0.0.1:8090, speak, and the reply plays as it is produced. Speaking while the reply plays interrupts it.
Any OpenAI Realtime client works the same way. Open a WebSocket to /v1/realtime, wait for session.created, and send the session settings:
{"type": "session.update", "event_id": "cfg-1", "session": {"instructions": "Answer in one short English sentence.", "output_modalities": ["text", "audio"], "audio": {"input": {"turn_detection": {"type": "server_vad", "silence_duration_ms": 800}}}}}
Send 16 kHz mono PCM16 audio at real-time pace as {"type": "input_audio_buffer.append", "event_id": "...", "audio": "<base64>", "sglang": {"seq": 0}}, with seq counting up from 0 for the whole session. The server answers with these events:
Event |
Meaning |
|---|---|
|
The start and the end of your utterance. |
|
Partial and final transcript of what you said. |
|
A reply begins. |
|
Reply text and reply audio, base64 PCM16 at 24 kHz mono. |
|
In a profile that publishes its aligner’s |
|
After a |
|
The reply is complete; |
|
The request or the session failed. |
response.cancel interrupts the reply from the client side. The capabilities grant supports_cancel when a node takes interrupt from input on a control edge, as every bundled profile’s dialog does, and supports_truncate always. An interrupted reply stays in the history only up to the last boundary whose audio the server had sent before the interruption: the last word with a forced aligner, the part of the sentence at the cut that its transcript says with heard_asr_url, the last sentence otherwise. conversation.item.truncate with audio_end_ms tells the server how much of the reply was heard; the history then keeps the reply up to the last boundary whose audio ended by then, never past what the server sent. A reply the history keeps nothing of, such as one interrupted before its first boundary, leaves no answer: your next utterance joins the one before it in a single user message, as if you had kept talking. An utterance you finished before the interruption is answered once the server knows how much of the interrupted reply it delivered, so its prompt keeps that part. The truncate cuts only the reply its item_id names, also after the next reply began, so the prompts after that keep what you heard; one for a reply the backend can no longer cut, such as an earlier reply after a later one ended, changes nothing, and an item_id the session never sent gets an item_not_found error. The language model sees a cut reply ending with …, and while the history holds one, the system message, including instructions a client sets, ends with a sentence saying such a reply was cut off by the user. A reply the model finished is shown without a trailing …, so that it does not read as cut.
To end turns yourself instead of by silence, set "turn_detection": null in the first session.update; input_audio_buffer.commit then ends the utterance after the audio sent before it.
Instructions and sampling (sglang.sampling.temperature, max_new_tokens_per_unit) can change with a later session.update and apply from the next reply; a reply in progress keeps the settings it started with. Turn detection can change the same way and applies from the next audio unit. session.updated reports supports_instruction_updates and supports_sampling_updates, false in a profile whose nodes have no system_prompt or no temperature and max_tokens, respectively. Session settings lists every field.
Turn Detection
Without it an utterance ends when the vad node hears silence_duration_ms of silence, so a pause to think in the middle of a sentence ends the turn and the reply answers half a sentence. A profile with a turn_detection node, such as qwen3-asr-qwen3-tts-base-clone, ends the turn only when the Smart Turn v3.2 end-of-turn model, scoring the utterance so far at each pause the vad node reports, finds it complete, or when the pause outlasts the longest pause the eagerness allows. The node reads the Session input and vad:session and publishes session: its speech.started opens the turn and its speech.stopped ends it, so the recognizer, the dialog and the client read turn_detection:session where they read vad:session before, and speech that resumes inside a held pause continues the utterance instead of interrupting a reply.
The stage loads the BSD-2 licensed smart-turn-v3.2-gpu.onnx from the Hugging Face repository pipecat-ai/smart-turn-v3 through the Hugging Face cache, or from the file or directory stages.turn_detection.factory.model_path names, and verifies its SHA-256 at startup. The stage’s factory.eagerness (low, medium or high, default medium) sets how readily a pause ends the turn: high takes a shorter silence on a confident score and gives up a held pause sooner, low waits longer. The profile sets the vad node’s silence_duration_ms to 160, the pause the detector scores as a candidate end; a client that selects {"type": "server_vad"} on this profile gets the VAD’s boundaries unchanged, with that pause.
A client selects the behaviour per session with the standard field, {"turn_detection": {"type": "semantic_vad", "eagerness": "high"}}; session.created lists semantic_vad under turn_control when the profile has the node. The Qwen3-Omni Session graph takes the node the same way: declare it in examples/qwen3_omni/session_h200.yaml beside vad, let dialog read turn_detection:session in place of vad:session, and publish turn_detection: [session] in place of vad: [session].
Scripted Conversation
The same conversation runs without the realtime protocol through the Session API:
PYTHONPATH=. python examples/streaming_cascade/run.py question.wav followup.wav --output response.wav
Each input is a 16 kHz mono signed 16-bit PCM WAV. The command runs the cascade in its own process without starting the profile’s services, so start them first. It streams the files back to back through one Session of the profile named by --config and writes the reply audio to response.wav; the second file is answered with the first turn in the history. --config defaults to nemotron-qwen3-tts. Pass --commit for a profile without the VAD node. The /v1/session protocol is described in Session API.
Benchmarking
The turn latency benchmark streams one WAV for several turns and prints, per turn, the time from speech end to first text, first audio, final transcript and completion:
python benchmarks/realtime_turn_latency.py --url ws://127.0.0.1:8765/v1/realtime --audio question.wav --turns 5
Configuration
A setting comes from one of three places: the profile file, read at launch; command-line overrides of the profile, also read at launch; and the settings a client sends for its own session.
Profile file
Field |
Default |
Effect |
|---|---|---|
|
required |
|
|
required |
The deployment’s name: the id |
|
|
The services to start, in order (see below). |
|
required |
The nodes of the conversation, by name. |
|
required |
Per node, the formats that reach the client. |
|
none |
Settings of stage routes by stage name, such as |
|
|
How long one input or control operation on a cascade node may take, including the wait for the last replies once the input has ended; past it the session fails with |
|
|
The length of the input audio units the server forwards into the session; reported as |
|
|
Sessions that every component node, the VAD node and the turn detection node hold at once. A session beyond them is refused when it opens; raise it, for example with |
|
|
How far ahead of real time reply audio is delivered; audio already delivered cannot be taken back by an interruption. |
|
|
Reply chunks held for a client that has stopped reading before the session fails. |
|
|
The same bound in bytes. |
A services entry has name (required, unique), command (required, a list), port (required, 1 to 65535, unique) and env (a map, default empty).
A session_nodes entry is either a node of a kind, with that kind’s options beside kind, or a stage route written stages: [...], which takes no options. Both take inputs, a non-empty list of:
Input |
Takes |
|---|---|
|
Every format of the client input: its 16 kHz |
|
Only the client audio, or only its controls. |
|
Every format |
|
Only that format. |
|
The same, with a bound on the edge: at most |
|
The source’s control event, delivered to this node as a control instead of as data; |
The formats each kind consumes and produces:
Kind |
Consumes |
Produces |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
stage |
|
|
stage |
|
|
session_publish maps a node to the formats the client receives: transcript becomes conversation.item.input_audio_transcription.*, text_delta becomes response.output_text.delta, pcm_f32le becomes response.output_audio.delta (PCM16), and session carries speech boundaries, the response lifecycle and sglang.response.progress.
Command-line overrides
Any field of the profile can be overridden after --config, as --<path> <value> or <path>=<value>, one leaf value per flag. Paths start at the top of the profile, except stage settings, which start at the stage name:
sgl-omni serve --config examples/streaming_cascade/profiles/qwen3-asr-qwen3-tts-vivian/launch.yaml --enable-realtime --port 8765 \
--session_nodes.llm.base_url http://127.0.0.1:9000/v1 \
--session_nodes.llm.temperature 0.3 \
--session_nodes.tts.language English \
--services.tts.env.CUDA_VISIBLE_DEVICES 1 \
--vad.factory.silence_duration_ms 800 \
--timeout_s 60
Services are addressed by name (--services.tts.port), not by position. An override may add an option the file leaves out, and is validated like the file: an option the node’s kind does not have fails with Extra inputs are not permitted, and a whole group such as --session_nodes.aligner x fails with addresses a settings group, not a single value. Lists such as a service command or a node’s inputs, and removing a node or a service, need an edit of the file.
Session settings
A realtime client sends its settings in session.update. The first session.update opens the session; later ones may change only the fields marked hot below, and any other change is refused with session field is frozen.
Field |
Reaches |
When it can change |
|---|---|---|
|
|
Any time; applies to the next prompt. At most 65536 characters, else |
|
|
Any time; applies to the next reply, a reply in progress keeps its value. |
|
|
Any time; applies to the next reply, a reply in progress keeps its value. |
|
|
Any time. |
|
|
Any time. |
The realtime endpoint takes no per-node options. A /v1/session client sets them, together with the standard fields, as the params of its open frame and later as the fields of a configure control:
{"instructions": "Answer in one short sentence.", "sampling": {"temperature": 0.2, "max_new_tokens_per_unit": 128}, "turn_detection": {"silence_duration_ms": 800}, "nodes": {"tts": {"language": "English"}, "llm": {"max_tokens": 256}}}
nodes maps a node name to options. An entry without kind, or with the node’s current kind, changes only the listed options; an entry with another kind replaces the node with a new one built from that entry alone, so it must list that kind’s required options. The new node takes over the conversation state of the old one. A configure reaches only the nodes that read input or input:session; a node that reads neither, such as aligner in the profiles, takes options only from the open frame. A node in the middle of a reply is replaced once the reply ends, and every node the configure reaches answers with a configured control naming its kind.
What needs a restart and what does not:
Setting |
Changes |
|---|---|
|
Restart the server. |
Default node options and VAD defaults |
Restart, or per session as above. |
Node options through |
When a |
|
When a realtime session opens. |
|
Any time in a realtime session. |
Node Reference
Every node of a kind has kind (required) and timeout_s. Options marked required have no default.
realtime_transcription
Streams each utterance over its own WebSocket to <base_url>/v1/realtime?model=<model> of an SGLang-Omni transcription server started with --enable-realtime, and reports partial and final transcripts. Its transcripts carry no language.
Option |
Type |
Default |
Effect |
|---|---|---|---|
|
string, |
required |
The transcription server. |
|
string |
required |
Sent as the |
|
string |
|
Environment variable whose value is sent as a bearer token; nothing is sent when it is unset. |
|
number > 0 |
|
The limit for connecting and for one utterance’s whole exchange. |
streaming_transcription
Streams each utterance over its own WebSocket to <base_url>/v1/realtime?intent=transcription&model=<model> of an SGLang-Omni ASR server that declares realtime transcription, such as Qwen3-ASR started with --enable-realtime. The node cuts the utterances, so each session runs without server VAD: the node sends the utterance’s audio, then transcription.done. Every transcription.segment that is not final becomes a partial transcript, and transcription.completed becomes the final transcript with the language the service heard. A segment the service cuts at its maximum length (30 s for Qwen3-ASR) starts the partial text again; the final transcript joins every segment.
Option |
Type |
Default |
Effect |
|---|---|---|---|
|
string, |
required |
The ASR server. |
|
string |
required |
Sent as the |
|
string |
|
Sent as the session’s |
|
integer > 0, or null |
null |
Audio between partial transcripts, sent in the session’s |
|
string |
|
As above. |
|
number > 0 |
|
The limit for connecting and for one utterance’s whole exchange. |
openai_transcription
Sends each finished utterance as a 16 kHz WAV to <base_url>/audio/transcriptions and reports one final transcript.
Option |
Type |
Default |
Effect |
|---|---|---|---|
|
string, |
required |
The service, including |
|
string |
required |
The |
|
string |
|
Sent as the |
|
|
|
|
|
string |
|
As above. |
|
number > 0 |
|
The request timeout. |
running_transcript
Turns the running text of the asr stage into utterances: partial transcripts while it grows, and a final one once tail_ms of further audio has arrived after speech.stopped or a commit. Its transcripts carry no language.
Option |
Type |
Default |
Effect |
|---|---|---|---|
|
number ≥ 0 |
|
Audio after the end of speech that still belongs to the utterance. |
dialog
Keeps the conversation history, opens a turn for each final transcript and sends the history to the language model. An utterance that arrives during a reply waits for it to end; after an interruption it also waits until the audio the graph delivered (reply.delivered) or the client’s truncate says how much of the reply the history keeps. Speech that starts during a reply interrupts it, and so does speech that started after the utterance ended and has not stopped when the reply begins, so that reply ends before any of it is heard.
Option |
Type |
Default |
Effect |
|---|---|---|---|
|
string |
|
The system message; realtime |
|
integer > 0 |
|
The bound on the history: a turn whose history plus utterance plus |
|
integer > 0 |
|
The longest utterance transcript and the longest reply. |
openai_chat
Streams one chat completion from <base_url>/chat/completions per turn.
Option |
Type |
Default |
Effect |
|---|---|---|---|
|
string, |
required |
The service, including |
|
string |
required |
The |
|
integer > 0 |
|
The |
|
number 0 to 2, or null |
null |
The |
|
object |
|
Further request fields, such as |
|
string |
|
As above. |
|
number > 0 |
|
The request timeout. |
openai_speech
Cuts the reply text into sentences and sends one streaming request per sentence to <base_url>/audio/speech with model, voice, input, response_format: pcm, stream: true, language when language is set, and references: [{audio_path, text}] when reference_audio_path is set. A sentence ends after a word ending in ., !, ?, ; or … (closing quotes and brackets included), after Chinese end punctuation followed by more text, or at a line end; a sentence longer than max_sentence_words is cut as that setting describes.
Every sentence is its own request, and Qwen3-TTS samples each request with a fresh seed. A CustomVoice speaker given by voice is held only by its speaker token, so its timbre moves from sentence to sentence and from turn to turn; a Base model cloning one reference_audio_path for every request keeps one voice, which is why the English profiles clone examples/streaming_cascade/voices/vivian.wav, a recording of the CustomVoice speaker Vivian. They leave reference_text empty, so Base clones from the recording’s speaker embedding alone: with the transcript it continues the recording itself and opens each sentence with a pause of about half a second, which a listener hears as a later reply.
openai_speech sends each sentence without its emoji, asterisks and backticks, and a markdown link as its text alone, because a speech service reads them as stray sounds or a spelled-out URL. A sentence with no word left, such as one of only emoji or punctuation, is not sent because the speech service answers it with an error, and its end still counts in reply.progress. A sentence tagged English without Chinese characters is sent with its numbers written out as words, the way the forced-alignment worker reads them: a year as two pairs, spaced digit groups such as a phone number digit by digit, a decimal with point, and an ordinal such as 21st as twenty first. The reply text and the reply.progress offsets keep everything the model wrote. The profiles’ system prompts also tell the language model to use neither markdown nor emoji.
Option |
Type |
Default |
Effect |
|---|---|---|---|
|
string, |
required |
The service, including |
|
string |
required |
The |
|
string |
|
The |
|
string |
|
A WAV the speech service reads, for voice cloning. |
|
string |
|
The transcript of the reference, sent with it when set. |
|
string |
|
The language tag: empty sends none; |
|
integer > 0 |
|
The most words sent in one request. A longer sentence is cut into requests of at most this many words and at least half as many, at a comma, colon or dash when one falls in that range; the first request of such a sentence waits for up to half as many words more, so its rest is never a word or two on its own. |
|
string |
|
As above. |
|
number > 0 |
|
The request timeout. |
alignment
Reports how far into the reply text the reply audio has reached, as reply.progress, so the dialog can cut an interrupted reply where the listener stopped hearing it. It reads ["llm:text_delta", "tts:pcm_f32le", "tts:session", "dialog:session"] in the profiles, and input:session too in the Qwen3-ASR profiles.
Option |
Type |
Default |
Effect |
|---|---|---|---|
|
|
|
|
|
string, |
|
The worker, for |
|
string, |
|
An OpenAI-compatible transcription service, including |
|
string |
|
The |
|
number > 0 |
|
The limit for one worker or transcription request; a sentence whose request fails or times out keeps only its sentence end. |
The dialog cuts an interrupted reply at the points reply.progress reports, up to the audio the graph sent to the client before the interruption, which the graph reports to the readers of the tts node’s session output as reply.delivered. Without an alignment node the dialog takes reply.progress from the tts node, which reports the end of each sentence. With one, as in qwen3-asr-qwen3-tts-vivian, the aligner node reads ["llm:text_delta", "tts:pcm_f32le", "tts:session", "dialog:session", "input:session"] and the dialog’s {kind: control, source: aligner, event: reply.progress} input replaces the one from tts; a profile names exactly one source of reply.progress. Once the client’s conversation.item.truncate arrives, the dialog reports every cut of that reply as reply.heard with text_end, the characters the history keeps, which reaches the client as sglang.response.heard.
method: sentence keeps the sentence ends, so a truncated reply keeps the sentences whose audio was played to the end and drops the sentence the listener was hearing. method: forced also sends each finished sentence and its audio to the worker at alignment_url and reports the word ends it returns, so the cut can fall inside a sentence that finished; when the worker fails or exceeds timeout_s, that sentence keeps its sentence end. A sentence that an interruption cuts off before its audio is complete gets no word ends and counts as unheard in both methods.
method: asr keeps the sentence ends and, when a reply is cut, sends the audio of the sentence at the cut, from the sentence’s start to the cut, as a WAV file in the reply’s language to the /audio/transcriptions endpoint of the OpenAI-compatible service at heard_asr_url with heard_asr_model; the reply keeps the sentences before it and the longest prefix of that sentence’s text the transcript says, with the punctuation that follows it. The cut is the client’s truncate position and never past reply.delivered, the same cut the dialog makes, so the aligner reads input:session for the client’s truncate. The match ignores case and punctuation, reads digits, English numbers up to twenty and ordinary Chinese numbers as the same number, and stops at the first word the transcript leaves out, repeats or says differently. When the request fails, exceeds timeout_s or the first words differ, the sentence counts as unheard. It needs no model beyond the profile’s own recognizer. method: forced takes heard_asr_url and heard_asr_model too, and then recognizes the sentence at the cut only when no word end at or after the cut covers it, such as a sentence cut off before its audio was complete; method: sentence takes neither.
Stage vad
Declared as vad: {stages: [vad], inputs: [input]}; reports speech.started and speech.stopped. Its defaults apply to every session and a session’s turn_detection overrides them; set them under stages.vad.factory or as --vad.factory.<option>.
Option |
Type |
Default |
Effect |
|---|---|---|---|
|
number 0 to 1 |
|
Speech probability above which a frame is speech. |
|
integer ≥ 0 |
|
Audio before the first speech frame that belongs to the utterance. |
|
integer ≥ 0 |
|
Silence that ends an utterance. |
|
integer ≥ 1 |
|
Sessions the detector holds at once. |
|
integer ≥ 1 |
|
Sessions it processes at once. |
Stage turn_detection
Declared as turn_detection: {stages: [turn_detection], inputs: ["input:pcm16", "input:session", "vad:session"]}; reports the turn’s speech.started and speech.stopped, which the recognizer and the dialog read in place of the VAD’s. A session’s turn_detection overrides eagerness; set the options under stages.turn_detection.factory or as --turn_detection.factory.<option>.
Option |
Type |
Default |
Effect |
|---|---|---|---|
|
string |
|
The Smart Turn ONNX file, its directory, or the Hugging Face repository holding |
|
|
|
How readily a pause ends the turn: the silence a confident, a likely and an unlikely end of turn needs. |
|
integer ≥ 1 |
|
Sessions the node holds at once. |
|
integer ≥ 1 |
|
Sessions it processes at once. |
Stage asr
Declared as asr: {stages: [asr], inputs: ["input:pcm16"]}; runs Nemotron 3.5 streaming ASR on the GPU set by stages.asr.gpu (default 0) and produces the running text a running_transcript node reads.
Compatibility and Constraints
Rejected when the profile loads
Each rule is followed by the error it raises.
A node names both
stagesand akind, or neither —a session node names either stages or a kind.A stage route with options beside
stages—options [...] belong to a node of a kind.An unknown
kind—unknown component kind '<kind>'.An option the kind does not have —
Extra inputs are not permitted.A missing required option (
base_url,model) —Field required.A node name of a kind that is not lowercase letters, digits and
_starting with a letter —String should match pattern '^[a-z][a-z0-9_]*$'.A node named
input—'input' names the session input, not a node.No node reads
input—no node reads 'input'.A node reads itself —
node '<name>' reads itself.An input names a node that does not exist —
node '<name>' reads unknown inputs [...].A
<node>:<format>input that the source does not produce or the reader does not consume —node '<name>' cannot take [...].A required format no input produces (every consumed format except
session, such asmessagesforopenai_chat) —no input of node '<name>' produces [...].A cycle of data edges without a
feedback: trueinput —nodes [...] form a cycle of data edges; declare the input that closes it with feedback: true.A
feedbackedge withoverflow: block—the feedback edge from '<source>' into '<name>' must not block.Two different bounds on inputs from one source —
node '<name>' bounds its edge from '<source>' twice.A node takes
reply.progresson control edges from two sources —node '<name>' takes reply.progress from [...]; one source reports it.A node reads
reply.progressof a source as data (thealignmentkind does) while a control edge takes the same event from that source —node '<name>' reads ['<source>:reply.progress'] as data, but a control edge takes them.A
dialogwhoseinterruptno node that publishes audio takes on a control edge with itssessionread by the dialog, in a profile that publishes audio or where some node takes thatinterrupt—node '<name>' waits for reply.delivered after it interrupts a reply: ....session_publishnames an unknown node —publish names unknown nodes [...].session_publishlists a format the node does not produce —node '<name>' does not produce [...].No published
sessionofopenai_chatoropenai_speechwhile adialogexists —no published node ends turns.Both
openai_chatandopenai_speechpublishsession—turns end twice: [...] all publish turn.done.A
dialogwhosesessionis not published —nodes [...] open turns and must publish 'session'.A stage name other than
vadorasr—no stage group serves the stages [...].base_urlofrealtime_transcriptionnotws://orwss://, or of the other service kinds nothttp://orhttps://—String should match pattern ....method: forcedwithoutalignment_url—the forced method needs alignment_url.alignment_urlwith a method other thanforced—alignment_url belongs to the forced method.Only one of
heard_asr_urlandheard_asr_model—heard_asr_url and heard_asr_model go together.method: asrwithout them —the asr method needs heard_asr_url and heard_asr_model.heard_asr_urlwithmethod: sentence—heard_asr_url belongs to the asr and forced methods.extra_bodysetsmodel,messages,stream,max_tokensortemperature—extra_body cannot set [...].temperatureoutside 0 to 2, a non-positivemax_tokens,max_sentence_words,max_history_chars,max_text_charsortimeout_s, or a negativetail_ms— a pydantic range error naming the field.Two
serviceswith one name or one port —services must have distinct names and ports.An empty
model_path—Model path is required.
Rejected on the command line or at startup
--stages.<...>—the stages. prefix is implied on the command line; write --<...>.--services.0.<...>—services is addressed by name, not by index.A flag naming a group instead of a value —
addresses a settings group, not a single value.--mem-fraction-static—--mem-fraction-static requires a pipeline with at least one SGLang engine stage.A service that exits or does not answer
/v1/modelson its port — the service errors under One command with a profile.A node whose service does not answer its probe:
openai_transcription,openai_chatandopenai_speechrequest<base_url>/models,openai_chatthen streams one token sampled with the node’s settings,realtime_transcriptionrequests/v1/modelson thehttpform of itsbase_url, a forcedalignmentrequests<alignment_url>/healthand then aligns one second of silence, and analignmentwithheard_asr_urlrequests<heard_asr_url>/models—cannot reach <url>: ...or<url> answered <status>, or the error of the chat token’s completion, and the server stops.
Rejected in a realtime session
A sampling parameter other than
temperatureandmax_new_tokens_per_unit—unsupported sampling parameters: ....A field other than the hot ones in Session settings changed after the first
session.update, orinstructionsorsglang.samplingin a profile whose capabilities reportsupports_instruction_updatesorsupports_sampling_updatesas false —session field is frozen.instructionslonger than 65536 characters —instructions exceed context or have invalid type.A
modelother than the deployment’s —model differs from deployment.An audio format other than 16 kHz input or 24 kHz output PCM —
unsupported PCM format or sample rate.A
turn_detectionwith atypeother thanserver_vad, or other thanserver_vadandsemantic_vadin a profile with aturn_detectionnode —turn detection type is unavailable; any non-nullturn_detectionin a profile without avadnode —VAD is only available for turn-based sessions.nodesinsession.update—invalid session configuration.response.create—manual turn commands are not granted.response.cancelin a profile where no node takesinterruptfrominputon a control edge —no node takes the control 'interrupt' from 'input'.A new session beyond the
max_sessions(16 unless the profile or--max_sessionssets another) that every cascade node holds at once — the firstsession.updateis refused withadmission_rejected.A
session.updatethat changesinstructions, or aconfigurethat changes a node, while the user is speaking, betweenspeech_startedandspeech_stopped— the session fails withcannot replace '<node>' while speech is in progress.
Rejected by the speech service
The speech service validates each sentence request, and a rejected request fails the session.
Qwen3-TTS CustomVoice with
reference_audio_path—references are not supported by this model.Qwen3-TTS CustomVoice with a
voicethat is not one of its speakers —Unknown voice '<voice>'. Supported voices: default, ....Qwen3-TTS Base without
reference_audio_path—Qwen3-TTS Base requires reference audio via ref_audio or references[0].audio_path. With a reference but noreference_text, Base clones from the speaker embedding of the recording alone.A
languagetag the service does not speak, including a language name the recognizer reported underlanguage: auto—language must be one of: .... Qwen3-TTS speaks Chinese, English, Japanese, Korean, German, French, Russian, Portuguese, Spanish and Italian.A speech service that does not stream 24 kHz PCM with an
X-Sample-Rateheader —speech service sent no X-Sample-Rate header, so its PCM rate is unknownorspeech service streams <rate> Hz, expected 24000.
Runtime limits
A conversation whose history, next utterance and
max_text_charstogether exceedmax_history_chars—conversation exceeds max_history_chars; open a new session, and the session fails. With the defaults this happens once the history passes about 49500 characters.An utterance transcript or a reply longer than
max_text_chars—transcript exceeds max_text_charsorreply exceeds max_text_chars.A language model stream that stops for a reason other than
stoporlength—LLM stopped with <reason>; one that ends without a finish reason —LLM stream ended before completion.A client that stops reading while
max_pending_output_chunksormax_pending_output_bytesof output wait — the session fails naming the limit.
Every failure ends the realtime session with an error event.
Allowed, with consequences
Without an
alignmentnode, the dialog’sreply.progresscontrol edge comes fromtts, which reports sentence ends, so an interrupted reply is cut at the end of the last sentence heard in full.method: sentence: the same sentence-level cut. Withforced, the cut can fall between words of a finished sentence; a sentence cut off before its audio is complete counts as unheard in both methods unlessheard_asr_urlis set.With
heard_asr_url, a transcript that leaves out, repeats or changes the first words of the sentence at the cut keeps none of it.No
reply.progressinput on the dialog at all: the profile loads, and an interrupted reply leaves no assistant entry in the history, so the next utterance joins the user message the reply answered.An aligner that does not read
tts:sessionloads but receives no sentence ends and reports nothing.sglang.response.progressreaches the client only from a node that publishessessionand whosesessionno other node reads: the aligner when it is published, neverttswhile the dialog or an aligner reads it.A client that sends no
conversation.item.truncate: the history keeps an interrupted reply up to the audio the server had delivered, which is ahead of what was played by up tomax_output_lead_ms. Withtruncate, the cut moves back to the client’saudio_end_ms, never past the delivered audio.language: autowithrealtime_transcription,running_transcriptorresponse_format: json: no language is ever reported, so every sentence is tagged by its script,Chinesewith Chinese characters andEnglishotherwise. A fixedlanguagetags every sentence without Chinese characters with that name whatever the user speaks.turn_detection: nullsent after the VAD has already reported speech in the session: the recognizer keeps waiting for speech boundaries, and eachinput_audio_buffer.committhen ends an empty turn (empty_turn). Turn detection off from the firstsession.updateworks as intended;running_transcriptends utterances at a commit either way.input_audio_buffer.commitis granted in every profile (client_commit: true); with the VAD on, it also ends an open utterance.max_text_charsat or abovemax_history_charsloads, and then every turn fails withconversation exceeds max_history_chars.A reasoning language model can still open a think block of its own with
chat_template_kwargs: {enable_thinking: false}. With--reasoning-parseron its server the block goes toreasoning_content, which the cascade never reads; without it the block is spoken and kept in the history.A reply cut by
max_tokens(finish reasonlength) is spoken and kept as it is.Speech always interrupts a running reply;
interrupt_responsedoes not change that.
Verifying a Deployment
A healthy start logs each service as starting service '<name>': ... and service '<name>' serving on :<port> (or already serving), then Pipeline '<model_path>' started (<n> GPU(s)), then the HTTP server listening on its port. A failed node probe logs Scheduler thread for stage <node> crashed with the probe error, and the server stops.
curl http://127.0.0.1:8765/health answers 200 with "status": "healthy" while the cascade runs, and 503 when it does not.
curl http://127.0.0.1:8765/v1/models lists one model whose id is the profile’s model_path, the name a realtime client must use if it sends model.
curl http://127.0.0.1:8765/v1/realtime/capabilities answers 503 instance is not ready when the cascade is not running, and 404 without --enable-realtime. For the bundled profiles it reports:
{
"model": "examples/streaming_cascade/profiles/qwen3-asr-qwen3-tts-vivian",
"interaction": "turn_based",
"turn_control": ["server_vad", null],
"client_commit": true,
"input_audio_format": {"type": "audio/pcm", "rate": 16000},
"output_audio_format": {"type": "audio/pcm", "rate": 24000},
"output_modalities": ["text", "audio"],
"native_unit_ms": 200,
"supports_truncate": true,
"supports_cancel": true,
"sampling_parameters": ["temperature", "max_new_tokens_per_unit"],
"supports_instruction_updates": true,
"supports_sampling_updates": true,
"limits": {"max_input_bytes": 1920000, "max_output_bytes": 33554432, "max_history_chars": 65536, "cleanup_timeout_s": 30}
}
with further fields omitted here. turn_control is [null] for a profile without a vad node, output_modalities is ["text"] for one that publishes no audio, sampling_parameters is empty and supports_sampling_updates false when no node takes temperature or max_tokens, and supports_instruction_updates is false when no node has a system_prompt. A first conversation turn through the playground or the turn latency benchmark confirms the whole chain.
Known Limitations
Reasoning models must have thinking off. The cascade speaks everything the language model returns as content. A model that thinks by default needs
enable_thinking: falsein the node’sextra_bodyand a reasoning parser on its server (--reasoning-parser qwen3for Qwen3.8, as inqwen3-asr-qwen3.8-27b-vivian), or its reasoning is synthesized.History is bounded. A conversation whose history exceeds
max_history_charsfails its next turn; open a new session.Audio only. The cascade takes audio input and produces text and audio; image input is not supported.
Session Graph#
A Session is one stateful conversation on the server: the client opens it, appends input for its whole lifetime, reads what it publishes and closes it. A pipeline declares the shape of its Session in its config, and the coordinator opens it; the serving layer appends input and reads the published formats without knowing the nodes behind them. A Session is either the one stage route from the pipeline’s entry stage or a graph of nodes declared in session_nodes.
Nodes
Every node of the Session is an entry of session_nodes, and session_publish lists, per node, the formats that reach the client. A node with stages is a route through those stages of the server.
inputs lists the nodes a node reads; input is the Session’s own input, its audio, its commit and other controls, and camera frames when a node of the Session takes them. node:format takes only that format from that node; two nodes never send the same format to one reader, and the audio format of input is the Session input’s alone. A node that reads every format of input while the Session takes camera frames must take them too, or name the formats it reads, such as input:pcm16 and input:session.
An input written {kind: control, source, event, preempt} is a control edge. Its event is a control type of the source’s session chunks or the format of its other chunks, such as tool_response; the event is handed to the node as a control operation, outside its input queue, to every stage of its route in route order, and travels on no other edge and to no client. With preempt the stages cancel the node’s in-flight appends first. The client’s interrupt reaches a node through a control edge from input.
An input of a stage node written {source, capacity, overflow} bounds its edge. The inputs naming one source are one edge into the node; it holds at most capacity chunks the node’s own session has not accepted yet, by default the Session’s max_pending_chunks. When the edge is full, overflow: block, the default, holds the producing node until the node takes a chunk, and rejects the client’s append as a full queue; overflow: drop_oldest drops the edge’s oldest chunk and lets the producer go on; on the client’s edge the dropped input is confirmed to the client like a taken one, so a real-time node such as a full-duplex model hears the newest audio instead of a backlog. Two inputs of one source that declare different bounds fail the declaration.
A chunk one node sends another may be larger than the Session’s max_chunk_bytes, as the unit of a Qwen3-Omni turn longer than about 32 s is. It enters the reader in fragments within that bound: the first stage of the reader’s route holds them and restores the chunk for a single append, so every stage of the route sees the chunk whole and once. The fragments of a turn interrupted on the way are dropped.
The Session opens its nodes upstream first and closes them downstream first. A cycle of data edges between nodes must name the input that closes it with feedback: true, and that input must not block: two nodes each held on the other’s full edge never wake. An undeclared cycle fails with form a cycle of data edges. A control edge holds no queue, so a cycle it closes needs no mark.
Stream vocabulary
Format |
Modality |
Payload |
|---|---|---|
|
|
16 kHz mono signed 16-bit PCM |
|
|
24 kHz mono float32 PCM |
|
|
One JPEG or PNG camera frame, at the time of the audio unit it came with |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Control types sent into a Session are commit (the utterance ends here), configure (settings change) and truncate (the listener heard the reply of the chunk’s turn up to t_ms; without a turn, the latest reply). Control types a Session emits are speech.started and speech.stopped with t_ms on the input timeline, turn.queued when an utterance waits for its reply, turn.started when the reply begins, turn.done after the reply’s audio, turn.empty for an utterance without words, reply.heard with text_end when a truncate cuts the reply the history keeps, and configured with {"nodes": {name: kind}}. Every chunk of a reply carries its turn number in the chunk’s turn field, numbered from 1 per Session in utterance order: the turn controls, the messages, text_delta, tool_call and tool_response chunks and the reply audio; every turn opens with turn.queued and closes with turn.done or turn.empty.
VAD node
Voice activity detection is a stage node: sglang_omni.models.vad hosts one Silero detector per Session in a CPU stage of its own, vad, declared with stages: [vad], reading the Session input’s audio and controls and publishing session. It emits speech.started and speech.stopped controls whose t_ms is the boundary on the input audio timeline, speech.started reaching prefix_padding_ms before the first speech frame; commit resets the detector. Nodes that place utterances read it through a vad:session edge. Its detector defaults are the stage’s factory options threshold, prefix_padding_ms and silence_duration_ms; a Session’s turn_detection overrides them for that Session alone, at open and through configure.
Turn detection node
End-of-turn detection is a stage node beside the VAD: sglang_omni.models.turn_detection runs the Smart Turn v3.2 model in a CPU stage of its own, turn_detection, declared with stages: [turn_detection], reading the Session input’s audio and controls and vad:session, and publishing session. It emits the same speech.started and speech.stopped controls as the VAD, at the turn’s boundaries: the VAD’s speech.started opens a turn, and at each speech.stopped of the VAD the node scores the turn’s audio, ending the turn after the silence that score requires, up to the longest pause of the eagerness preset; speech that resumes before then continues the turn, and commit forgets it. Nodes that place utterances or interrupt replies on speech read turn_detection:session in place of vad:session. The stage’s factory options are model_path (SGLANG_OMNI_SMART_TURN_MODEL_PATH when unset) and eagerness; a Session’s turn_detection selects semantic_vad with an eagerness, or server_vad, which passes the VAD’s boundaries through, at open and through configure. SessionPorts.turn_detection_types names the turn_detection.type values a Session’s nodes implement, and the realtime bridge offers them as turn_control.
Interruption
An interrupt naming a turn that reaches a node on a control edge first drops that turn’s chunks from other nodes still queued for the node, on its edges and among its unsent appends, and a chunk of that turn that a node emits later no longer reaches it; the client’s own input is never dropped. The node’s own output of that turn, whether already buffered or emitted later, reaches neither its readers nor the client. The controls about the interruption pass both ways: the node’s interrupt and turn.interrupted of that turn, and the reply.delivered that reports its audio, so a node the client interrupts by turn still stops the nodes it controls and hears what was delivered. Control edges are delivered upstream first, so a node’s stale output is fenced before its readers hear the event. An interrupt without a turn, such as the client’s response.cancel, means the open reply, which only the node knows; the Session leaves its queues to the node.
Delivery pacing
A Session delivers reply audio in real time. For every node of the Session that publishes audio, an audio chunk leaves at most max_output_lead_ms ahead of the wall clock since the reply’s first audio left, cut at that horizon, so the client never holds more than max_output_lead_ms of a reply however long its first audio took; the node’s later audio and its turn.done wait behind it, and its other chunks, such as transcripts, reply text, speech boundaries and the confirmation of each input append, pass, so the client keeps appending while the reply plays. Once the input has ended everything leaves at once. Audio leaving together, at the input’s end or when delivery catches up, is joined per turn into contiguous chunks of at most max_output_lead_ms, so a held 40 s reply reaches the client as about 40 chunks rather than one per generated chunk. A Session that is one stage route paces its output the same way. max_output_lead_ms is a field of the pipeline config (default 1000); an interruption cannot take back audio the client already holds, so it bounds how long the client may keep speaking after the user started.
A node’s turn.done leaves once the wall clock reaches the end of the turn’s audio, when the client has played it, and not when its last audio left. When the Session releases a node’s turn.done, it delivers it to the nodes reading it and returns it to the node itself as a control, so a node can keep its reply open, and interruptible, until the client has played the reply’s audio: a user who starts speaking during its last max_output_lead_ms still interrupts it. A turn.interrupted the node publishes, or an interrupt control naming a turn, drops what the Session still holds of that turn, its turn.done included. Held audio counts against the Session’s max_output_bytes. A reply.progress reaches the client only from a node that publishes session and whose session no node reads, the end of the route that builds the progress: with an aligner reading the speech node’s session, the client hears the aligner’s progress and not the speech node’s, and without one it hears none.
Session API
A Session takes four operations: open, append, control and close. Client.control_session(identity, event, stages=None, should_preempt=False) delivers a session control chunk such as interrupt to every stage of the Session’s route, or to the named stages, in the Session’s arrival order with its appends; should_preempt cancels the stages’ in-flight appends first. On a Session graph the event follows the control edges declared from input. A Session delivers its outputs as the nodes produce them, between appends as well as during one, so a client that has spoken may wait for the replies without appending.
A /v1/session client opens the Session the pipeline declares; the open frame carries params, and the Session’s limits are the deployment’s. An append frame carries a chunk and is answered with accepted; a control frame carries the event as its chunk, with optional stages and should_preempt, and is answered with controlled. Both answers carry the chunk’s seq, and a full queue answers either with rejected and leaves the Session open.
A chunk’s turn identifies a cancellable reply within the Session, not a tool
call or an input audio frame. All work belonging to that reply preserves the
same ID, including tool responses. Interrupted reply IDs remain fenced for the
lifetime of the graph, including tool results carried by control edges. Session
control commands such as interrupt and truncate remain deliverable. Nodes must discard any matching
work they have already buffered when they receive the interrupt.
A producer without reply boundaries leaves turn unset. VoiceChat’s continuous
duplex stream does not currently allocate reply turns; tool call IDs identify its
individual calls. Passing through a supplied turn does not add native speech
interruption or reply-boundary detection to VoiceChat.
Cascade components
The cascade config’s kind shorthand expands each component into its own CPU stage and Session node, with the stages sharing the cascade process. Data and control edges use the same coordinator runtime as native model stages. Feedback edges must be declared explicitly; a native stage may read a component’s output directly.
A node declares either a component kind, with its options beside it, or a stages route for a native model or the VAD. inputs lists the sources it reads: input is the client’s audio and controls, node a node’s every format, node:format one format, input:session the client’s controls, and {kind: control, source, event} a control edge. A component accepts the formats its kind consumes; the declaration is validated when the config loads: names are unique, every input exists, every format a component requires other than session controls is produced by one of its inputs, exactly one reply-ending kind publishes session, and the kinds that open turns (dialog) and end them (openai_chat or openai_speech) are present once. At startup every service kind is probed once: openai_transcription, openai_chat and openai_speech request {base_url}/models, openai_chat also streams one token sampled with the node’s settings so a kernel the service compiles on its first sampled request is built before the server is ready, realtime_transcription requests /v1/models on the HTTP form of its base_url, and a forced alignment requests {alignment_url}/health and aligns one second of silence; a failed probe fails the start with the node’s name.
A kind is a config class with a default kind, a constructor and the formats it consumes and produces, registered through COMPONENTS.register in sglang_omni.models.streaming_cascade.components; opens_turns marks the kind that numbers turns, ends_turns the one whose turn.done ends a reply, and optional the consumed formats a configuration may leave unconnected. running_transcript is the kind that turns the one running text of a Nemotron 3.5 ASR stage served as a node of the same Session into utterance transcripts, cut at speech.stopped or at a commit plus tail_ms.
A configure control reaches every node reading input. Its standard fields reach every node with the matching option, instructions as system_prompt and sampling as temperature, max_tokens; nodes: {name: {...}} changes one node’s options, and a different kind replaces its component, which adopts the previous one’s state. Names and inputs cannot change; a node is not replaced while it is speaking, the change waits for the reply to end. Each node answers with configured {"nodes": {name: kind}} on its session output.
An EOS append ends the input readers in graph order, after their upstream input readers finish. Its confirmation waits for pending routed input and published reply turns to drain. Background component failures fail the output read without waiting for another append.
The cascade graph enables shared_timeline: its published reply chunks use one coordinator-owned media clock. A reply starts no earlier than the latest client audio or the previous reply’s audio end; audio advances that clock, and interruption resets it to the client audio clock. Speech boundaries retain their original input timestamps. Other graphs preserve their nodes’ timestamps by default.
Full-Duplex-Bench runbook#
This page runs Full-Duplex-Bench (FDB) v1.0 and v1.5 against a full-duplex model served by SGLang-Omni (MiniCPM-o 4.5 by default). Every step is a subcommand of python -m benchmarks.duplex.fdb; the code is in benchmarks/duplex/fdb/.
What is measured#
FDB has two datasets, and they test different behavior:
v1.0 tests turn-taking. The model should stay quiet through a pause in the user’s turn, come in once the turn ends, stop and answer when the user interrupts it, and, while the user talks at length, give short backchannels such as “mm-hmm” without taking the turn.
v1.5 tests overlap handling. The user asks something, and while the model answers, more speech arrives: an interruption, a backchannel, speech to someone else, or background speech. The question is what the model does with it.
The two measure different things, so a run takes samples from both by default. Each dataset gets its own result table, and their numbers are never pooled.
v1.0
v1.0 has 727 samples in five subsets. A sample is one input audio file plus an annotation (the pause positions, the end of the user’s turn, the interruption, or the human backchannel timing), streamed in one session.
Subset |
Samples |
Task |
Source |
Reported numbers |
|---|---|---|---|---|
|
137 |
Pause handling |
Synthetic |
Takeover rate (lower is better) |
|
216 |
Pause handling |
Candor conversations |
Takeover rate (lower is better) |
|
119 |
Turn-taking |
Candor conversations |
Takeover rate (higher), latency (lower) |
|
200 |
User interruption |
Synthetic |
Takeover rate (higher), latency (lower), relevance (higher) |
|
55 |
Backchannel |
ICC conversations |
Takeover rate (lower), backchannel frequency, backchannel JSD (lower) |
The following official reference numbers come from the evaluator at the pinned revision, run unchanged on the transcript of the model’s output. The runbook also reports unified v1.0 metrics for pause handling, turn taking and interruption:
Takeover rate: the share of samples in which the model took the turn, meaning its transcript spans at least 1 s or has more than 3 words.
Latency: the time from the annotated end of the user’s turn to the model’s first word, averaged over the samples in which the model took the turn. A start before the end of the turn counts as 0.
Relevance: a 0-5 rating of the model’s reply to the interruption, given by the judge (see Judges).
Backchannel frequency and JSD: how often the model backchannels, and how far its backchannel timing is from the human one.
The capture, VAD and judge can differ from the paper’s, so compare these numbers between runs of this runbook, not with the published tables.
v1.5
v1.5 has 498 pairs in four categories: user_interruption (200), user_backchannel (98), talking_to_other (100) and background_speech (100). A pair is two input files with the same user query: input.wav contains the later overlapping speech and clean_input.wav does not. Each file is streamed in its own session.
Stop latency: how long the model keeps talking while the other speech overlaps it. Lower is better on
user_interruption, where the model should give way. In the other three categories the model should keep its turn, so the overlap mostly tracks how long the other speech lasts.Response latency: the time from the end of a user speech span to the start of the next model speech. Lower is better when a reply is due. Both latencies are whole-file intervals from the official timing code, not latencies around the overlap event.
Behavior labels: the judge gives each pair one label for how the model handled the overlap.
C_RESPOND: it addressed the overlap’s content.C_RESUME: it ignored the overlap and continued.C_UNCERTAIN_HANDLING: it asked for a repeat or showed that it did not catch the overlap.C_UNKNOWN: its reply was off-target, or it said nothing. The share that should be high isC_RESPONDonuser_interruptionandC_RESUMEin the other categories;C_UNKNOWNshould be low.Semantic quality (
JUDGE=qwenonly, not part of the paper): a label says what the model did, not whether that was right, so aC_RESUMEthat ignored a real interruption looks the same as one that correctly ignored a backchannel. The semantic judge grades each pair on three axes, eachaccept,failorunresolved:interaction_handling(the overlap was handled correctly for its category),relevance(the reply addressed the request that mattered) andgrounding(no unsupported facts, claimed actions or false memories).jointaccepts only when all three accept. A verdict must quote the transcript it relies on, and a verdict with a missing or wrong quote becomesunresolved. Each cell reportsquality = A/(A+F)andcoverage = (A+F)/Nover all N selected pairs; read the two together, since a high quality over a low coverage judges few pairs.
Listening quality
The timing, label and semantic numbers say what the model did and when, not how its replies sound. asr also scores every reply of both datasets on its own, and RESULTS.md reports the scores per v1.5 category and per v1.0 subset, never as one total. A reply is the audio of one response, its deltas back to back as a client with enough buffer plays them, up to where the simulated playout stopped when a protocol cuts replies at barge-in; gaps from deltas that arrive late are left out, because the serving metrics measure them as underruns.
Omission rate (lower is better): the words of the transcript in
response.donethat are missing from the reply’s Parakeet transcript, over the transcript’s words, pooled over the completed replies of a category. Both texts go through Whisper’s English normalizer before alignment. A cancelled reply is not compared: its transcript can run past the audio it played.WER (lower is better): substitutions, deletions and insertions of the same alignment. A swallowed syllable often shows up as a substitution rather than a deletion. Both rates include Parakeet’s own errors, so compare them between systems rather than read them as absolute rates.
UTMOS (1-5, higher is better): the UTMOS prediction of each reply of at least 3 s, the model the TTS benchmarks use; it drops for clicks, distortion and unnatural prosody. The table shows the mean and the 10th percentile.
Voice similarity (higher is better): 100 times the cosine of the WavLM speaker embedding of a reply’s middle 3 s with the mean embedding of the same window of every other reply in the repeat, so a reply whose voice drifts from the model’s usual one scores low. The table shows the mean and the 10th percentile.
Short replies: replies with audio shorter than 3 s, such as most backchannels. They are compared for omission but get no UTMOS or voice score.
The 3 s comes from MiniCPM-o replies. Middle crops of replies of 6 s or more score within 0.07 of the full reply’s UTMOS from 2 s on and 0.38 below it at 1 s. A speaker embedding depends on length at every duration measured, so the voice score compares windows of one length; copies of the replies shifted up 4 semitones separate from the originals by d = 2.7 with 2 s windows, 4.0 with 3 s and 5.0 with 4 s, while the replies long enough to score drop from 44 of 50 at 3 s to 38 at 4 s.
The metrics move for their own defects. On 24 MiniCPM-o replies, removing the middle 40% of the audio raises the omission rate from 4.4% to 43.4%; adding 10 clicks per second drops UTMOS from 4.26 to 2.08; shifting up 4 semitones drops voice similarity from 72.5 to 52.5. The omission rate moves at most 0.6 points under the clicks and the pitch shift, and voice similarity at most 0.5 under the cut and the clicks. UTMOS also drops 0.22 for the cut, which leaves a splice, and to 1.41 for the pitch shift, whose phase vocoder adds artifacts.
These are reported only, with no pass mark yet. listening/scores.json keeps each reply’s duration, transcript, Parakeet text and scores.
Setup#
You need one GPU with at least 80 GB (H100 or H200), and git, uv and the FFmpeg shared libraries (version 4 to 8) on the host. Run every command in this page from the repository root, with the sglang-omni virtualenv active (which python should print its interpreter).
python -m benchmarks.duplex.fdb setup
setup is safe to rerun and skips finished steps. It installs the minicpm-o extra of sglang-omni (onnx and einops) into the active venv and puts everything else under FDB_WORK (default $HOME/fdb):
Path |
Content |
|---|---|
|
Official scoring code at the pinned revision |
|
The two datasets from the official Google Drive; |
|
Python 3.12 with NeMo, torch, Silero VAD and torchcodec for ASR, timing and the official evaluators. torchcodec loads FFmpeg, and |
|
ASR checkpoint, SHA-256 checked |
|
Model under test at revision |
|
Judge checkpoint; its commit is saved in |
Judges
JUDGE selects the judge:
|
v1.5 behavior labels |
v1.5 semantic quality |
v1.0 interruption relevance |
|---|---|---|---|
|
Qwen3.8-27B, served locally: non-thinking, greedy, seeds 1-3 |
Qwen3.8-27B: thinking, temperature 0, seed 1 |
Qwen3.8-27B with the official prompt: non-thinking, seed 0 |
|
|
not run |
|
JUDGE=gpt needs OPENAI_API_KEY. With JUDGE=qwen the labels and ratings are not the paper’s, but the judge is local and pinned, so runs with the same settings stay comparable with each other. The semantic judge sends six control cases (benchmarks/duplex/semantic/) together with its first grading batches and keeps the grades only if all 18 control axis statuses match control-expected.json; otherwise it discards them and stops. The controls check the rubric, not the judge’s accuracy.
A judge starts only when the step has a request for it: the v1.5 pairs and the v1.0 interruption subset need one, while the other v1.0 subsets are scored without an LLM. A run with only those subsets starts no judge server and needs no API key.
Run the benchmark#
A run has three steps, then an aggregation. Each step starts the server it needs and stops it when it exits. generate --all-stages runs the other three after recording, so one command goes from recording to RESULTS.md:
python -m benchmarks.duplex.fdb generate --run-name minicpmo-10 --v15-per-subset 10 --v10-per-subset 10 --all-stages
Without --all-stages, generate only records, and each later step is its own subcommand.
Step |
Subcommand |
What it does |
|---|---|---|
1 |
|
Starts the model server, streams the selected inputs to |
2 |
|
Transcribes the windows with word timestamps (Parakeet), computes v1.5 official speech intervals, writes v1.0 unified metrics from the saved transcript and annotations on CPU, then cuts every reply and scores its listening quality |
3 |
|
Runs the official v1.0 evaluator on the v1.0 subsets that need no LLM, then judges the v1.5 pairs and the v1.0 interruption subset (with |
|
Combines the repeats of a run into |
Run a small preflight first. It takes one pair per v1.5 category and three samples per v1.0 subset (about 15 minutes) and catches environment problems before a long run. v1.0 needs three samples rather than one because the official turn-taking and interruption evaluators divide by the number of takeovers and fail when no selected sample takes the turn.
python -m benchmarks.duplex.fdb generate --run-name preflight --v15-per-subset 1 --v10-per-subset 3 --all-stages
The preflight passes when generate prints {"pass": 8} and {"eligible_pairs": 4, ...} for v1.5 and {"pass": 15} with 3 eligible samples per v1.0 subset; asr prints {"ok": 16}, {"ok": 8} and 3 transcripts per v1.0 subset; and judge prints {"valid": 4}, controls: 18/18 axis statuses match and the parsed v1.0 results of each subset as JSON (the evaluator’s own [Result] block is in repeat-N/v10/reference/logs/evaluate-<subset>.log). If anything differs, see Troubleshooting.
A run is one fixed selection of samples under $FDB_WORK/runs/RUN_NAME. Decoding uses sampling, so two generations of the same inputs differ; to measure that variance, run the steps several times under one run name with --repeat 1, --repeat 2, and so on. --repeat N only names the output directory repeat-N/; one command always runs once. aggregate presents unified v1.0 scores per repeat and means ± standard deviations in its v1.5 and official v1.0 reference tables.
for repeat in 1 2 3; do
python -m benchmarks.duplex.fdb generate --run-name minicpmo-12 --repeat "$repeat" --v15-per-subset 12 --v10-per-subset 12 --all-stages
done
Each repeat ends with aggregate, so RESULTS.md covers every repeat finished so far.
generate refuses to write into a repeat that already holds a recording; to redo one, delete its repeat-N/ directory and run its three steps again. When asr or judge stops a --all-stages run, its message names the command that continues it; run that step and the ones after it on their own. asr and judge resume finished work when rerun. For v1.0, asr runs unified scoring after a successful or already completed reference-asr; failed ASR does not trigger scoring. The unified scorer does not need judge or its summary.json.
Every subcommand takes --log-level (info by default). With --log-level debug, a step also prints the server and recorder logs it writes, each line prefixed with the log’s name, such as [judge-server] or [record-shard-0]. The logs are written under repeat-N/ at either level (see the files table in Results).
Choosing samples
Only generate selects samples; asr and judge score whatever the repeat recorded. Selection works the same way for both datasets and is never random: each subset (v1.5 calls them categories) takes its first N samples in numeric ID order (1, 2, …, 10, 11, …). So every repeat with the same options gets the same samples, and a smaller N is always a prefix of a larger one.
The options come in two matching sets, --v15-* and --v10-*. Both default to 12 per subset. A count of 0 leaves that dataset out of the run; leaving both out is an error.
|
Effect |
|---|---|
|
N samples from every subset of that dataset (default 12); |
|
Overrides the count for one subset; |
|
Exactly these samples. Repeatable |
|
One |
# Both full datasets.
python -m benchmarks.duplex.fdb generate --run-name minicpmo-full --v15-per-subset all --v10-per-subset all
# v1.0 only; v1.5 only.
python -m benchmarks.duplex.fdb generate --run-name v10-only --v15-per-subset 0
python -m benchmarks.duplex.fdb generate --run-name v15-only --v10-per-subset 0
# 20 v1.5 interruptions, 10 v1.5 backchannels and every v1.0 backchannel sample.
python -m benchmarks.duplex.fdb generate --run-name small \
--v15-per-subset 0 --v15-subset-count user_interruption=20 --v15-subset-count user_backchannel=10 \
--v10-per-subset 0 --v10-subset-count icc_backchannel=all
# Three specific samples.
python -m benchmarks.duplex.fdb generate --run-name debug \
--v15-sample-id user_interruption/1 --v15-sample-id background_speech/7 \
--v10-sample-id candor_turn_taking/37
# Another run's selection.
python -m benchmarks.duplex.fdb generate --run-name replay \
--v15-sample-ids-file "$FDB_WORK/runs/minicpmo-12/repeat-1/sample-ids.txt" \
--v10-sample-ids-file "$FDB_WORK/runs/minicpmo-12/repeat-1/v10/sample-ids.txt"
generate prints how many samples it selected per subset and writes their IDs to repeat-N/sample-ids.txt (v1.5) and repeat-N/v10/sample-ids.txt (v1.0). All repeats of a run must select the same samples, so pass the same options every time; a repeat with a different selection stops with an error.
Sessions run in real time, so generate dominates the wall time. Approximate times per repeat on one H200, one session at a time, without the 1 minute of model server startup per generate and 2 minutes of judge server startup per judge:
Selection |
|
|
|
|---|---|---|---|
48 v1.5 pairs (12 per category) |
~25 min |
~1 min |
~6 min |
498 v1.5 pairs |
~4 h |
~10 min |
~45 min |
60 v1.0 samples (12 per subset) |
~20 min |
~1 min |
~1 min |
727 v1.0 samples |
~3.6 h |
~10 min |
~5 min |
A v1.5 session lasts about 15 s and a v1.0 sample about 18 s on average. Most of a v1.5 judge is the semantic judge, which runs 8 thinking-mode batches of up to 8 pairs concurrently at about 5 minutes per batch; the Qwen behavior judge sends up to 16 requests at once. generate --num-shards 2 roughly halves the generation time, but read the note on it in Notes first; --gpus 0,1 --num-shards 2 halves it without sharing a GPU.
Results#
aggregate prints its tables and writes them to $FDB_WORK/runs/RUN_NAME/RESULTS.md, with one section per dataset the run selected. The v1.0 unified section shows each repeat separately, without averaging its rates, quantiles or confidence intervals. In the v1.5 and official v1.0 reference tables, a numeric cell is the mean ± sample standard deviation over repeats with available values; a count cell shows one value or a range when repeats differ.
The v1.5 section has one row per category and one for all of them. This example is a validation run (MiniCPM-o 4.5, 3 pairs per category, two repeats, Qwen judge), so the samples are small:
Category |
Pairs |
Timed overlap sessions |
Stop latency (s) |
Response latency (s) |
C_RESPOND |
C_RESUME |
C_UNCERTAIN_HANDLING |
C_UNKNOWN |
Judged pairs |
|---|---|---|---|---|---|---|---|---|---|
all |
12 |
12 |
1.483 ± 0.070 |
2.698 ± 0.075 |
25.0 ± 11.8% |
66.7 ± 11.8% |
8.3 ± 0.0% |
0.0 ± 0.0% |
12 |
user_interruption |
3 |
3 |
2.161 ± 0.015 |
2.479 ± 0.128 |
50.0 ± 23.6% |
50.0 ± 23.6% |
0.0 ± 0.0% |
0.0 ± 0.0% |
3 |
user_backchannel |
3 |
3 |
0.625 ± 0.000 |
2.308 ± 0.121 |
0.0 ± 0.0% |
100.0 ± 0.0% |
0.0 ± 0.0% |
0.0 ± 0.0% |
3 |
talking_to_other |
3 |
3 |
1.751 ± 0.083 |
2.742 ± 0.083 |
33.3 ± 0.0% |
66.7 ± 0.0% |
0.0 ± 0.0% |
0.0 ± 0.0% |
3 |
background_speech |
3 |
3 |
1.393 ± 0.181 |
3.204 ± 0.034 |
16.7 ± 23.6% |
50.0 ± 23.6% |
33.3 ± 0.0% |
0.0 ± 0.0% |
3 |
Pairs counts every selected pair, failed or not. Timed overlap sessions counts the overlap sessions that produced timing intervals. Judged pairs counts the pairs with a valid label, and the label shares are over these pairs only.
With JUDGE=qwen the v1.5 section adds the semantic table. From the same validation run:
Category |
interaction_handling quality |
interaction_handling coverage |
relevance quality |
relevance coverage |
grounding quality |
grounding coverage |
joint quality |
joint coverage |
|---|---|---|---|---|---|---|---|---|
all |
73.9 ± 1.6% |
95.8 ± 5.9% |
85.9 ± 5.8% |
87.5 ± 5.9% |
52.2 ± 11.0% |
79.2 ± 5.9% |
32.2 ± 17.3% |
79.2 ± 5.9% |
user_interruption |
66.7 ± 47.1% |
83.3 ± 23.6% |
66.7 ± 47.1% |
83.3 ± 23.6% |
58.3 ± 11.8% |
83.3 ± 23.6% |
25.0 ± 35.4% |
83.3 ± 23.6% |
user_backchannel |
100.0 ± 0.0% |
100.0 ± 0.0% |
100.0 ± 0.0% |
100.0 ± 0.0% |
100.0 ± 0.0% |
50.0 ± 23.6% |
100.0 ± 0.0% |
50.0 ± 23.6% |
talking_to_other |
50.0 ± 23.6% |
100.0 ± 0.0% |
75.0 ± 35.4% |
66.7 ± 0.0% |
58.3 ± 11.8% |
83.3 ± 23.6% |
16.7 ± 23.6% |
83.3 ± 23.6% |
background_speech |
83.3 ± 23.6% |
100.0 ± 0.0% |
100.0 ± 0.0% |
100.0 ± 0.0% |
16.7 ± 23.6% |
100.0 ± 0.0% |
16.7 ± 23.6% |
100.0 ± 0.0% |
The v1.0 unified section first shows per-repeat coverage and metric tables for pause handling, turn taking and user interruption. Coverage lists declared, selected, eligible and ineligible samples, exclusion reasons and the minimum observable horizon after the reference point. A sample rate uses eligible samples as its denominator; its Wilson 95% interval describes sampling uncertainty, while the all-selected bounds allow each ineligible outcome to be either false or true. Per-pause and seconds-per-second ratios pool their respective denominators over eligible samples. Other intervals bootstrap whole samples. n/a with zero eligible samples means no data. not reached with a positive eligible count means the requested response-latency quantile was not observed, including nonresponses; a window shorter than a response threshold limits the corresponding rate. The report labels any missing, stale, unsupported or incomplete unified artifacts unavailable and gives a CPU-only backfill command. It does not display stale numeric scores. Backchannel remains official-reference-only.
The following v1.0 official reference table has one row per selected subset. Selected counts the recorded samples, Eligible the ones the official evaluator can score, and Evaluated the ones it scored; then come Takeover rate, Latency (s), Relevance (0-5), Judge retries, Backchannel frequency and Backchannel JSD, with n/a where the subset’s task does not report a number. A subset whose sessions all failed still has a row, with Eligible 0. A subset that a repeat has not evaluated is listed under the table as not evaluated yet (judge has not reached it) or failed (with its evaluator log); its cells average the repeats that did evaluate it, and show n/a when none did. A nonzero Judge retries means the interruption takeover rate is inflated (see v1.0 scoring).
The listening-quality section comes last, with one table per dataset and the same mean ± deviation cells; see Listening quality for what each column measures. Its v1.5 table from a validation run (MiniCPM-o 4.5, 4 pairs per category, one repeat):
Category |
Replies |
Short replies |
Compared replies |
Omission rate |
WER |
UTMOS mean |
UTMOS p10 |
Voice similarity mean |
Voice similarity p10 |
|---|---|---|---|---|---|---|---|---|---|
all |
35 |
1 |
35 |
4.2% |
7.3% |
4.271 |
3.893 |
74.412 |
67.846 |
user_interruption |
9 |
0 |
9 |
3.9% |
7.0% |
4.358 |
4.163 |
76.408 |
68.144 |
user_backchannel |
8 |
0 |
8 |
5.5% |
6.7% |
4.248 |
3.960 |
72.195 |
67.167 |
talking_to_other |
10 |
0 |
10 |
3.1% |
8.7% |
4.226 |
3.868 |
75.081 |
70.286 |
background_speech |
8 |
1 |
8 |
4.0% |
7.1% |
4.250 |
3.910 |
73.422 |
67.977 |
Each repeat writes these files under $FDB_WORK/runs/RUN_NAME/repeat-N/:
Path |
Content |
|---|---|
|
The selected v1.5 pairs (empty when v1.5 was left out) and v1.0 samples ( |
|
Raw session traces, input and output audio, protocol reports |
|
|
|
v1.5 fixed-window WAVs and |
|
v1.5 timing, ASR coverage and, with |
|
v1.5 behavior labels with |
|
Semantic judge: control comparison, the exact packets sent, raw responses, per-pair outcomes and |
|
Readable v1.5 report: coverage, timing and semantic quality |
|
One 16 kHz WAV per reply in |
|
v1.0 windows ( |
With JUDGE=qwen, the “Official behavior label distribution” section of report.txt shows 0 / 0 and not_prepared, because that section counts GPT-4o labels only; the Qwen labels are in judge-qwen/summary.json and RESULTS.md.
Settings#
Per-run choices (--run-name, --repeat, sample selection, --num-shards, --log-level) are command options; --help on any subcommand lists them. Machine settings are environment variables, read in benchmarks/duplex/fdb/common.py:
Variable |
Default |
Meaning |
|---|---|---|
|
|
Root for downloads, environments and results |
|
|
|
|
unset |
The GPU indices this job may use. The first is the job GPU: it runs the one model server of |
|
|
The GPU index when |
|
|
Local ports. The judge also uses the next port for NCCL. Avoid 29500-29899, which the model server uses for NCCL |
|
|
Checkpoint of the model under test |
|
|
Recorded in the run manifest; must match |
|
|
Checkpoint of the Qwen judge |
|
|
Per-session client deadline for v1.5 and v1.0. v1.5 inputs run up to about 18 s; v1.0 inputs average about 18 s and some run past 90 s. Neither deadline may exceed the recorder’s 230 s |
|
|
Venv that runs ASR, timing, the evaluators and the judge clients |
Running jobs in parallel#
One job uses one GPU. To run several at once, open one terminal per GPU and give each its own GPU index and run name:
export FDB_WORK="${FDB_WORK:-$HOME/fdb}"
unset GPU SERVER_PORT JUDGE_PORT
export CUDA_VISIBLE_DEVICES=1 # 0 in the first terminal, 1 in the second, and so on
RUN=minicpmo-30-gpu1
python -m benchmarks.duplex.fdb generate --run-name "$RUN" --v15-per-subset 30 --v10-per-subset 30
python -m benchmarks.duplex.fdb asr --run-name "$RUN"
python -m benchmarks.duplex.fdb judge --run-name "$RUN"
python -m benchmarks.duplex.fdb aggregate --run-name "$RUN"
The ports, the judge config and the compile caches follow the GPU index: the model port is 8097 + 10×GPU, the judge port 30000 + 10×GPU, the judge config is in $FDB_WORK/judge/port-<judge port>/ and the caches in $FDB_WORK/cache/gpu-<index>/. The first line generate prints confirms them, for example Job GPU 1: model port 8107, judge port 30010 (nccl 30011), .... The unset line matters when a shell profile sets GPU or a port. Never point two terminals at the same run name.
Serving metrics#
Serving metrics answer how well a server keeps up when several sessions share it. They are computed only from what the client records, which means any server that speaks the same realtime session protocol or the legacy turn-based protocol can be put in the same table.
Start the server yourself, with max_sessions at least the largest level, then sweep:
python -m benchmarks.eval.benchmark_duplex_serving sweep \
--dataset v1.5 --concurrency 1,2,4,8 --output "$FDB_WORK/serving/minicpmo" -- \
--url ws://127.0.0.1:8097/v1/realtime --profile minicpmo-native-pr2377 \
--dataset-root "$FDB_WORK/dataset/v1.5" --dataset-revision "$(cat $FDB_WORK/dataset/v1.5.revision)" \
--server-revision "$(git rev-parse HEAD)" --model openbmb/MiniCPM-o-4_5 --model-revision 503e754 \
--max-per-subset 4
The options after -- go unchanged to the record command of benchmark_duplex_v15 (or benchmark_duplex_v10 with --dataset v1.0); the sweep adds --concurrency N and --output <output>/concurrency-N for each level. It writes serving.json, SERVING.md and one run directory per level, and keeps going when a level has failed sessions, since those are part of the result. report --run <run> --run <run> ... --output serving.json builds the same table from runs that are already recorded.
Each level is one row:
Column |
Meaning |
|---|---|
|
Sessions open at once, averaged over the level’s recording; the first and last sessions of a level have fewer neighbours |
|
Sessions whose protocol replay failed or that broke off |
|
Sessions denied admission, and sessions whose input drifted more than one 80 ms packet from its schedule. A pacing violation means the load generator fell behind, so the level does not measure the server |
|
Native profiles only. Units whose lag exceeds one nominal unit (1000 ms for MiniCPM-o), plus every unit of a failed session |
|
Native profiles only. Receipt of |
|
Native: from the unit carrying a reply’s first audio being ready to that audio’s receipt. Turn-based: from |
|
The smallest startup buffer with which a reply plays without a gap. It does not depend on |
|
Replies that a player starting |
|
Turn-based only. The time from a reply’s first to its last audio arrival over the audio before the last chunk. Native profiles show |
|
|
A level meets the targets when no session failed, no session was denied admission, no input pacing was violated, and, for native profiles, the unit miss rate is at most 1%, or, for turn-based profiles, the reply RTF p90 is below 1. A turn-based server’s unit receipts track input intake, so it is judged by how fast its replies stream. The capacity is the highest level below the first one that misses; when every recorded level meets the targets, the capacity is at least the highest one and a higher level has to be recorded.
These are client-side times on the client’s host: they include the network to the server and leave out the player’s device and the acoustics. Where the time goes inside SGLang-Omni comes from the request event recorder instead; see Full Duplex session Unit metrics.
MiniCPM-o 4.5 on one H100
One H100 80 GB ran a short v1.5 audio cohort with the native session protocol. Each recording selected the first 16 naturally sorted pairs from each of the four v1.5 subsets, including both clean and overlap variants: 128 sessions and 1,744 nominal input units per recording. The server allowed 64 session slots, but its memory pools were sized for this short cohort. A warmup pair was excluded. Each recording used a fresh client process against the same continuously running server.
For this campaign, a recording passed only with fewer than 1% late or missing units, all 128 sessions successful, no admission failures, no input pacing violations, and the requested overlap of complete input streams. A unit missed when sglang.unit.done arrived more than 1,000 ms after the client had its input ready. This strict campaign gate differs from the general serving target above, which permits exactly 1% misses. session.created alone does not establish model admission.
Concurrent sessions |
Recorded result |
Qualification |
|---|---|---|
1, 2 |
One screening run each, with no unit misses |
Both passed; neither was repeated. |
4 |
Five passing runs, 640 successful sessions, 0 of 8,720 unit misses |
Highest tested level confirmed by five runs. The median of the five per-run unit-lag p95 values was 421.4 ms, with a range of 406.5 to 433.1 ms. |
8 |
Two valid runs: 3 of 1,744 misses (0.172%) and 25 of 1,744 (1.433%) |
The first passed; the second failed the unit gate. |
16 |
Two valid runs: 103 of 1,744 misses (5.906%) and 90 of 1,744 (5.161%) |
Both failed the unit gate. |
32, 48, 64 |
CUDA out-of-memory failures with failed sessions and invalid aggregate input pacing |
Observed failures. Their latency and unit-miss aggregates are not qualified paced-load results. |
The C4 runs reached four overlapping complete input streams with no session, admission, or pacing failures. At the separate 80 ms playback reserve, simulated playback still underruns in 438 of 731 C4 replies. Each C4 run recorded audio in 126 or 127 of its 128 sessions. Protocol success does not establish response quality. Concurrencies 5 through 7 were not measured, so the result does not identify an exact maximum concurrency of four. The cohort also does not qualify long calls, video input, semantic quality, or an H100 versus H200 comparison.
The 128 inputs totaled 1,676.565 seconds; the longest was 15.763 seconds. The run used Python 3.12.3, torch 2.13.0+cu130, sglang 0.5.21, transformers 5.12.1, websockets 17.1, and soundfile 0.13.1, with 64 assigned CPU threads. The screen order was C8, C16, C32, C48, C64, C4, C2, then C1. After a valid C8 repeat failed the unit gate, repeats 3 through 5 at C8 and C16 were cancelled and four more C4 runs were recorded. The lower-concurrency cells therefore include the same server’s earlier high-load history.
To reproduce the configuration, use server and serving-tool commit 3ae4c2464eb0b44e0850a9f17da46461a688bc32, the client changes with the measured file bytes, and model snapshot 503e754207c94da6bb26850b4469f367c9ea3582. The client changes move per-variant report writes off the event loop and import only the selected recorder. The separate empty-EOS scoring fix in PR #2868 was not applied to these measurements; the selected inputs had no integer-second durations. Keep the 80 ms input pacing tolerance unchanged.
The --dataset-revision value below is the retained historical label, not a content pin for the upstream Drive archives. The campaign verified all 448 selected file hashes against its retained inputs. Those hashes remain with the private recording artifacts; a fresh dataset download alone does not establish identical inputs.
After setup, point MODEL_PATH at that exact model snapshot and use the v1.5 dataset described above. Start the server in one terminal:
export FDB_WORK="${FDB_WORK:-$HOME/fdb}"
export MODEL_PATH="$FDB_WORK/models/MiniCPM-o-4_5"
export OMP_NUM_THREADS=8 MKL_NUM_THREADS=8
python -m sglang_omni.cli serve \
--model-path "$MODEL_PATH" --variant session --max_sessions 64 \
--thinker.engine.kv_cache_bytes 17179869184 \
--perception.gpu_memory_fraction null \
--perception.total_reserve_bytes 17179869184 \
--talker.engine.max_total_tokens 65536 \
--talker.gpu_memory_fraction 0.08 \
--enable-realtime --host 127.0.0.1 --port 8099
Run each concurrency and repeat in a new client process and output directory. For example, set CONCURRENCY=4 and vary REPEAT from 1 through 5:
export FDB_WORK="${FDB_WORK:-$HOME/fdb}"
CONCURRENCY=4
REPEAT=1
python -m benchmarks.eval.benchmark_duplex_serving sweep \
--dataset v1.5 --concurrency "$CONCURRENCY" \
--output "$FDB_WORK/serving/h100-short/c${CONCURRENCY}-r${REPEAT}" -- \
--url ws://127.0.0.1:8099/v1/realtime \
--profile minicpmo-native-pr2377 \
--dataset-root "$FDB_WORK/dataset/v1.5" \
--dataset-revision 3e799c45a045256f47d5f1c9cda90157e2d2ec9e \
--server-revision 3ae4c2464eb0b44e0850a9f17da46461a688bc32 \
--model openbmb/MiniCPM-o-4_5 \
--model-revision 503e754207c94da6bb26850b4469f367c9ea3582 \
--timeout 90 --max-per-subset 16
The reported results came from one C1 and C2 screen, five C4 runs, two C8 runs, and two C16 runs. C32, C48, and C64 were attempted once each; their failed recordings remain part of the observation. The complete raw traces remain in the original experiment storage, and the selected local audit copy cannot rescore every session. Neither raw tree is hosted with this runbook.
Notes#
Keep the settings fixed across the repeats of a run. Mixing repeats with different selections,
--num-shardsor judges makes their mean meaningless; use a new run name instead.--num-shardschanges what you measure. With--num-shards 2, two sessions share the GPU, so the latencies are measured under load and do not compare with one shard. The model server keepsmax_sessionsat 2, sogeneraterefuses more than 2 shards per server up front.--gpusspreads the shards over GPUs.--gpus 0,1starts one model server on each listed GPU, each with its own port (8097 + 10×GPU) and caches, and sends shard i to the i-th server modulo their count. With as many shards as GPUs every session has a GPU to itself, so the timing compares with one shard.asrandjudgestill run on the job GPU, the first index ofCUDA_VISIBLE_DEVICES, soCUDA_VISIBLE_DEVICES=0,1with--gpus 0,1records on both GPUs and transcribes on GPU 0.The first N samples are not a random sample. They give fast numbers that compare between runs, not estimates for the full dataset.
Failed sessions are never dropped. They stay in the denominators as ineligible, and an empty set of intervals shows as
n/a, not as zero.
Troubleshooting#
Symptom |
Fix |
|---|---|
|
The message ends with the last lines of the server log; the full log is |
|
The model server died while recording; the message ends with the last lines of that GPU’s |
|
The judge server died; the message names the phase and ends with the last lines of |
|
Another server holds the port, often left from an interrupted run or a second job on the same GPU. Stop it, or give this job another |
|
That repeat was generated before; use the next |
|
An earlier repeat of this run used another selection; pass the same options, or use a new run name |
|
The earlier repeats of this run left v1.0 out; continue with the option change the message names, or use a new run name |
|
Give at least one dataset a nonzero count |
|
The workspace was set up without v1.0; rerun |
|
|
A shard log reports |
They stay in the denominators. Read |
|
Lower |
|
Use at least as many shards as GPUs in |
|
Unset |
HTTP 503 in a record log |
Another client may hold the server’s sessions; check for competing recordings |
|
Export indices such as |
|
List only GPUs that |
|
Export a single index for that command |
|
Unset |
|
Rerun the command the warning prints; it adds |
|
|
|
ASR completed, but the saved export, transcript or annotation did not score. Fix the stated cause and run the CPU-only backfill command printed in the warning; it does not repeat ASR or v1.5 work. Rerun |
|
The report names a missing export/ASR file, missing output pair, stale hashes, unsupported version or incomplete subset. Repair the export or ASR if named, then use the per-repeat backfill command in the report and rerun |
|
The terminal has the traceback. A |
|
The traceback is at the end of |
|
The v1.0 relevance judge answered without a rating; the ledger in |
|
The judge config changed since this repeat was judged, for example after an SGLang upgrade. Delete |
|
The judge missed a control case, listed in the output; nothing graded alongside the controls is kept. Check that the judge server runs the pinned Qwen3.8-27B revision; do not grade with a configuration that fails the controls |
|
The semantic judge settings, prompt or inputs changed since this repeat was graded. Delete |
|
The endpoint answered with a model other than |
|
Rerun |
|
Install the FFmpeg shared libraries (version 4 to 8) and rerun |
Scoring CLI reference#
The runbook wraps three CLIs, which also score a recording by hand: benchmarks.eval.benchmark_duplex_v15 records v1.5 sessions, benchmarks.eval.benchmark_duplex_reference scores them with the official v1.5 ASR, timing and behavior code, and benchmarks.eval.benchmark_duplex_v10 records and scores v1.0 sessions. Run eval "$(python -m benchmarks.duplex.fdb env)" first to set the variables the commands below use. Recording and export run in the sglang-omni venv (python); ASR, timing, judging and evaluation need the packages setup installs in the scoring venv, so those commands run with "$SCORING_PYTHON". record needs the model server and custom-judge the judge server; python -m benchmarks.duplex.fdb serve-model and serve-judge start them in the foreground until Ctrl-C.
Dependencies and source
The official scoring scripts and the behavior prompt are not vendored. They are loaded from a Full-Duplex-Bench checkout at revision 3e799c45a045256f47d5f1c9cda90157e2d2ec9e, and their SHA-256 is checked before every run. Their CC BY-NC 4.0 license applies.
The scoring venv uses Python 3.12, torch and torchaudio 2.11.0+cu130, NeMo 3.0.0, Silero VAD 6.2.1, numpy 2.5.3, scipy 1.18.1, soundfile 0.14.0, openai 3.28.0, pydantic, python-dotenv and torchcodec 0.11. Each phase records the package versions it ran with. No command downloads a model on its own. ASR uses a local nvidia/parakeet-tdt-0.6b-v2 checkpoint at revision ae9ad07059c7c739ffaf932226a8fe64ae2620b0, .nemo SHA-256 d99e39955c9d3d0350d8fb7c75e40c64a2b2eaeb003883d7c941fd2e8747b28c.
v1.5 scoring
Record and export:
OUT=results/fdb-manual
python -m benchmarks.eval.benchmark_duplex_v15 record \
--profile minicpmo-native-pr2377 \
--dataset-root "$FDB_DATASET" --dataset-revision "$(cat "$FDB_WORK/dataset/v1.5.revision")" \
--url "$REALTIME_URL" --model "$MODEL_ID" --model-revision "$MODEL_REVISION" \
--server-revision "$(git rev-parse HEAD)" --timeout "$SESSION_TIMEOUT_S" \
--output "$OUT/recording"
python -m benchmarks.eval.benchmark_duplex_reference export \
--engine model --trace-format realtime-pcm16-v1 --run "$OUT/recording" \
--dataset-root "$FDB_DATASET" --out "$OUT/reference-audio"
record takes the full dataset unless --max-per-subset N or repeated --sample-id category/id narrows it, and exits 0 only when every selected session passes. export cuts the observation windows into a new directory without touching the recording. With --dataset-root, every dataset pair that was not recorded counts as missing, so a partial run also passes --only category/id for each recorded pair. Repeated --run takes disjoint shards; --engine is a free label for the scored cohort.
A turn-based (half-duplex) endpoint speaks the conversation protocol instead of the native session protocol: it takes a turn_detection object in session.update, sends none of the sglang.input_audio.* receipts or sglang.unit.done, and streams response.audio.delta at 24 kHz. The profile qwen3-omni-half-duplex switches the recorder, the protocol oracle and the export to that protocol:
python -m benchmarks.eval.benchmark_duplex_v15 record \
--profile qwen3-omni-half-duplex \
--turn-detection '{"type": "server_vad", "silence_duration_ms": 500}' \
--dataset-root "$FDB_DATASET" --dataset-revision "$(cat "$FDB_WORK/dataset/v1.5.revision")" \
--url "$REALTIME_URL" --model "$MODEL_ID" --model-revision "$MODEL_REVISION" \
--server-revision "$(git rev-parse HEAD)" --timeout "$SESSION_TIMEOUT_S" \
--output "$OUT/recording"
python -m benchmarks.eval.benchmark_duplex_reference export \
--engine model --trace-format realtime-legacy-pcm16-v1 --run "$OUT/recording" \
--dataset-root "$FDB_DATASET" --out "$OUT/reference-audio"
--turn-detectionis required for this profile and rejected for native ones. It is sent once per session, so a detector sweep is one run per setting against the same server:server_vadtakesthreshold,prefix_padding_msandsilence_duration_ms,semantic_vadtakeseagerness, and both takeinterrupt_response. The manifest records it underconfig.legacy.benchmark_duplex_v10 recordtakes the same options.A session fails unless
session.updatedechoes the requestedturn_detection.type, so a detector that silently falls back is caught. There are no input or drain receipts: the session ends--legacy-tailseconds (default 1.0) after the input with a no-opsession.update, whosesession.updatedproves the server is alive.Any
errorevent fails the session. A response cancelled with reasonturn_detectedis accepted only after aninput_audio_buffer.speech_startedduring it.Simulated playout follows the server’s barge-in: it is cut at
output_audio_buffer.cleared, or atinput_audio_buffer.speech_startedunless the session setinterrupt_response: false. The export applies the same cut, recordsbarge_in_cuts, and marks a session eligible only when its protocol verdict ispass.
A turn-based endpoint can also speak the session protocol. It takes turn_detection under session.audio.input in session.update, grants interaction: turn_based with 200 ms units and a flushed input tail, sends the sglang.input_audio.* receipts and sglang.unit.done, detects the turns itself and streams response.output_audio.delta at 24 kHz. The profile turn-based records it, and its export takes --trace-format realtime-turn-based-pcm16-v1:
python -m benchmarks.eval.benchmark_duplex_v15 record \
--profile turn-based \
--turn-detection '{"type": "server_vad", "silence_duration_ms": 500}' \
--dataset-root "$FDB_DATASET" --dataset-revision "$(cat "$FDB_WORK/dataset/v1.5.revision")" \
--url "$REALTIME_URL" --model "$MODEL_ID" --model-revision "$MODEL_REVISION" \
--server-revision "$(git rev-parse HEAD)" --timeout "$SESSION_TIMEOUT_S" \
--output "$OUT/recording"
python -m benchmarks.eval.benchmark_duplex_reference export \
--engine model --trace-format realtime-turn-based-pcm16-v1 --run "$OUT/recording" \
--dataset-root "$FDB_DATASET" --out "$OUT/reference-audio"
--turn-detectionis required. These endpoints accept onlyserver_vad, configured bythreshold,prefix_padding_msandsilence_duration_ms. A session fails unlesssession.updatedechoes the type undersession.audio.input.turn_detection. The manifest records the setting underconfig.turn_based.The session ends like a native one:
sglang.input_audio.endat the input end, thensession.closeaftersglang.input_audio.drained. The server first answers the utterance still open at the input end, so--timeoutmust cover the longest input plus that answer.A response may end
completedwith reasonstoporempty_turn, orcancelledwith reasoninterrupted. Each cancellation needs its own earlierinput_audio_buffer.speech_started, but not one during the response, because the server can create the reply it cancels after that event. Anyerrorfails the session.supports_truncatemay take either value.The cut point differs from the legacy rule. These endpoints send no
output_audio_buffer.cleared, so simulated playout and the export do not cut atspeech_started; they drop queued audio when aresponse.donewith statuscancelledarrives. As for the legacy protocol, the export recordsbarge_in_cutsand marks a session eligible only when its protocol verdict ispass.v1.0 recordings export the same way:
benchmark_duplex_v10 reference-export --trace-format realtime-turn-based-pcm16-v1resamples the 24 kHz audio to 16 kHz and keeps only sessions whose protocol verdict ispass.
Score:
TREE=(--reference-source "$FDB_SOURCE" --tree "model=$OUT/reference-audio")
"$SCORING_PYTHON" -m benchmarks.eval.benchmark_duplex_reference asr "${TREE[@]}" \
--out "$OUT/scores" --nemo "$PARAKEET_NEMO" --nemo-sha256 "$PARAKEET_SHA256" --device cuda
"$SCORING_PYTHON" -m benchmarks.eval.benchmark_duplex_reference timing "${TREE[@]}" \
--out "$OUT/scores" --audio-loader soundfile
"$SCORING_PYTHON" -m benchmarks.eval.benchmark_duplex_reference prepare-judge "${TREE[@]}" --out "$OUT/scores"
"$SCORING_PYTHON" -m benchmarks.eval.benchmark_duplex_reference judge "${TREE[@]}" --out "$OUT/scores" \
--judge gpt-4o-2024-08-06 --api-key-env OPENAI_API_KEY
"$SCORING_PYTHON" -m benchmarks.eval.benchmark_duplex_reference summarize "${TREE[@]}" --out "$OUT/scores"
"$SCORING_PYTHON" -m benchmarks.eval.benchmark_duplex_reference report --scores "$OUT/scores" --engine model
Run the phases in order on one output directory with the same --tree; repeat --tree to score several cohorts together. CUDA ASR needs exactly one visible GPU. A rerun resumes from the receipts; --retry-failed retries failed work units, and --limit N caps the units of one call. After changing the recording, the scoring configuration or the reference code, use a new output directory. A zero exit means the phase finished, not that every sample qualified.
report reads only summary.json and the manifest, so it needs no GPU or key. It prints the populations, protocol verdicts, ASR and timing coverage, interval statistics with confidence intervals, and the behavior label distribution. --semantic-summary adds the semantic judge’s summary.json, and --replay replay.json adds offline replay agreement in the form {"selected": N, "replayed": M, "samples": [{"sample": "category/id", "variant": "overlap", "recorded_status": "pass", "status": "match"}]}.
The GPT judge reads its key from OPENAI_API_KEY, never from an argument or a manifest; --base-url selects a compatible endpoint. It sends the exact reference prompt with seed 1 and makes at most three attempts. A reply from a model other than gpt-4o-2024-08-06, a malformed label or a failed request leaves the label invalid, with no fallback judge. Without a key the labels stay pending and summarize still runs.
custom-judge sends the same prompt to a self-hosted model. Its labels never replace GPT-4o labels. It reads an existing scoring directory and writes to a separate new one:
"$SCORING_PYTHON" -m benchmarks.eval.benchmark_duplex_reference custom-judge "${TREE[@]}" \
--source-scores "$OUT/scores" --out "$OUT/judge-qwen" \
--judge-config "$JUDGE_CONFIG" --base-url "$JUDGE_URL"
"$SCORING_PYTHON" -m benchmarks.eval.benchmark_duplex_reference custom-summarize "${TREE[@]}" \
--source-scores "$OUT/scores" --out "$OUT/judge-qwen" \
--judge-config "$JUDGE_CONFIG"
judge and serve-judge write the judge config to $JUDGE_CONFIG, which is $FDB_WORK/judge/port-$JUDGE_PORT/judge-config.json:
{
"model_id": "Qwen/Qwen3.8-27B",
"model_revision": "<40-hex commit>",
"tokenizer_id": "Qwen/Qwen3.8-27B",
"tokenizer_revision": "<40-hex commit>",
"served_model": "qwen3.8-27b",
"precision": "bf16",
"enable_thinking": false,
"decoding": {
"temperature": 0.0, "top_p": 1.0, "top_k": -1,
"min_p": 0.0, "repetition_penalty": 1.0, "max_tokens": 512
},
"seeds": [1, 2, 3],
"server_launch_receipt": "launch-receipt.json",
"server_launch_receipt_sha256": "<sha256 of the receipt>"
}
Both revisions must be full commit hashes. temperature, top_p and max_tokens are required; the other decoding fields and enable_thinking are sent only when present, so leave out any the endpoint does not support. The launch receipt records the server command, runtime version, checkpoint and tokenizer revisions and precision, and the client checks its hash, because the model name an endpoint returns does not prove which checkpoint it loaded. The endpoint key goes in CUSTOM_JUDGE_API_KEY, or a placeholder such as EMPTY without authentication. A rerun with the same config skips finished samples; a changed config or selection needs a new --out. --concurrency (default 16) caps the requests in flight and is recorded with the run. --retry-failed retries transport and parsing failures only; wrong models, invalid labels and non-stop finishes stay invalid. The per-category Wilson 95% intervals describe dataset sampling, not judge agreement or generation variance.
v1.0 scoring
v1.0 runs the official get_transcript/asr.py and evaluation/evaluate.py unchanged:
OUT=results/fdb10
python -m benchmarks.eval.benchmark_duplex_v10 record \
--profile minicpmo-native-pr2377 \
--dataset-root "$FDB10_DATASET" --dataset-revision "$(cat "$FDB_WORK/dataset/v1.0.revision")" \
--url "$REALTIME_URL" --model "$MODEL_ID" --model-revision "$MODEL_REVISION" \
--server-revision "$(git rev-parse HEAD)" --timeout "$V10_SESSION_TIMEOUT_S" \
--output "$OUT/recording"
python -m benchmarks.eval.benchmark_duplex_v10 reference-export \
--run "$OUT/recording" --dataset-root "$FDB10_DATASET" --out "$OUT/reference"
"$SCORING_PYTHON" -m benchmarks.eval.benchmark_duplex_v10 reference-asr \
--tree "$OUT/reference" --reference-source "$FDB_SOURCE" \
--nemo "$PARAKEET_NEMO" --nemo-sha256 "$PARAKEET_SHA256" --device cuda
CUDA_VISIBLE_DEVICES="" "$SCORING_PYTHON" -m benchmarks.eval.benchmark_duplex_v10 unified-score \
--tree "$OUT/reference"
"$SCORING_PYTHON" -m benchmarks.eval.benchmark_duplex_v10 reference-evaluate \
--tree "$OUT/reference" --reference-source "$FDB_SOURCE"
reference-exportwrites each sample’s window as<subset>/<id>/output.wavwith its annotation into a new directory, plusmanifest.json. Repeated--runtakes shards. An ineligible session gets no folder and keeps its reasons in the manifest. A recording made with a turn-based profile needs--trace-format realtime-legacy-pcm16-v1; as in the v1.5 export, the audio is resampled from the profile’s output rate and a session is eligible only when its protocol verdict ispass.reference-asrtranscribes every sample with the local Parakeet checkpoint. For user interruption it cuts the audio at the end of the interruption and shifts the word times back, so the transcript holds only the reply. It writesoutput.jsonper sample and the receiptasr.json, and runs once per export.unified-scorereads the saved manifest, ASR transcript and annotations on CPU, then writesunified.jsonandunified-samples.jsonl. It is independent of the official evaluator and can be rerun for an existing export without repeating ASR. To backfill a runbook repeat, useeval "$(python -m benchmarks.duplex.fdb env)", thenCUDA_VISIBLE_DEVICES="" "$SCORING_PYTHON" -m benchmarks.eval.benchmark_duplex_v10 unified-score --tree "$FDB_WORK/runs/RUN_NAME/repeat-N/v10/reference", replacingRUN_NAMEandNwith the recorded run and repeat; rerunaggregateafterward.reference-evaluateruns the evaluator per subset and stores the printed[Result]numbers, the counts and the log path insummary.json.--subsetevaluates only the named subsets; the rest can follow later.
The interruption relevance is rated by gpt-4-turbo with seed 0, as the official evaluator requests, for every interruption the model took over. That needs OPENAI_API_KEY (or the variable named by --api-key-env); without a key, evaluate the other subsets with --subset. --base-url URL --served-model NAME sends the same prompts to a self-hosted judge, with thinking off and the reply capped at 512 tokens, since the evaluator sets neither and SGLang would stop at 128 tokens. summary.json records the endpoint actually used and marks the rating official only for OpenAI’s endpoint without --served-model. Every exchange is appended to judge/user-interruption-<UTC time>.jsonl.
The official evaluator has a retry bug: when a rating does not parse, it resends the same request without limit, and each retry counts that sample’s takeover again. The client stops after three identical requests, which fails the subset, and summary.json reports retries. A nonzero value means the interruption takeover rate is inflated.
Measurement definitions
Input pacing. Audio is sent in 80 ms packets. A packet that starts more than 80 ms off its source cadence makes the session ineligible. This bounds transport jitter only.
Observation window. Both datasets score the output over
[0, T], where T is the input length, counted from the first input packet. Received audio plays first-in first-out, so initial delay, gaps and queueing stay in; audio still queued at T is cut, and the rest is silence. The window is mono 16 kHz PCM16 with exactly the input’s sample count. An error inside the window makes the session ineligible; silence is valid output.v1.5 intervals. The official timing code merges VAD speech spans across gaps of 0.6 s (user) and 0.5 s (model). Stop intervals are overlaps of user and model spans; response intervals run from the end of a user span to the start of the next model span.
summary.jsonreports their pooled means and medians with sample-cluster bootstrap intervals.v1.0 takeover and latency. The evaluator reads the Parakeet transcript of the window. A sample took the turn when the words span at least 1 s or number more than 3. The latency is the first word’s start minus the annotated end of the user’s turn, clamped at 0. Pause handling looks at the whole window, not only at the annotated pauses.
Missing values. Empty interval sets and missing labels are null, never a zero latency or 0%. The counts keep every selected, eligible, invalid and unscored sample.
Limits. Simulated playout is not acoustic latency, and the window can cut long replies. One generation per sample does not estimate generation variance. Prosody and MOS are not measured.
Verification
The unit tests run the official formulas on synthetic fixtures without a GPU or API key, and cover export, missing samples, valid silence, timing, ASR deduplication, bounded and concurrent judging, the semantic judge’s control gate, resumption, and the runbook’s selection, phase gating, server checks and aggregation for both datasets:
FDB_REFERENCE_SOURCE="$FDB_SOURCE" python -m pytest -q \
tests/unit_test/benchmarks/test_duplex*.py tests/unit_test/benchmarks/test_fdb*.py
Export is deterministic: exporting a recording again reproduces every eligible WAV byte for byte, and summarizing saved transcripts and intervals again reproduces the same summaries.
Full-Duplex-Bench v1.0 unified metrics#
unified-score scores a Full-Duplex-Bench v1.0 reference tree with turn-taking metrics that do not depend on how a system decides to speak. It reads the same Parakeet transcripts and dataset annotations as the official evaluator, and for user interruption it also runs Silero VAD on the exported audio, so native full-duplex, turn-based and cascade systems are scored by the same rules. It needs no GPU, LLM or network access.
Why the official numbers are only a reference#
reference-evaluate runs the pinned official evaluator unchanged, and its numbers stay in the report as the reference column. Four of its rules measure something other than their names suggest:
Talking over the user gives the best latency.
eval_smooth_turn_taking.pysets a negative latency, a reply that starts before the user’s turn ends, to 0. On one 119-sample capture of a native full-duplex model, 39 latencies were clamped this way.Pause takeover does not look at the pauses.
eval_pause_handling.pynever readspause.json. It counts a takeover in every nonempty transcript of the whole window except one that both spans less than 1 s and has at most 3 words. Speech before the user’s first pause counts, and an output of at most 3 words within 1 s, such as a takeover that the user’s resumed speech cuts off, does not. On Candor pause handling, the official rule found a takeover in 30 samples of a turn-based system that answers after 500 ms of silence, while that system’s transcript contained a word onset at or after the first annotated pause in 67.Non-responders leave the latency average. Latency is averaged only over samples that took the turn, so a model too slow to answer inside the window drops out and can look faster.
Not every interruption interrupts.
eval_user_interruption.pyscores every sample alike, whether or not the model was speaking when the user cut in. On one 200-sample capture of a native full-duplex model, no speech was detected at the interruption onset in 87 samples, and a reply after R in those samples still enters the official takeover rate, latency and judge rating.
Inputs and checks#
Run it on a tree written by reference-export and transcribed by reference-asr (see the runbook). All times are seconds on the input window [0, T], where T is the input length recorded in manifest.json. Each transcript word has a start and an end from output.json; a word with a null end counts as zero-length at its start. Times are rounded to the microsecond before any comparison. This removes float noise in the files (an annotation at 6.32000000000005 against a word at 6.32), not ASR alignment error.
Loading a sample checks its times and stops with the sample id and the problem if any check fails: every word starts in [0, T]; the reference times E, S and R defined below lie in [0, T] with S <= R; pause.json lists at least one pause, each [a, b] with a <= b inside [0, T], sorted and not overlapping; and no interruption word starts before R.
These annotations do not reliably label backchannels versus aborted turn starts; we therefore count every word onset, including a one-word “yeah”. The rules are a strict word-timing proxy that also penalizes acknowledgments a listener might accept.
Metrics#
Pause handling
Subsets synthetic_pause_handling and candor_pause_handling, reported separately. The official evaluator scores the whole window as one user turn, and we operationally treat the window as a non-yielding user turn. From the start of the first annotated pause to T the user is pausing or speaking again, so a model word starting in that region takes the floor too early. Each pause owns the time from its start to the next pause start, and the last pause owns the time to T.
Metric |
Definition |
Better |
|---|---|---|
|
Samples with a model word starting at or after the first pause start |
Lower |
|
Pauses whose owned time contains a model word start, over all pauses |
Lower |
|
Annotated pause seconds covered by ASR word spans, over all annotated pause seconds |
Lower |
|
Samples with a model word starting inside an annotated pause |
Context |
|
Samples with a model word starting before the first pause start |
Context |
|
Samples with a model word that starts before the first pause start and ends after it |
Context |
|
Samples with any model word in the window |
Context |
pause_intrusion_rate is the headline. It counts onsets up to T, not only inside the pauses, because a model whose reaction time exceeds a pause replies after the user has resumed; this needs no added reaction-time parameter. inside_pause_onset_rate is the narrower count of onsets inside the pauses.
Onsets before the first pause are outside this pause-conditioned observation region: they cannot react to a pause that has not started. Their samples stay in the denominator, and a model still talking at the first pause counts once it starts another word. This is a scope restriction, not a judgment that early talk is harmless; pre_pause_speech_rate and carry_in_rate report it.
intrusion_s_per_pause_s measures coverage of annotated pause time by ASR word spans, not acoustic occupancy. Any word overlapping a pause adds that overlap, including one that started before the first pause, and a zero-length word adds nothing. pause_intrusion_per_pause is an owned-interval diagnostic, not a per-pause takeover probability: one continuing utterance can place words in several owned intervals, the intervals differ in length, and the last one runs to T. intrusion_words in unified-samples.jsonl counts the intruding word onsets, not utterances or seconds.
Smooth turn-taking
Subset candor_turn_taking. The reference time E is turn_taking.json[0].timestamp[0], the time the official evaluator uses as the end of the user’s turn. Each sample has exactly one outcome:
early_start: a model word starts before E.response: no word starts before E and at least one starts at or after it. The latency is the start of the first word minus E.no_response: no transcribed word in the window.
Metric |
Definition |
Better |
|---|---|---|
|
|
Lower |
|
|
Higher |
|
|
Lower |
|
Median with its interval, and p90, of the latency, where a sample without a |
Lower |
An early start never counts as a response, so talking through the end of the user’s turn cannot buy a short latency, and an early acknowledgment followed by a good answer is an early start. no_response means no transcribed word, not every failed sample, so a system can have no silent samples and still not reach its p90.
Let F(t) be the share of samples with a response latency of at most t. Then response_rate_ks is F(k), and a latency quantile is Q(q) = inf{t : F(t) >= q}, the ceil(qN)-th smallest latency with no interpolation. The quantiles and the response rates are one curve: Q(q) is at most k exactly when response_rate_ks is at least q, so the median is the time by which half of all samples have responded. A quantile that falls on a sample without a response is reported as not reached. Placing those samples at infinity is a scoring convention for a valid response within the window, not an estimate of when a silent system would answer.
User interruption
Subset synthetic_user_interruption. interrupt.json[0].timestamp gives the onset S and the end R of the user’s interruption. reference-asr cuts the audio at R before transcribing, so the transcript holds only words from R on and cannot show whether the model stopped. Silero VAD on the full output.wav supplies that (see Speech detection). Each sample records whether detected speech covers S (speech_at_onset, a segment with start <= S < end) and whether speech that began before R carries over it (speech_at_end, start < R < end; a reply that begins exactly at R does not). The outcome is decided in this order:
not_exercised: no detected speech at S, so the interruption did not cut into detected speech. The sample keepsspeech_at_end, so a model that starts talking during the interruption and talks past R stays visible.speaking_at_end: speech at S and at R. The words after R may continue the interrupted answer, so the sample has no latency. A model that stopped and then started a reply before R lands here too, the interruption counterpart of an early start.response: speech at S, none carried over R, and a transcribed word starts at or after R. The latency is the start of the first word minus R.no_response: speech at S, none carried over R, and no transcribed word after R.
Metric |
Definition |
Better |
|---|---|---|
|
Eligible samples with speech at S |
Context |
|
Exercised samples that are |
Lower |
|
Exercised samples with a |
Higher |
|
Exercised samples that are |
Lower |
|
Median with its interval, and p90, over exercised samples, as for turn-taking |
Lower |
|
Median with its interval, and p90, of the time from S to the end of the segment covering S; a segment that runs to T has no observed stop |
Lower |
|
Detected speech seconds inside [S, R] over interruption seconds, pooled over exercised samples |
Lower |
|
Exercised samples with a segment starting inside (S, R) |
Lower |
The outcome only checks the endpoint R. A model can keep talking through most of the interruption, stop just before R and still get a response; stop_latency_s, speech_s_per_interruption_s and restart_rate show that. None of them tells a reactive stop from an answer that ended on its own, which would need a matched run with the interruption muted.
Every metric except exercised_rate is over exercised samples. Whether a sample is exercised depends on the system as well as the dataset, because S is fixed in input time: a model that answers briefly or late is silent at S more often. A not_exercised sample therefore counts as an unknown outcome in the selected bounds of those metrics, like an ineligible one; for exercised_rate itself it is a known no. A model silent at every S gets bounds of [0, 1] and is visibly unmeasured, and a terse model’s lower bound drops. The bounds are worst-case completions under this fixed-onset protocol, not a bound on how a model would handle an interruption timed while it speaks. Read exercised_rate and the bounds with every interruption number.
Backchannel
icc_backchannel has no unified metric; the official frequency and JSD are the reference. eval_backchannel.py has known problems. Fixing them needs new definitions with their own validation, so they are only listed here:
It adds every VAD segment up to 3 s to the backchannel list, including the ones it classified as a takeover.
Its takeover flag is overwritten by each segment, and the loop stops at the first segment over 3 s, which also drops the later segments from frequency and timing.
The timing score is SciPy’s Jensen-Shannon distance with the natural log, at most about 0.83, while a sample without backchannels is given 1. The average therefore mixes the share of samples without backchannels with the timing distance of the rest.
Speech detection
The scorer runs the Silero model that load_silero_vad() loads, with Silero’s default threshold 0.5, minimum speech 250 ms and minimum silence 100 ms, and no padding. The default 30 ms of padding widens each segment on both sides and can move a reply that starts just after R to before it. output.wav must hold exactly T seconds of samples. Segments come from the whole file, so near S they can depend on audio after S. A pause longer than 100 ms ends a segment, so a model pausing between sentences exactly at S counts as not_exercised. On one 200-sample capture of a native full-duplex model, thresholds from 0.3 to 0.7 and minimum silences from 50 to 500 ms moved not_exercised between 85 and 95 samples, response_rate_2s between 0.952 and 0.957 and the median stop_latency_s between 1.04 and 1.27 s, and left speaking_at_end at 4. restart_rate ran from 0.05 to 0.27, because a shorter minimum silence splits speech into more segments, so it is a diagnostic and does not rank systems.
The audio is the received output played in order. For the turn-based realtime protocol the playout drops queued audio when the server reports that the user started speaking while interruptions are on; native full-duplex traces have no such event, so audio a system has already sent plays out, and the outcome describes the system as delivered.
Reading them together
Silence scores perfectly on pause handling, and one canned word right after E counts as a response. Read pause restraint and response rates together, from the same system configuration; neither is enough alone.
Denominators and intervals#
Sample shares and latency quantiles are over the subset’s eligible samples, and for interruption over its exercised ones. Eligible samples are those whose capture passed the
reference-exportchecks (input pacing, an error-free observation window, dataset hashes that match the run). The pooled ratios divide by the pauses and the pause seconds of the eligible samples.declaredis the full subset size.Ineligible samples are counted under
counts, never dropped silently. A sample can have several reasons, so the counts incounts.ineligible_reasonsneed not sum toineligible.An ineligible sample’s outcome is unknown, and a capture failure can depend on the system. Every sample share therefore also has
selected_bounds,[count / selected, (count + unknown) / selected]: its value over all selected samples with each unknown outcome taken as no, then as yes. The unknown samples are the ineligible ones, plus thenot_exercisedones for every interruption metric exceptexercised_rate.coverageis eligible over selected for each subset.A share of samples has a 95% Wilson interval, whose endpoints are exactly 0 at a count of 0 and exactly 1 at a count of n. The pooled ratios and the median latency have a 95% percentile bootstrap interval that resamples whole samples (2000 resamples, seed 0), because the pauses of one sample are not independent. Each resample’s median and the interval endpoints use the same quantile rule as above. A resample with no pause time has no
intrusion_s_per_pause_sand is skipped. The output is deterministic.min_observable_sis the shortest time from the reference to T. T is the dataset input length, so every system has the same horizon on a sample. A response after T is not observed, soresponse_rate_ksis a lower bound whenmin_observable_sis below k. Candor turn-taking windows end about 5 s after E, so thereresponse_rate_5sis close to the share that responded inside the window.The intervals cover dataset sampling only, not repeated generations or ASR errors. To compare two systems, pair their rows in
unified-samples.jsonlbysample_idover the samples eligible in both, and for interruption exercised in both. Pairing does not remove selection bias, so report coverage,exercised_rateand the selected bounds as well, and for interruption how many samples eligible in both were exercised in both, only in one system, or in neither.
Run#
python -m benchmarks.eval.benchmark_duplex_v10 unified-score --tree "$OUT/reference"
It refuses a tree without asr.json, stops if the transcript or annotation of an eligible sample, or the output.wav of an interruption sample, differs from asr.json or manifest.json, and stops on a time that fails the checks above. It writes:
unified.json: per subset, the counts,coverageand every metric with its interval, plusselected_boundsfor sample shares andmin_observable_sfor turn-taking and interruption. Only the median latency has an interval, not p90. At the top are the definitions version (fdb-v1.0-unified-4), the hashes ofmanifest.jsonandasr.json, and the silero-vad version, model file hash and settings.unified-samples.jsonl: one row per selected sample of the four scored subsets (icc_backchannelhas no rows) with every per-sample quantity, including the pause flagspause_intrusion,inside_pause_onset,pre_pause_speech,carry_inandany_speech, and for interruption the outcome, S,speech_at_onset,speech_at_end,speech_before_onset,stop_s,speech_in_interruption_sandrestarts. Ineligible rows keep their sample id, subset and task and add their reasons.
It prints each subset’s metrics as value, interval and count, with the bounds over all selected samples for each share. When reference-evaluate has written summary.json from the same manifest.json and asr.json, its numbers are printed below them as the reference column; if either hash differs, the column is left out and a line says which input changed. Interruption numbers rated by a judge other than the official one are labeled with that judge’s model instead of official. summary.json is never written or used for scoring. Rerunning on the same tree writes the same bytes.
Not measured#
Why the model stops after an interruption.
stop_latency_ssays when the speech covering S ends, not whether the model stopped because of the interruption; that needs a matched run with the interruption muted.The end of the user’s speech in a pause sample. The window is treated as a non-yielding user turn. The official evaluator reads output chunks, not a user-end annotation, so it does not establish the true turn end either. A reply that starts in a short trailing silence counts as an intrusion, so not every counted onset is premature.
Relevance of a reply. Neither these metrics nor the official turn-taking score check that a reply answers the user. Only the official interruption judge rating covers it, and that rating depends on the judge model the run configured.
ASR errors. A missed or invented word changes the metrics it falls in. All systems share the pinned Parakeet checkpoint, which does not mean they share the same recognition error.
Acoustic latency. The output is a simulated playout of the received audio.