[Frontend] Add server-side VAD for turn-based omni models (#6618)

Signed-off-by: LHXuuu <xulianhao.xlh@antgroup.com>
This commit is contained in:
LHXuuu authored and GitHub committed 2026-09-08 03:17:00 -07:00
1 parent 031612f250
commit c9c25b33c3
36 files changed
+4446 -940

No files matched your search

+4 -4
View File
@@ -66,7 +66,8 @@ The checkpoint keeps these verified contracts:
- scheduler data-plane append over a resumable request;
- stale epoch/turn/response fencing;
- `/v1/duplex` and OpenAI Realtime projection;
- optional OpenAI `server_vad` turn detection backed by per-session Silero VAD;
- optional OpenAI `server_vad` turn detection backed by a process-shared Silero
model and per-session VAD state;
- existing JoyVL behavior.
The checkpoint does not claim:
@@ -652,7 +653,7 @@ EOS decisions advance the model conversation.
Explicit Realtime clients may still use `input_audio_buffer.commit` to create a
conversation item. The translator validates that wire input before producing a
commit carrying `realtime_item_id`. Native auto-response may already have
commit carrying `item_id`. Native auto-response may already have
streamed those PCM samples into the runtime, so the runner accepts a validated
commit even when no runtime-side chunk remains. A truly empty explicit buffer
continues to return `input_audio_buffer_empty`.
@@ -677,8 +678,7 @@ multi-turn runtime evidence.
During auto-response overlap, `preserve_realtime_input` distinguishes "do not
append this silent chunk to the native buffer" from "clear the open Realtime
item". Silent overlap no longer discards earlier user PCM. A separate explicit
`server_vad` policy is documented in the Realtime contract above.
item". Silent overlap no longer discards earlier user PCM.
The first chunk of one overlapping input item also reserves its target model
turn. A later Realtime commit uses that reserved identity even if response EOS
+24 -17
View File
@@ -1,17 +1,17 @@
# Realtime Duplex API
vLLM-Omni serves full-duplex speech models over a WebSocket endpoint,
`/v1/realtime?duplex=1`, that speaks the OpenAI Realtime vocabulary plus a
`/v1/realtime`, that speaks the OpenAI Realtime vocabulary plus a
set of duplex extensions: the model decides when to listen and when to speak,
the user can talk over the assistant, playback progress is reported back so
history stays honest, and a dropped connection can resume the same session.
This page covers how to run a duplex deployment, how to drive it from Python
with `vllm_omni.clients.duplex.DuplexClient`, and the complete wire contract.
The endpoint is served for models that ship a duplex plugin — currently
MiniCPM-o 4.5, PersonaPlex, and Nemotron VoiceChat — and only for deploy
configurations that declare `session_mode: duplex`. The runtime architecture
is described in [Full-Duplex Runtime (MiniCPM-o 4.5)](../design/fullduplex.md).
The handler is enabled by either a model-native `session_mode: duplex`
deployment or an explicit `duplex_session` configuration for turn-based
Server VAD. The runtime architecture is described in
[Full-Duplex Runtime (MiniCPM-o 4.5)](../design/fullduplex.md).
## Quick Start
@@ -27,7 +27,7 @@ vllm-omni serve openbmb/MiniCPM-o-4_5 \
`vllm_omni/deploy/minicpmo_4_5.yaml` declares `session_mode: duplex` and
`duplex_session.max_sessions: 4`, which mounts the WebSocket routes
`ws://<host>:8099/v1/realtime?duplex=1` (this page) and `ws://<host>:8099/v1/duplex`
`ws://<host>:8099/v1/realtime` (this page) and `ws://<host>:8099/v1/duplex`
(the native dialect for raw-protocol clients) next to the usual HTTP API.
PersonaPlex and Nemotron VoiceChat use `vllm_omni/deploy/personaplex.yaml` and
`vllm_omni/deploy/nemotron_labs_voicechat_duplex.yaml` in the same way.
@@ -240,7 +240,7 @@ on the same helpers.
## API Reference
This section is the complete wire contract of `/v1/realtime?duplex=1`: how
This section is the complete wire contract of `/v1/realtime`: how
the dialect relates to the OpenAI Realtime protocol, which surfaces each
model's capability flags gate, a catalogue of every client-to-server and
server-to-client event, and a JSON example for each. `DuplexClient` speaks
@@ -253,10 +253,11 @@ its protocol tests.
### Endpoint and transport
The endpoint is `ws(s)://<host>/v1/realtime?duplex=1`; the `duplex` query
parameter (`1`, `true`, or `on`) selects the duplex session handler, and the
route is mounted only for deployments whose deploy configuration declares
`session_mode: duplex`. Optional query parameters are `model`, `session_id`,
The endpoint is `ws(s)://<host>/v1/realtime`. Bare connections select the
session handler when the deployment declares either `session_mode: duplex`
or `duplex_session`. `?duplex=1` remains a compatibility alias, while
`?duplex=0` selects the legacy Realtime handler. Optional query parameters
are `model`, `session_id`,
`autostart` (`0` means resume-only), `resume`, and `native_duplex`
(`minicpmo45_native_duplex` is accepted as a deprecated alias and folded
into the canonical name). Every message is one JSON object per WebSocket
@@ -342,9 +343,14 @@ Semantic divergences hidden behind shared names:
before any commit; a commit may end in `response.listen` and no
response, and the model may open a response with no commit at all.
OpenAI: commit ⇒ item, `response.create` ⇒ exactly one response.
- `turn_detection.interrupt_response=false` is rejected;
`create_response` is ignored; `semantic_vad` is unsupported; the VAD
runs per session (Silero) and implies `overlap_policy=barge_in_on_speech`.
- With `turn_detection={"type":"server_vad"}`, omitted `interrupt_response`
defaults to `false` on turn-based models such as Qwen3-Omni, enabling
endpointing and automatic responses without interruption. Explicit `true`
is rejected there. Model-native duplex keeps the `true` default and rejects
`false`; its VAD implies `overlap_policy=barge_in_on_speech`.
This capability-dependent default differs from OpenAI's `true` default;
the effective configuration is returned in `session.created` / `session.updated`.
`semantic_vad` is unsupported.
- `rate_limits.updated` is always an empty list (compatibility only).
- Cancellation never reuses a `response_id`; truncation is driven by
`playback.ack` as well as `conversation.item.truncate`.
@@ -1201,9 +1207,10 @@ Realtime projector renames and fans out these events before they reach a
barge-in, or audio truncation; Nemotron VoiceChat does not support barge-in
or audio truncation; camera frames are consumed only by MiniCPM-o 4.5; tool
calls are produced only by Nemotron VoiceChat.
- `turn_detection` supports only `server_vad` with `interrupt_response=true`;
`semantic_vad`, `interrupt_response=false`, and `create_response` are not
supported. Server VAD requires a model-native session.
- `turn_detection` supports `server_vad` or `null`, not `semantic_vad`.
Turn-based models support endpointing without interruption, including
`create_response=false` for explicit response creation. Model-native duplex
requires `interrupt_response=true` and `create_response=true`.
- `input_audio_transcription` and `input_audio_noise_reduction` are accepted
and echoed but no separate transcription or noise-reduction stage runs;
transcripts come from the model.
+21 -3
View File
@@ -264,7 +264,7 @@ python examples/online_serving/openai_chat_completion_client_for_multimodal_gene
#### Realtime WebSocket client (`openai_realtime_client.py`)
[`openai_realtime_client.py`](./openai_realtime_client.py) connects to **`ws://<host>:<port>/v1/realtime`**, streams a local WAV as **PCM16 mono @ 16 kHz** in fixed-size chunks (OpenAI-style `input_audio_buffer.append` / `commit`), and receives **`response.audio.delta`** (incremental PCM for the reply) plus **`transcription.*`** events. By default it concatenates audio deltas and writes **`--output-wav`** (model output is typically **24 kHz**). Optional **`--delta-dump-dir`** saves each delta as `delta_000001.wav`, … for debugging.
[`openai_realtime_client.py`](./openai_realtime_client.py) connects to **`ws://<host>:<port>/v1/realtime`**, streams a local WAV as **PCM16 mono @ 16 kHz** in fixed-size chunks, and receives **`response.audio.*`**, **`response.output_text.*`**, and transcript events. By default it uses explicit `input_audio_buffer.commit`; pass **`--server-vad`** to let the server detect speech endpoints and commit the turn automatically. The client concatenates audio deltas and writes **`--output-wav`** (model output is typically **24 kHz**). Optional **`--delta-dump-dir`** saves each delta as `delta_000001.wav`, … for debugging.
Streaming input works well for translation-style use cases; if the Thinker runs while input is still incomplete, consider limiting **`max_tokens`** in your session / server defaults to avoid over-generation.
@@ -282,6 +282,7 @@ python openai_realtime_client.py \
--model Qwen/Qwen3-Omni-30B-A3B-Instruct \
--input-wav /path/to/input_16k_mono.wav \
--output-wav realtime_output.wav \
--server-vad \
--delta-dump-dir ./rt_delta_wavs
```
@@ -297,15 +298,32 @@ python openai_realtime_client.py \
| `--chunk-ms` | `200` | Size of each uploaded audio chunk (milliseconds of audio) |
| `--send-delay-ms` | `0` | Delay between chunk sends (simulate realtime upload) |
| `--delta-dump-dir` | *(optional)* | Directory to write per-`response.audio.delta` WAV files |
| `--server-vad` | disabled | Enable server-side Silero VAD instead of sending explicit commits |
| `--num-requests` | `1` | Number of sequential sessions (see `--concurrency`) |
| `--concurrency` | `1` | Max concurrent WebSocket sessions when `--num-requests` > 1 |
Ensure the server is running, for example:
Server VAD uses the pinned Silero v6.2 ONNX artifact from `istupakov/silero-vad-onnx` and never downloads model files while processing a session. Make the artifact available in the Hugging Face cache before startup, or configure a local artifact in the deployment YAML:
```yaml
duplex_session:
server_vad_model_path: /models/silero_vad.onnx
```
Start from the bundled Qwen3-Omni deployment YAML, add the fields above at the top level, and serve that complete configuration:
```bash
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
cp vllm_omni/deploy/qwen3_omni_moe.yaml /path/to/qwen3_omni_server_vad.yaml
# Edit /path/to/qwen3_omni_server_vad.yaml and add duplex_session.
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct \
--omni \
--port 8091 \
--deploy-config /path/to/qwen3_omni_server_vad.yaml
```
Keep Qwen's default `session_mode: turn`; `duplex_session` enables the Realtime handler without changing scheduler
semantics. Bare `/v1/realtime` selects that handler. The client uses `?duplex=0` only for the existing non-Server-VAD
wire flow; `?duplex=1` remains a supported compatibility alias.
The Python client supports the following command-line arguments:
- `--query-type` (or `-q`): Query type (default: `use_video`). Options: `text`, `use_audio`, `use_image`, `use_video`
@@ -1,9 +1,12 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Realtime client for vLLM-Omni /v1/realtime (audio + text events).
This client:
1) Reads a local WAV file (must be mono, 16-bit PCM, 16kHz),
2) Streams PCM16 chunks to /v1/realtime with OpenAI-style events,
3) Receives response.audio.* and transcription.* events,
3) Receives response.audio.*, response.output_text.*, and transcript events,
4) Saves synthesized audio to an output WAV file and optional text file.
By default each ``response.audio.delta`` is treated as an **incremental PCM**
@@ -18,6 +21,7 @@ Usage:
--model Qwen/Qwen3-Omni-30B-A3B-Instruct \
--input-wav input_16k_mono.wav \
--output-wav realtime_output.wav \
--server-vad \
--delta-dump-dir ./rt_delta_wavs
Dependencies:
@@ -32,6 +36,7 @@ import base64
import json
import wave
from pathlib import Path
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
try:
import websockets
@@ -70,6 +75,14 @@ def _write_wav_pcm16(path: Path, pcm16_bytes: bytes, sample_rate_hz: int) -> Non
wf.writeframes(pcm16_bytes)
def _with_realtime_route(url: str, *, server_vad: bool) -> str:
parts = urlsplit(url)
query = [(key, value) for key, value in parse_qsl(parts.query, keep_blank_values=True) if key != "duplex"]
if not server_vad:
query.append(("duplex", "0"))
return urlunsplit(parts._replace(query=urlencode(query)))
async def run_client(
url: str,
model: str,
@@ -79,6 +92,7 @@ async def run_client(
chunk_ms: int,
send_delay_ms: int,
delta_dump_dir: Path | None,
server_vad: bool,
request_idx: int = 1,
total_requests: int = 1,
) -> None:
@@ -92,23 +106,44 @@ async def run_client(
delta_index = 0
text_chunks: list[str] = []
final_text: str = ""
final_audio_transcript: str = ""
if delta_dump_dir is not None:
delta_dump_dir.mkdir(parents=True, exist_ok=True)
# Keep the CLI mode authoritative even if the input URL already contains
# an explicit duplex query parameter.
url = _with_realtime_route(url, server_vad=server_vad)
async with websockets.connect(url, max_size=64 * 1024 * 1024) as ws:
# 1) Validate model.
await ws.send(
json.dumps(
{
"type": "session.update",
if server_vad:
session_update = {
"type": "session.update",
"session": {
"model": model,
}
)
)
"audio": {
"input": {
"format": {"type": "audio/pcm", "rate": 16_000},
"turn_detection": {
"type": "server_vad",
"threshold": 0.5,
"prefix_padding_ms": 300,
"silence_duration_ms": 500,
"create_response": True,
"interrupt_response": False,
},
}
},
},
}
else:
# Preserve the existing non-duplex wire format.
session_update = {"type": "session.update", "model": model}
await ws.send(json.dumps(session_update))
# 2) Start generation once (non-final commit).
await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": False}))
# 2) Preserve the legacy explicit-commit flow unless Server VAD owns endpointing.
if not server_vad:
await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": False}))
# 3) Stream audio chunks.
for i in range(0, len(pcm16), chunk_bytes):
@@ -124,8 +159,20 @@ async def run_client(
if send_delay_ms > 0:
await asyncio.sleep(send_delay_ms / 1000.0)
# 4) Final commit closes input stream.
await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": True}))
# 4) Finalize the turn explicitly, or append silence for Server VAD endpointing.
if server_vad:
trailing_silence = bytes(16_000 * 2)
for i in range(0, len(trailing_silence), chunk_bytes):
await ws.send(
json.dumps(
{
"type": "input_audio_buffer.append",
"audio": base64.b64encode(trailing_silence[i : i + chunk_bytes]).decode("utf-8"),
}
)
)
else:
await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": True}))
# 5) Receive server events until audio done.
while True:
@@ -144,7 +191,7 @@ async def run_client(
sr = event.get("sample_rate_hz")
if isinstance(sr, int) and sr > 0:
output_sample_rate = sr
audio_b64 = event.get("audio", "")
audio_b64 = event.get("delta" if server_vad else "audio", "")
if audio_b64:
pcm_delta = base64.b64decode(audio_b64)
incremental_pcm_parts.append(pcm_delta)
@@ -158,14 +205,14 @@ async def run_client(
)
continue
if event_type == "transcription.delta":
if event_type in {"response.output_text.delta", "transcription.delta"}:
delta = event.get("delta", "")
if delta:
text_chunks.append(delta)
print(delta, end="", flush=True)
continue
if event_type == "transcription.done":
if event_type in {"response.output_text.done", "transcription.done"}:
final_text = event.get("text", "") or "".join(text_chunks)
usage = event.get("usage")
final_text_with_tag = f"Final transcription: {final_text}"
@@ -176,7 +223,16 @@ async def run_client(
print(f"{log_prefix}text usage: {usage}")
continue
if event_type == "response.audio_transcript.done":
final_audio_transcript = event.get("transcript", "")
continue
if event_type == "response.audio.done":
if not server_vad:
break
continue
if event_type == "response.done" and server_vad:
break
if event_type == "error":
@@ -191,7 +247,7 @@ async def run_client(
print(f"{log_prefix}Saved realtime audio to: {output_wav} (incremental chunks joined)")
if output_text is not None:
text_to_save = final_text if final_text else "".join(text_chunks)
text_to_save = final_text or "".join(text_chunks) or final_audio_transcript
output_text.parent.mkdir(parents=True, exist_ok=True)
output_text.write_text(text_to_save, encoding="utf-8")
print(f"{log_prefix}Saved realtime text to: {output_text}")
@@ -213,6 +269,7 @@ async def run_clients_concurrent(
chunk_ms: int,
send_delay_ms: int,
delta_dump_dir: Path | None,
server_vad: bool,
num_requests: int,
concurrency: int,
) -> None:
@@ -235,6 +292,7 @@ async def run_clients_concurrent(
chunk_ms=chunk_ms,
send_delay_ms=send_delay_ms,
delta_dump_dir=per_delta_dir,
server_vad=server_vad,
request_idx=index,
total_requests=num_requests,
)
@@ -283,6 +341,11 @@ def main() -> None:
default=None,
help="If set, each response.audio.delta is saved as delta_NNNNNN.wav under this directory",
)
parser.add_argument(
"--server-vad",
action="store_true",
help="Let the server detect speech endpoints instead of sending input_audio_buffer.commit",
)
parser.add_argument("--num-requests", type=int, default=1, help="Total number of requests to send")
parser.add_argument(
"--concurrency",
@@ -309,6 +372,7 @@ def main() -> None:
chunk_ms=args.chunk_ms,
send_delay_ms=args.send_delay_ms,
delta_dump_dir=args.delta_dump_dir,
server_vad=args.server_vad,
)
)
else:
@@ -322,6 +386,7 @@ def main() -> None:
chunk_ms=args.chunk_ms,
send_delay_ms=args.send_delay_ms,
delta_dump_dir=args.delta_dump_dir,
server_vad=args.server_vad,
num_requests=args.num_requests,
concurrency=concurrency,
)
-4
View File
@@ -77,7 +77,6 @@ dev = [
# Provides top-level cosyvoice2; do not co-install stepaudio2-minicpmo,
# whose top-level stepaudio2 package conflicts with this distribution.
"step-audio2==1.0.0", # Step-Audio2 / MiniCPM-o 4.5 Token2Wav (flashcosyvoice / hyperpyyaml)
"silero-vad==6.1", # Realtime server-VAD duplex E2E
]
demo = [
@@ -87,9 +86,6 @@ demo = [
"cn2an>=0.5.22", # Chinese number normalization for TTS (e.g. "1次" -> "一次")
]
server-vad = [
"silero-vad==6.1",
]
forced-aligner = [
"qwen-asr>=0.0.6", # faithful Japanese/Korean word segmentation for the TTS forced aligner
@@ -30,10 +30,27 @@ from tests.e2e.online_serving.run_minicpmo_realtime_duplex_soft_interrupt import
run_soft_interrupt,
)
from tests.helpers.mark import hardware_test
from vllm_omni.entrypoints.duplex.server_vad import (
SILERO_VAD_FILENAME,
SILERO_VAD_REPO_ID,
SILERO_VAD_REVISION,
)
pytestmark = [pytest.mark.full_model, pytest.mark.omni]
@pytest.fixture(scope="module")
def _cached_server_vad_artifact() -> None:
"""Cache the pinned ONNX artifact before the Realtime server starts."""
from huggingface_hub import hf_hub_download
hf_hub_download(
repo_id=SILERO_VAD_REPO_ID,
filename=SILERO_VAD_FILENAME,
revision=SILERO_VAD_REVISION,
)
@hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
def test_duplex_admission_and_expiry_reaper(omni_server, tmp_path: Path) -> None:
@@ -97,7 +114,7 @@ def test_duplex_soft_interrupt(omni_server, tmp_path: Path) -> None:
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
def test_duplex_server_vad_hard_interrupt(omni_server) -> None:
def test_duplex_server_vad_hard_interrupt(_cached_server_vad_artifact, omni_server) -> None:
result = asyncio.run(
run_server_vad_interrupt(
SimpleNamespace(
@@ -33,6 +33,7 @@ def test_duplex_session_runtime_defaults_are_typed_and_immutable(tmp_path) -> No
assert deploy.duplex_session.max_pending_turns_per_session == 4
assert deploy.duplex_session.max_sessions == 1
assert deploy.duplex_session.completed_append_cache_size == 256
assert deploy.duplex_session.server_vad_model_path is None
with pytest.raises(FrozenInstanceError):
deploy.duplex_session.idle_ttl_s = 1.0 # type: ignore[misc]
@@ -46,6 +47,29 @@ def test_duplex_session_runtime_accepts_disabled_idle_expiry(tmp_path) -> None:
assert deploy.duplex_session.idle_ttl_s is None
def test_duplex_session_runtime_accepts_local_server_vad_model(tmp_path) -> None:
deploy_path = tmp_path / "duplex.yaml"
deploy_path.write_text(
"duplex_session:\n server_vad_model_path: /models/silero_vad.onnx\nstages: []\n",
encoding="utf-8",
)
deploy = load_deploy_config(deploy_path)
assert deploy.duplex_session.server_vad_model_path == "/models/silero_vad.onnx"
def test_duplex_session_runtime_rejects_empty_server_vad_model_path(tmp_path) -> None:
deploy_path = tmp_path / "duplex.yaml"
deploy_path.write_text(
"duplex_session:\n server_vad_model_path: ''\nstages: []\n",
encoding="utf-8",
)
with pytest.raises(ValueError, match="server_vad_model_path"):
load_deploy_config(deploy_path)
@pytest.mark.parametrize(
("name", "value"),
[
+43 -1
View File
@@ -376,14 +376,26 @@ class TestSingleStageModeDetection:
mocker: MockerFixture,
*,
stage_cfgs: list[Any] | None = None,
resolved_config_path: str = "/fake/path",
patch_deploy_config: bool = True,
**kwargs: Any,
) -> AsyncOmniEngine:
mock_stage_configs = stage_cfgs or [_make_stage_cfg(0)]
if patch_deploy_config:
mocker.patch.object(
StageConfigFactory,
"get_pipeline_config",
return_value=None,
)
mocker.patch(
"vllm_omni.engine.async_omni_engine.load_deploy_config",
return_value=SimpleNamespace(duplex_session=DuplexSessionRuntimeConfig()),
)
mocker.patch.object(
AsyncOmniEngine,
"_resolve_stage_configs",
return_value=("/fake/path", mock_stage_configs),
return_value=(resolved_config_path, mock_stage_configs),
)
mocker.patch.object(AsyncOmniEngine, "_bootstrap_orchestrator")
mock_thread_cls = mocker.patch("threading.Thread")
@@ -445,6 +457,8 @@ class TestSingleStageModeDetection:
engine = self._make_engine_no_thread(
mocker,
deploy_config="/fake/duplex.yaml",
resolved_config_path="/fake/duplex.yaml",
patch_deploy_config=False,
)
get_pipeline_config.assert_called_once_with(
@@ -455,6 +469,30 @@ class TestSingleStageModeDetection:
load_deploy_config.assert_called_once_with("/fake/duplex.yaml")
assert engine.duplex_session_config is duplex_session
def test_auto_discovered_deploy_loads_duplex_runtime_config(
self,
mocker: MockerFixture,
) -> None:
deploy_path = "/resolved/qwen3_omni_moe.yaml"
duplex_session = DuplexSessionRuntimeConfig(server_vad_model_path="/models/silero_vad.onnx")
mocker.patch(
"vllm_omni.engine.async_omni_engine.StageConfigFactory.get_pipeline_config",
return_value=None,
)
load_deploy_config = mocker.patch(
"vllm_omni.engine.async_omni_engine.load_deploy_config",
return_value=SimpleNamespace(duplex_session=duplex_session),
)
engine = self._make_engine_no_thread(
mocker,
resolved_config_path=deploy_path,
patch_deploy_config=False,
)
load_deploy_config.assert_called_once_with(deploy_path)
assert engine.duplex_session_config is duplex_session
def test_single_stage_mode_without_stage_id_has_no_filter(self, mocker: MockerFixture):
engine = self._make_engine_no_thread(
mocker,
@@ -518,6 +556,10 @@ class TestEndpointRestrictionsTrustRemoteCode:
StageConfigFactory.try_infer_model_type.cache_clear()
def _make_engine_no_thread(self, mocker: MockerFixture, **kwargs: Any) -> AsyncOmniEngine:
mocker.patch(
"vllm_omni.engine.async_omni_engine.load_deploy_config",
return_value=SimpleNamespace(duplex_session=DuplexSessionRuntimeConfig()),
)
mocker.patch.object(
AsyncOmniEngine,
"_resolve_stage_configs",
@@ -61,20 +61,19 @@ if loaded:
assert result.returncode == 0, result.stderr
def test_generic_handler_requires_explicit_serving_runtime_adapter() -> None:
def test_generic_handler_supports_adapterless_chat_fallback() -> None:
repo_root = Path(__file__).resolve().parents[3]
script = """
from types import SimpleNamespace
from vllm_omni.entrypoints.duplex.serving import OmniDuplexSessionHandler
chat_service = SimpleNamespace(engine_client=SimpleNamespace())
try:
OmniDuplexSessionHandler(chat_service=chat_service)
except ValueError as exc:
if "serving runtime adapter" not in str(exc).lower():
raise
else:
chat_service = SimpleNamespace(
engine_client=SimpleNamespace(),
model_config=SimpleNamespace(model="test-model"),
)
handler = OmniDuplexSessionHandler(chat_service=chat_service)
if handler._serving_runtime_adapter is not None:
raise SystemExit("generic handler silently selected a model serving adapter")
"""
@@ -568,6 +568,48 @@ def test_websocket_routes_emit_stable_unavailable_frames_and_close(path: str, pa
websocket.receive_text()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("duplex_query", "expected_handler"),
[
(None, "duplex"),
("1", "duplex"),
("true", "duplex"),
("on", "duplex"),
("0", "legacy"),
("false", "legacy"),
],
)
async def test_realtime_route_defaults_to_configured_duplex_handler(
monkeypatch, duplex_query: str | None, expected_handler: str
) -> None:
calls: list[str] = []
class _DuplexHandler:
async def handle_realtime_session(self, _websocket) -> None:
calls.append("duplex")
class _LegacyConnection:
async def handle_connection(self) -> None:
calls.append("legacy")
monkeypatch.setattr(api_server, "RealtimeConnection", lambda _websocket, _serving: _LegacyConnection())
query_params = {} if duplex_query is None else {"duplex": duplex_query}
websocket = SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
openai_serving_duplex=_DuplexHandler(),
openai_serving_realtime=object(),
)
),
query_params=query_params,
)
await api_server.realtime_websocket(websocket)
assert calls == [expected_handler]
def test_health_without_engine_returns_stable_unhealthy_response() -> None:
"""Lock ``/health`` with no engine initialized.
@@ -163,6 +163,8 @@ class TestStreamingAudioResampler:
chunked = StreamingAudioResampler(24000, 8000)
pieces = [chunked.process(chunk) for chunk in np.split(waveform, [137, 2048, 9001, 17003])]
# Bounded FIR history must own its data, not retain the last large chunk.
assert chunked._history.base is None
pieces.append(chunked.process(np.empty(0), final=True))
actual = np.concatenate(pieces)
@@ -193,9 +195,15 @@ class TestStreamingAudioResampler:
output_rms = np.sqrt(np.mean(output[100:-100] ** 2))
assert output_rms < input_rms * 0.01
def test_rejects_non_integer_downsampling_ratio(self):
with pytest.raises(ValueError, match="integer downsampling ratio"):
StreamingAudioResampler(24000, 16000)
def test_supports_rational_downsampling_ratio(self):
waveform = np.sin(np.linspace(0, 200 * np.pi, 24000, endpoint=False)).astype(np.float32)
resampler = StreamingAudioResampler(24000, 16000)
output = resampler.process(waveform, final=True)
reference = torchaudio.functional.resample(torch.from_numpy(waveform), 24000, 16000).numpy()
assert output.shape == (16000,)
np.testing.assert_allclose(output[100:-100], reference[100:-100], atol=2e-3, rtol=1e-3)
def test_rejects_multichannel_audio(self):
resampler = StreamingAudioResampler(24000, 8000)
File diff suppressed because it is too large. Load diff
@@ -3,10 +3,11 @@
"""
E2E online tests for Qwen3-Omni /v1/realtime WebSocket (streaming PCM in, audio out).
Three scenarios:
Four scenarios:
- Ready CI: async_chunk on, smoke only (no send delay, no accuracy check).
- Merge CI: async_chunk on + send delay, full accuracy check.
- Merge CI: async_chunk off, no send delay, full accuracy check.
- Server VAD: two turns without client commits.
"""
from __future__ import annotations
@@ -28,11 +29,18 @@ from tests.helpers.media import (
generate_synthetic_audio,
)
from tests.helpers.runtime import OmniServerParams
from tests.helpers.stage_config import get_deploy_config_path
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
from vllm_omni.config.stage_config import load_deploy_config
from vllm_omni.entrypoints.duplex.server_vad import (
SILERO_VAD_FILENAME,
SILERO_VAD_REPO_ID,
SILERO_VAD_REVISION,
)
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
MODEL = "Qwen/Qwen3-Omni-30B-A3B-Instruct"
MODEL = os.environ.get("VLLM_OMNI_TEST_QWEN3_OMNI_MODEL", "Qwen/Qwen3-Omni-30B-A3B-Instruct")
SERVER_VAD_MODEL_PATH = os.environ.get("VLLM_OMNI_TEST_SILERO_VAD_MODEL_PATH")
# Synthetic input for realtime E2E (``generate_synthetic_audio``); distinct cache file per phrase.
REALTIME_SYNTH_PHRASE_TEXT = (
@@ -48,6 +56,15 @@ SEND_DELAY_MS = 200
# CI overlay bakes in async_chunk: False and covers CUDA/ROCm/XPU via ``platforms:``.
default_stage_config = get_deploy_config_path("ci/qwen3_omni_moe.yaml")
server_vad_stage_config = modify_stage_config(
default_stage_config,
{
"async_chunk": True,
"duplex_session": (
{"server_vad_model_path": SERVER_VAD_MODEL_PATH} if SERVER_VAD_MODEL_PATH is not None else {}
),
},
)
realtime_sync_server_params = [
pytest.param(
@@ -72,6 +89,17 @@ realtime_async_chunk_server_params = [
),
]
realtime_server_vad_server_params = [
pytest.param(
OmniServerParams(
model=MODEL,
stage_config_path=server_vad_stage_config,
use_stage_cli=True,
),
id="server_vad",
),
]
def _pcm16_mono_16k_from_wav_bytes(wav_bytes: bytes) -> bytes:
with wave.open(io.BytesIO(wav_bytes), "rb") as wf:
@@ -183,6 +211,86 @@ async def _run_realtime_audio_roundtrip(
}
async def _append_pcm16_chunks(ws, pcm16: bytes, chunk_bytes: int) -> None:
for offset in range(0, len(pcm16), chunk_bytes):
await ws.send(
json.dumps(
{
"type": "input_audio_buffer.append",
"audio": base64.b64encode(pcm16[offset : offset + chunk_bytes]).decode("utf-8"),
}
)
)
async def _receive_server_vad_turn(ws) -> list[dict]:
events: list[dict] = []
while True:
message = await asyncio.wait_for(ws.recv(), timeout=600)
if isinstance(message, bytes):
continue
event = json.loads(message)
events.append(event)
if event.get("type") == "error":
raise AssertionError(f"WebSocket error: {event}")
if event.get("type") == "response.done":
return events
async def _run_server_vad_audio_roundtrips(
host: str,
port: int,
model: str,
pcm16: bytes,
*,
chunk_ms: int = 100,
turns: int = 2,
) -> list[list[dict]]:
chunk_bytes = max(16_000 * 2 // 1000 * chunk_ms, 2)
turn_events: list[list[dict]] = []
async with websockets.connect(f"ws://{host}:{port}/v1/realtime", max_size=64 * 1024 * 1024) as ws:
await ws.send(
json.dumps(
{
"type": "session.update",
"session": {
"model": model,
"audio": {
"input": {
"format": {"type": "audio/pcm", "rate": 16_000},
"turn_detection": {"type": "server_vad"},
}
},
},
}
)
)
silence = bytes(16_000 * 2 * 3 // 2)
for _ in range(turns):
await _append_pcm16_chunks(ws, pcm16, chunk_bytes)
await _append_pcm16_chunks(ws, silence, chunk_bytes)
turn_events.append(await _receive_server_vad_turn(ws))
return turn_events
@pytest.fixture(scope="class")
def cached_silero_vad_artifact() -> str:
"""Prepare the pinned artifact before the serving subprocess starts."""
if SERVER_VAD_MODEL_PATH is not None:
return SERVER_VAD_MODEL_PATH
from huggingface_hub import hf_hub_download
return hf_hub_download(
repo_id=SILERO_VAD_REPO_ID,
filename=SILERO_VAD_FILENAME,
revision=SILERO_VAD_REVISION,
)
def _synthetic_pcm16_input(
*,
phrase_text: str = REALTIME_SYNTH_PHRASE_TEXT,
@@ -296,6 +404,66 @@ class TestQwen3OmniRealtimeWebSocket:
f"Expected an issue-like audio response longer than 5 seconds, got {output_duration_s:.2f}s"
)
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.parametrize("omni_server", realtime_server_vad_server_params, indirect=True)
def test_server_vad_multi_turn_without_client_commit(
self,
cached_silero_vad_artifact: str,
omni_server,
) -> None:
"""Two Qwen turns are endpointed without client commits."""
assert cached_silero_vad_artifact
deploy = load_deploy_config(server_vad_stage_config)
assert deploy.session_mode == "turn"
assert deploy.async_chunk is True
assert deploy.duplex_session.server_vad_model_path == SERVER_VAD_MODEL_PATH
pcm16 = _synthetic_pcm16_input()
turns = asyncio.run(
_run_server_vad_audio_roundtrips(
omni_server.host,
omni_server.port,
omni_server.model,
pcm16,
chunk_ms=100,
turns=2,
)
)
assert len(turns) == 2
required_sequence = [
"input_audio_buffer.speech_started",
"input_audio_buffer.speech_stopped",
"input_audio_buffer.committed",
"response.created",
"response.audio.delta",
"response.audio.done",
"response.done",
]
input_item_ids: list[str] = []
response_ids: list[str] = []
for events in turns:
event_types = [event["type"] for event in events]
positions = [event_types.index(event_type) for event_type in required_sequence]
assert positions == sorted(positions)
started = next(event for event in events if event["type"] == "input_audio_buffer.speech_started")
stopped = next(event for event in events if event["type"] == "input_audio_buffer.speech_stopped")
committed = next(event for event in events if event["type"] == "input_audio_buffer.committed")
created = next(event for event in events if event["type"] == "response.created")["response"]
done = next(event for event in events if event["type"] == "response.done")["response"]
assert started["item_id"] == stopped["item_id"] == committed["item_id"]
assert created["id"] == done["id"]
assert done["status"] == "completed"
input_item_ids.append(committed["item_id"])
response_ids.append(created["id"])
assert len(set(input_item_ids)) == 2
assert len(set(response_ids)) == 2
@pytest.mark.advanced_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@@ -0,0 +1,429 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import asyncio
import base64
import io
import sys
import threading
import wave
import weakref
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
import numpy as np
import pytest
from vllm_omni.entrypoints.duplex.protocol import (
DuplexSession,
DuplexSessionConfig,
)
from vllm_omni.entrypoints.duplex.server_vad import (
ServerVADConfig,
ServerVADFrame,
ServerVADPipeline,
SileroVADBackend,
SileroVADBackendProvider,
SpeechEndpointDecision,
ThresholdEndpointPolicy,
parse_session_turn_detection,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class SequenceDetector:
frame_samples = 160
def __init__(self, probabilities: list[float]) -> None:
self.probabilities = probabilities
def new_state(self) -> int:
return 0
def infer(self, frame: np.ndarray, state: object) -> tuple[float, object]:
index = int(state)
probability = self.probabilities[index] if index < len(self.probabilities) else 0.0
return probability, index + 1
def test_server_vad_config_defaults_and_validation():
config = ServerVADConfig.from_value({"type": "server_vad"})
assert config.type == "server_vad"
assert config.threshold == 0.5
assert config.prefix_padding_ms == 300
assert config.silence_duration_ms == 500
assert config.create_response is True
assert config.interrupt_response is None
assert "interrupt_response" not in config.as_dict()
assert config.min_speech_duration_ms is None
assert config.as_dict()["type"] == config.type
interrupting = ServerVADConfig.from_value(
{
"type": "server_vad",
"interrupt_response": True,
"min_speech_duration_ms": 96,
}
)
assert interrupting.interrupt_response is True
assert interrupting.min_speech_duration_ms == 96
for value in (None, 0, "true"):
with pytest.raises(ValueError, match="interrupt_response must be a boolean"):
ServerVADConfig.from_value({"type": "server_vad", "interrupt_response": value})
for enabled in (False, True):
_, aliased = parse_session_turn_detection(
{
"turn_detection": {"type": "server_vad"},
"audio": {"input": {"turn_detection": {"type": "server_vad", "interrupt_response": enabled}}},
}
)
assert aliased is not None and aliased.interrupt_response is enabled
with pytest.raises(ValueError, match="Unknown"):
ServerVADConfig.from_value({"type": "server_vad", "semantic_eagerness": "high"})
@pytest.mark.asyncio
@pytest.mark.parametrize("prefix_padding_ms", [0, 10, 30])
async def test_server_vad_pipeline_handles_arbitrary_chunk_boundaries(prefix_padding_ms: int):
detector = SequenceDetector([0.0, 0.9, 0.8, 0.0, 0.0])
pipeline = ServerVADPipeline(
detector,
ServerVADConfig(prefix_padding_ms=prefix_padding_ms, silence_duration_ms=20),
)
samples = np.zeros(detector.frame_samples * 5, dtype=np.float32)
batches = [
await pipeline.push(samples[:73]),
await pipeline.push(samples[73:431]),
await pipeline.push(samples[431:]),
]
frames = [frame for batch in batches for frame in batch.frames]
assert len(frames) == 5
assert [index for index, frame in enumerate(frames) if frame.decision.speech_started] == [1]
assert [index for index, frame in enumerate(frames) if frame.decision.speech_stopped] == [4]
assert frames[1].decision.audio_start_ms == max(0, 10 - prefix_padding_ms)
assert frames[4].decision.audio_end_ms == 50
pipeline.reset()
reset_batch = await pipeline.push(samples)
assert [index for index, frame in enumerate(reset_batch.frames) if frame.decision.speech_started] == [1]
assert [index for index, frame in enumerate(reset_batch.frames) if frame.decision.speech_stopped] == [4]
# Keep session-relative timestamps, but never reference prefix audio
# discarded by reset at 50 ms.
assert reset_batch.frames[1].decision.audio_start_ms == max(50, 60 - prefix_padding_ms)
assert reset_batch.frames[4].decision.audio_end_ms == 100
@pytest.mark.asyncio
async def test_server_vad_retained_audio_does_not_keep_input_chunk():
pipeline = ServerVADPipeline(SequenceDetector([]), ServerVADConfig())
session = DuplexSession("server-vad-memory", DuplexSessionConfig())
samples = np.zeros(160 * 100 + 1, dtype=np.float32)
source_ref = weakref.ref(samples)
batch = await pipeline.push(samples)
session.append_server_vad_frame(
batch.frames[-1].samples,
speech_started=False,
speech_stopped=False,
prefix_samples=80,
)
session.append_server_vad_frame(
batch.frames[-1].samples,
speech_started=True,
speech_stopped=False,
prefix_samples=80,
)
del batch, samples
# Allow the completed to_thread callback to release its result, too.
await asyncio.sleep(0)
assert source_ref() is None
assert pipeline.scratch_bytes == 4
assert session.server_vad_utterance_bytes == (80 + 160) * 4
@pytest.mark.asyncio
async def test_server_vad_pipeline_pcm16_resampling_is_split_invariant():
sample_index = np.arange(2_400, dtype=np.int32)
source = ((sample_index * 7919) % 65_536 - 32_768).astype("<i2")
# Supply ample silence after the signal so the streaming resampler can
# emit a complete set of VAD frames without depending on its FIR width.
right_context = np.zeros(240, dtype="<i2")
continuous_source = np.concatenate((source, right_context))
async def process(chunk_sizes: list[int]) -> tuple[np.ndarray, list[SpeechEndpointDecision]]:
pipeline = ServerVADPipeline(
SequenceDetector([0.0] * 10),
ServerVADConfig(),
)
frames: list[ServerVADFrame] = []
offset = 0
for chunk_size in chunk_sizes:
chunk = continuous_source[offset : offset + chunk_size]
batch = await pipeline.push_pcm16(chunk.tobytes(), source_sample_rate_hz=24_000)
frames.extend(batch.frames)
offset += chunk_size
assert offset == continuous_source.size
return np.concatenate([frame.samples for frame in frames]), [frame.decision for frame in frames]
whole_samples, whole_decisions = await process([continuous_source.size])
split_samples, split_decisions = await process([7, 13, 511, 512, 513, continuous_source.size - 1_556])
np.testing.assert_array_equal(split_samples, whole_samples)
assert whole_samples.size == 1_600
assert split_decisions == whole_decisions
@pytest.mark.asyncio
async def test_server_vad_pipeline_reset_clears_resampler_and_source_rate_lock():
pipeline = ServerVADPipeline(
SequenceDetector([0.0]),
ServerVADConfig(),
)
batch = await pipeline.push_pcm16(
np.asarray([30_000, -30_000], dtype="<i2").tobytes(),
source_sample_rate_hz=24_000,
)
assert not batch.frames
pipeline.reset()
source = np.zeros(160, dtype="<i2")
source[:3] = [-32768, 0, 32767]
batch = await pipeline.push_pcm16(source.tobytes(), source_sample_rate_hz=16_000)
assert len(batch.frames) == 1
np.testing.assert_array_equal(
batch.frames[0].samples[:3],
np.asarray([-1.0, 0.0, 32767 / 32768], dtype=np.float32),
)
def test_threshold_endpoint_policy_uses_silero_v62_exit_hysteresis():
policy = ThresholdEndpointPolicy(
ServerVADConfig(threshold=0.5, silence_duration_ms=20),
sample_rate_hz=16_000,
)
frame_samples = 160
started = policy.update(0.8, frame_start_sample=0, frame_samples=frame_samples)
assert started.speech_started is True
# Values below the activation threshold but above threshold - 0.15 must
# not begin a silence candidate or close an active speech segment.
for frame_index in range(1, 5):
decision = policy.update(
0.4,
frame_start_sample=frame_index * frame_samples,
frame_samples=frame_samples,
)
assert decision.speech_stopped is False
first_silence = policy.update(
0.2,
frame_start_sample=5 * frame_samples,
frame_samples=frame_samples,
)
stopped = policy.update(
0.2,
frame_start_sample=6 * frame_samples,
frame_samples=frame_samples,
)
assert first_silence.speech_stopped is False
assert stopped.speech_stopped is True
def test_threshold_endpoint_policy_honors_minimum_speech_duration():
policy = ThresholdEndpointPolicy(
ServerVADConfig(prefix_padding_ms=0, min_speech_duration_ms=25),
sample_rate_hz=16_000,
)
frame_samples = 160
assert not policy.update(0.9, frame_start_sample=0, frame_samples=frame_samples).speech_started
assert not policy.update(0.9, frame_start_sample=160, frame_samples=frame_samples).speech_started
started = policy.update(0.9, frame_start_sample=320, frame_samples=frame_samples)
assert started.speech_started is True
assert started.audio_start_ms == 0
def test_threshold_endpoint_policy_clamps_silero_exit_threshold():
policy = ThresholdEndpointPolicy(
ServerVADConfig(threshold=0.1, silence_duration_ms=20),
sample_rate_hz=16_000,
)
frame_samples = 160
started = policy.update(0.9, frame_start_sample=0, frame_samples=frame_samples)
assert started.speech_started is True
first_silence = policy.update(
0.0,
frame_start_sample=frame_samples,
frame_samples=frame_samples,
)
stopped = policy.update(
0.0,
frame_start_sample=2 * frame_samples,
frame_samples=frame_samples,
)
assert first_silence.speech_stopped is False
assert stopped.speech_stopped is True
# The committed audio ends at 30 ms: 10 ms of speech followed by the
# 20 ms of silence required to detect the endpoint.
assert stopped.audio_end_ms == 30
assert stopped.endpoint_delay_ms == 20
def test_silero_backend_matches_upstream_v62_streaming_contract(monkeypatch, tmp_path):
class FakeSessionOptions:
inter_op_num_threads = 0
intra_op_num_threads = 0
class FakeInferenceSession:
def __init__(self, path: str, *, providers: list[str], sess_options: object) -> None:
self.providers = providers
self.sess_options = sess_options
self.calls: list[dict[str, np.ndarray]] = []
self.run_barrier: threading.Barrier | None = None
sessions.append(self)
def get_inputs(self) -> list[SimpleNamespace]:
return [SimpleNamespace(name=name) for name in ("input", "state", "sr")]
def run(self, _outputs: object, inputs: dict[str, np.ndarray]) -> list[np.ndarray]:
self.calls.append({name: np.array(value, copy=True) for name, value in inputs.items()})
if self.run_barrier is not None:
self.run_barrier.wait(timeout=5)
return [
np.asarray([[0.75]], dtype=np.float32),
np.full((2, 1, 128), len(self.calls), dtype=np.float32),
]
sessions: list[FakeInferenceSession] = []
monkeypatch.setitem(
sys.modules,
"onnxruntime",
SimpleNamespace(
SessionOptions=FakeSessionOptions,
InferenceSession=FakeInferenceSession,
),
)
model_path = tmp_path / "silero_vad.onnx"
model_path.write_bytes(b"fake-model")
backend = SileroVADBackend(model_path)
session = sessions[0]
assert session.providers == ["CPUExecutionProvider"]
assert session.sess_options.inter_op_num_threads == 1
assert session.sess_options.intra_op_num_threads == 1
assert session.calls[0]["input"].shape == (1, 576)
assert session.calls[0]["state"].shape == (2, 1, 128)
np.testing.assert_array_equal(session.calls[0]["state"], np.zeros((2, 1, 128), dtype=np.float32))
assert session.calls[0]["sr"].shape == ()
assert session.calls[0]["sr"].item() == 16_000
state = backend.new_state()
first_frame = np.arange(backend.frame_samples, dtype=np.float32)
probability, state = backend.infer(first_frame, state)
first_call = session.calls[1]
assert probability == pytest.approx(0.75)
np.testing.assert_array_equal(
first_call["input"][:, : backend.context_samples],
np.zeros((1, backend.context_samples), dtype=np.float32),
)
np.testing.assert_array_equal(
first_call["input"][:, backend.context_samples :],
first_frame[None, :],
)
np.testing.assert_array_equal(first_call["state"], np.zeros((2, 1, 128), dtype=np.float32))
second_frame = -first_frame
backend.infer(second_frame, state)
second_call = session.calls[2]
np.testing.assert_array_equal(
second_call["input"][:, : backend.context_samples],
first_frame[None, -backend.context_samples :],
)
np.testing.assert_array_equal(
second_call["input"][:, backend.context_samples :],
second_frame[None, :],
)
np.testing.assert_array_equal(
second_call["state"],
np.full((2, 1, 128), 2, dtype=np.float32),
)
# Independent pipeline states may enter the shared ORT session concurrently.
session.run_barrier = threading.Barrier(2)
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(backend.infer, first_frame, backend.new_state()),
executor.submit(backend.infer, second_frame, backend.new_state()),
]
assert [future.result()[0] for future in futures] == pytest.approx([0.75, 0.75])
def test_duplex_session_owns_server_vad_prefix_and_utterance_audio():
session = DuplexSession("server-vad-session", DuplexSessionConfig())
frame = np.zeros(160, dtype=np.float32)
session.reserve_input_bytes(frame.nbytes * 3, limit=frame.nbytes * 4)
released = session.append_server_vad_frame(
frame,
speech_started=False,
speech_stopped=False,
prefix_samples=160,
)
assert released == 0
released = session.append_server_vad_frame(
frame,
speech_started=False,
speech_stopped=False,
prefix_samples=160,
)
assert released == frame.nbytes
session.release_input_bytes(released)
session.append_server_vad_frame(
frame,
speech_started=True,
speech_stopped=False,
prefix_samples=160,
)
assert session.server_vad_utterance_bytes == frame.nbytes * 2
assert session.stage_server_vad_audio_for_commit() is True
committed = session.commit_user_input()
assert committed is not None
assert committed.message["role"] == "user"
audio_url = committed.message["content"][0]["audio_url"]["url"]
assert audio_url.startswith("data:audio/wav;base64,")
wav_bytes = base64.b64decode(audio_url.partition(",")[2])
with wave.open(io.BytesIO(wav_bytes), "rb") as wav_file:
assert wav_file.getnchannels() == 1
assert wav_file.getsampwidth() == 2
assert wav_file.getframerate() == 16_000
assert wav_file.getnframes() == frame.size * 2
def test_silero_provider_rejects_missing_or_invalid_local_artifact(tmp_path):
missing = SileroVADBackendProvider(model_path=str(tmp_path / "missing.onnx"))
with pytest.raises(RuntimeError, match="does not exist"):
missing.get()
invalid_path = tmp_path / "silero_vad.onnx"
invalid_path.write_bytes(b"not-the-pinned-model")
invalid = SileroVADBackendProvider(model_path=str(invalid_path))
with pytest.raises(RuntimeError, match="checksum mismatch"):
invalid.get()
@@ -74,7 +74,7 @@ async def test_openai_handler_passes_current_session_fence_for_runtime_controls(
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine))
session = DuplexSession(
session_id="sid",
config=DuplexSessionConfig(),
config=DuplexSessionConfig(extra_body={"native_duplex": True}),
epoch=1,
turn_id=2,
)
@@ -92,7 +92,7 @@ async def test_openai_handler_passes_current_session_fence_for_runtime_controls(
async def test_runtime_signal_uses_fence_captured_before_session_turn_advances():
engine = FenceRecordingEngine()
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine))
session = DuplexSession(session_id="sid", config=DuplexSessionConfig())
session = DuplexSession(session_id="sid", config=DuplexSessionConfig(extra_body={"native_duplex": True}))
captured_fence = DuplexFence("sid", epoch=0, turn_id=0)
session.turn_id = 1
@@ -111,7 +111,7 @@ async def test_openai_handler_forwards_cancel_fence_through_async_omni_facade():
app = object.__new__(AsyncOmni)
app.engine = engine
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(app))
session = DuplexSession(session_id="sid-cancel", config=DuplexSessionConfig())
session = DuplexSession(session_id="sid-cancel", config=DuplexSessionConfig(extra_body={"native_duplex": True}))
cancelled_fence = DuplexFence("sid-cancel", epoch=2, turn_id=3)
next_fence = DuplexFence("sid-cancel", epoch=3, turn_id=3)
+26
View File
@@ -28,6 +28,7 @@ from vllm_omni.entrypoints.utils import (
coerce_param_message_types,
filter_dataclass_kwargs,
)
from vllm_omni.model_executor.models.qwen3_omni.pipeline import QWEN3_OMNI_PIPELINE
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -295,6 +296,31 @@ class TestFilterDataclassKwargs:
class TestResolveOmniConfig:
def test_bare_deploy_name_returns_packaged_resolved_path(self, tmp_path, mocker: MockerFixture):
deploy_name = "qwen3_omni_moe.yaml"
deploy_path = tmp_path / deploy_name
deploy_path.write_text(
"duplex_session:\n server_vad_model_path: /models/silero_vad.onnx\nstages: []\n",
encoding="utf-8",
)
mocker.patch("vllm_omni.config.config_factory._DEPLOY_DIR", tmp_path)
mocker.patch("vllm_omni.config.omni_config._DEPLOY_DIR", tmp_path)
mocker.patch(
"vllm_omni.config.config_factory.StageConfigFactory.get_pipeline_config",
return_value=QWEN3_OMNI_PIPELINE,
)
resolved = resolve_omni_config(
"dummy-model",
trust_remote_code=False,
deploy_config_path=deploy_name,
cli_overrides={},
stage_overrides=None,
strategy_config_path=None,
)
assert resolved.config_path == str(deploy_path)
def test_stage_lookup_error_lists_resolved_ids(self):
resolved = OmniConfigResolution(
config_path=None,
+5
View File
@@ -509,6 +509,7 @@ class DuplexSessionRuntimeConfig:
max_pending_turns_per_session: int = 4
max_sessions: int = 1
completed_append_cache_size: int = 256
server_vad_model_path: str | None = None
# Startup warmup: run this many silent 80 ms-style frames through a
# throwaway realtime session before real clients are admitted, so
# one-time costs (kernel JIT, first prefill/decode paths, codec caches)
@@ -528,6 +529,10 @@ class DuplexSessionRuntimeConfig:
}
if self.idle_ttl_s is not None and self.idle_ttl_s <= 0:
raise ValueError("duplex_session.idle_ttl_s must be positive or null")
if self.server_vad_model_path is not None and (
not isinstance(self.server_vad_model_path, str) or not self.server_vad_model_path.strip()
):
raise ValueError("duplex_session.server_vad_model_path must be a non-empty string or null")
for name, value in positive.items():
if value <= 0:
raise ValueError(f"duplex_session.{name} must be positive")
+1 -1
View File
@@ -224,7 +224,7 @@ class AsyncOmniEngine:
trust_remote_code=bool(trust_remote_code),
deploy_config_path=deploy_config_path,
)
self._set_pipeline_runtime_config(pipeline_config, deploy_config_path)
self._set_pipeline_runtime_config(pipeline_config, self.config_path)
else:
self._set_pipeline_runtime_config(
self._config_resolution.pipeline_config,
+45 -10
View File
@@ -21,8 +21,12 @@ MIN_INPUT_SAMPLE_RATE_HZ = 8_000
MAX_INPUT_SAMPLE_RATE_HZ = 192_000
def validate_input_sample_rate_hz(sample_rate_hz: int | float) -> int:
if isinstance(sample_rate_hz, bool) or (isinstance(sample_rate_hz, float) and not math.isfinite(sample_rate_hz)):
def validate_input_sample_rate_hz(sample_rate_hz: object) -> int:
if (
isinstance(sample_rate_hz, bool)
or not isinstance(sample_rate_hz, int | float)
or (isinstance(sample_rate_hz, float) and not math.isfinite(sample_rate_hz))
):
raise ValueError("sample_rate_hz must be a finite integer")
rate = int(sample_rate_hz)
if rate != sample_rate_hz:
@@ -35,14 +39,27 @@ def validate_input_sample_rate_hz(sample_rate_hz: int | float) -> int:
def resample_pcm16_mono(raw: bytes, *, source_rate_hz: int, target_rate_hz: int) -> bytes:
if source_rate_hz <= 0 or target_rate_hz <= 0 or source_rate_hz == target_rate_hz:
return raw
samples = np.frombuffer(raw, dtype="<i2").astype(np.float32)
if samples.size <= 1:
return raw
target_size = max(1, int(round(samples.size * target_rate_hz / source_rate_hz)))
source_x = np.linspace(0.0, 1.0, num=samples.size, endpoint=True)
target_x = np.linspace(0.0, 1.0, num=target_size, endpoint=True)
resampled = np.interp(target_x, source_x, samples)
return np.clip(resampled, -32768, 32767).astype("<i2").tobytes()
# Import lazily so the generic Duplex audio module does not pull in the
# OpenAI API server while the Duplex stack itself is being imported.
from vllm_omni.entrypoints.openai.audio_utils_mixin import StreamingAudioResampler
samples = np.frombuffer(raw, dtype="<i2")
rate_gcd = math.gcd(source_rate_hz, target_rate_hz)
reduced_factor = max(source_rate_hz // rate_gcd, target_rate_hz // rate_gcd)
if reduced_factor > StreamingAudioResampler._max_polyphase_factor:
# The FIR size scales with the reduced ratio. Keep unusual client
# rates bounded while common audio rates use the high-quality path.
if samples.size <= 1:
return raw
target_size = max(1, int(round(samples.size * target_rate_hz / source_rate_hz)))
source_x = np.linspace(0.0, 1.0, num=samples.size, endpoint=True)
target_x = np.linspace(0.0, 1.0, num=target_size, endpoint=True)
resampled = np.interp(target_x, source_x, samples)
return np.clip(resampled, -32_768, 32_767).astype("<i2").tobytes()
resampler = StreamingAudioResampler(source_rate_hz, target_rate_hz)
normalized = samples.astype(np.float32) * np.float32(1.0 / 32768.0)
resampled = resampler.process(normalized, final=True)
return np.clip(np.rint(resampled * 32768.0), -32768, 32767).astype("<i2").tobytes()
def decode_g711_ulaw(raw: bytes) -> bytes:
@@ -116,6 +133,24 @@ def wav_payload_to_pcm16(raw: bytes) -> tuple[bytes | None, int | None]:
return None, None
def encode_float32_mono_wav_base64(
samples: np.ndarray,
*,
sample_rate_hz: int,
) -> str:
"""Package normalized mono float32 samples as a PCM16 WAV data payload."""
rate = validate_input_sample_rate_hz(sample_rate_hz)
normalized = np.ascontiguousarray(samples, dtype=np.float32).reshape(-1)
pcm16 = np.clip(np.rint(normalized * 32768.0), -32768, 32767).astype("<i2")
with io.BytesIO() as buffer:
with wave.open(buffer, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(rate)
wav_file.writeframes(pcm16.tobytes())
return base64.b64encode(buffer.getvalue()).decode("ascii")
def convert_input_audio_with_rate(
audio: object,
fmt: object,
+5 -6
View File
@@ -11,7 +11,7 @@ def should_enable_duplex_endpoint(
*,
config_path: str | None = None,
) -> bool:
"""Enable duplex routes only for deployments that explicitly opt in."""
"""Enable the realtime session handler for explicitly configured deployments."""
if stage_configs:
for stage in stage_configs:
session_mode = (
@@ -21,14 +21,13 @@ def should_enable_duplex_endpoint(
return True
if config_path:
try:
from omegaconf import OmegaConf
from vllm_omni.config.stage_config import resolve_deploy_yaml
raw_config = OmegaConf.load(config_path)
session_mode = raw_config.get("session_mode") if hasattr(raw_config, "get") else None
if session_mode == "duplex":
raw_config = resolve_deploy_yaml(config_path)
if raw_config.get("session_mode") == "duplex" or isinstance(raw_config.get("duplex_session"), dict):
return True
except Exception as exc:
logger.warning("Failed to inspect duplex session_mode from %s: %s", config_path, exc)
logger.warning("Failed to inspect realtime session configuration from %s: %s", config_path, exc)
return False
@@ -38,7 +38,14 @@ class ChatFallbackProjectorMixin:
request = self._build_chat_request(session, request_id)
result = await self._chat_service.create_chat_completion(request, raw_request=None)
if isinstance(result, ErrorResponse):
await send_json({"type": "error", "error": result.message, "code": result.type or "chat_error"})
error = result.error
await send_json(
{
"type": "error",
"error": error.message if error else "Chat request failed",
"code": error.type if error else "chat_error",
}
)
session.end_response(commit_text=False)
return
if hasattr(result, "__aiter__"):
+198 -10
View File
@@ -10,6 +10,16 @@ from enum import Enum
from types import MappingProxyType
from uuid import uuid4
import numpy as np
from vllm_omni.entrypoints.duplex.audio import (
encode_float32_mono_wav_base64,
)
from vllm_omni.entrypoints.duplex.server_vad import (
ServerVADConfig,
parse_session_turn_detection,
)
class DuplexOverlapPolicy(str, Enum):
LISTEN_ONLY = "listen_only"
@@ -231,10 +241,12 @@ class DuplexSessionConfig:
overlap_barge_in_ms: int = 1200
overlap_silence_rms: float = 0.003
playback_commit_policy: str = DuplexPlaybackCommitPolicy.COMMIT_ALL_ON_DONE.value
turn_detection_configured: bool = False
server_vad: ServerVADConfig | None = None
extra_body: dict[str, object] = field(default_factory=dict)
def as_dict(self) -> dict[str, object]:
return {
payload: dict[str, object] = {
"model": self.model,
"modalities": list(self.modalities),
"instructions": self.instructions,
@@ -253,6 +265,9 @@ class DuplexSessionConfig:
"playback_commit_policy": self.playback_commit_policy,
"extra_body": dict(self.extra_body),
}
if self.turn_detection_configured:
payload["turn_detection"] = self.server_vad.as_dict() if self.server_vad is not None else None
return payload
@classmethod
def from_event(cls, event: dict[str, object]) -> DuplexSessionConfig:
@@ -295,9 +310,13 @@ class DuplexSessionConfig:
config.playback_commit_policy = cls._normalize_playback_commit_policy(source["playback_commit_policy"])
if isinstance(source.get("modalities"), list) and all(isinstance(x, str) for x in source["modalities"]):
config.modalities = list(source["modalities"])
turn_detection_configured, server_vad = parse_session_turn_detection(source)
if isinstance(source.get("extra_body"), dict):
config.extra_body = normalize_native_duplex_key(dict(source["extra_body"]))
extra = config.extra_body
raw_realtime_session = extra.get("realtime_session_payload")
if not turn_detection_configured and isinstance(raw_realtime_session, dict):
turn_detection_configured, server_vad = parse_session_turn_detection(raw_realtime_session)
if isinstance(extra.get("overlap_policy"), str):
config.overlap_policy = cls._normalize_overlap_policy(extra["overlap_policy"])
if isinstance(extra.get("overlap_short_ack_ms"), int | float):
@@ -308,6 +327,9 @@ class DuplexSessionConfig:
config.overlap_silence_rms = max(0.0, float(extra["overlap_silence_rms"]))
if isinstance(extra.get("playback_commit_policy"), str):
config.playback_commit_policy = cls._normalize_playback_commit_policy(extra["playback_commit_policy"])
if turn_detection_configured:
config.turn_detection_configured = True
config.server_vad = server_vad
return config
@staticmethod
@@ -354,6 +376,13 @@ class DuplexCommittedInput:
input_commit_seq: int
@dataclass
class PendingServerVADTurn:
committed: DuplexCommittedInput
create_response: bool
item_id: str
@dataclass
class DuplexAssistantAudioTextMark:
text_chars: int
@@ -368,6 +397,13 @@ class InputBufferState:
overlap_speech_ms: int = 0
reserved_input_bytes: int = 0
pending_turns: int = 0
turn_detection_config_locked: bool = False
vad_prefix_audio: list[np.ndarray] = field(default_factory=list)
vad_prefix_samples: int = 0
vad_utterance_audio: list[np.ndarray] = field(default_factory=list)
vad_speech_active: bool = False
active_server_vad_item_id: str | None = None
pending_server_vad_turn: PendingServerVADTurn | None = None
@dataclass
@@ -627,6 +663,9 @@ class DuplexSession:
return active_turn_id is None or int(turn_id) == active_turn_id
def append_history_message(self, message: dict[str, object]) -> None:
response_id = self.active_response_id
if response_id is not None:
self.reserve_history_item(f"item_{response_id}")
self._conversation.messages.append(message)
def stage_pending_history_item(self, item_id: str, message: dict[str, object]) -> None:
@@ -653,6 +692,13 @@ class DuplexSession:
self._input.reserved_input_bytes += size
return True
@property
def turn_detection_config_locked(self) -> bool:
return self._input.turn_detection_config_locked
def lock_turn_detection_config(self) -> None:
self._input.turn_detection_config_locked = True
def release_input_bytes(self, size: int) -> None:
self._input.reserved_input_bytes = max(0, self._input.reserved_input_bytes - max(0, int(size)))
@@ -677,6 +723,92 @@ class DuplexSession:
self._input.pending_audio.append(DuplexAudioChunk(data=data, format=fmt, sample_rate_hz=sample_rate_hz))
self.turn_state = DuplexTurnState.USER_SPEAKING
def append_server_vad_frame(
self,
samples: np.ndarray,
*,
speech_started: bool,
speech_stopped: bool,
prefix_samples: int,
) -> int:
"""Store one detector frame while keeping commit-eligible audio session-owned.
Returns the number of raw float32 bytes released when the rolling
pre-speech prefix is trimmed.
"""
# Session-owned audio must not retain the batch backing a detector frame.
frame = np.array(samples, dtype=np.float32, copy=True).reshape(-1)
released_samples = 0
if not self._input.vad_speech_active:
if speech_started:
self._input.vad_utterance_audio = [*self._input.vad_prefix_audio, frame]
self._input.vad_prefix_audio = []
self._input.vad_prefix_samples = 0
self._input.vad_speech_active = True
else:
self._input.vad_prefix_audio.append(frame)
self._input.vad_prefix_samples += frame.size
while self._input.vad_prefix_samples > prefix_samples and self._input.vad_prefix_audio:
overflow = self._input.vad_prefix_samples - prefix_samples
first = self._input.vad_prefix_audio[0]
if first.size <= overflow:
self._input.vad_prefix_audio.pop(0)
self._input.vad_prefix_samples -= first.size
released_samples += first.size
else:
self._input.vad_prefix_audio[0] = first[overflow:].copy()
self._input.vad_prefix_samples -= overflow
released_samples += overflow
else:
self._input.vad_utterance_audio.append(frame)
if speech_stopped:
self._input.vad_speech_active = False
if self._input.vad_speech_active or speech_started:
self.turn_state = DuplexTurnState.USER_SPEAKING
return released_samples * np.dtype(np.float32).itemsize
def stage_server_vad_audio_for_commit(self) -> bool:
if not self._input.vad_utterance_audio:
return False
samples = np.concatenate(self._input.vad_utterance_audio)
encoded = encode_float32_mono_wav_base64(samples, sample_rate_hz=16_000)
self._input.pending_audio.append(DuplexAudioChunk(data=encoded, format="wav", sample_rate_hz=16_000))
self._input.vad_utterance_audio.clear()
self._input.vad_speech_active = False
return True
def clear_server_vad_audio(self) -> None:
self._input.vad_prefix_audio.clear()
self._input.vad_prefix_samples = 0
self._input.vad_utterance_audio.clear()
self._input.vad_speech_active = False
self._input.active_server_vad_item_id = None
@property
def active_server_vad_item_id(self) -> str | None:
return self._input.active_server_vad_item_id
@property
def server_vad_utterance_bytes(self) -> int:
return sum(frame.nbytes for frame in self._input.vad_utterance_audio)
def discard_uncommitted_server_vad_utterance(self) -> int:
released = self.server_vad_utterance_bytes
self._input.vad_utterance_audio.clear()
self._input.vad_speech_active = False
self._input.active_server_vad_item_id = None
self._input.pending_text.clear()
return released
def begin_server_vad_speech(self, item_id: str) -> None:
self._input.active_server_vad_item_id = item_id
def finish_server_vad_speech(self) -> str | None:
item_id = self._input.active_server_vad_item_id
self._input.active_server_vad_item_id = None
return item_id
def mark_user_input_activity(self) -> None:
self.turn_state = DuplexTurnState.USER_SPEAKING
@@ -692,10 +824,16 @@ class DuplexSession:
self._input.pending_audio.clear()
self._input.reserved_input_bytes = 0
self._input.pending_turns = 0
self.clear_server_vad_audio()
self.turn_state = DuplexTurnState.IDLE
return cancelled
def commit_user_input(self) -> DuplexCommittedInput | None:
def commit_user_input(
self,
*,
append_history: bool = True,
retain_input_bytes: int = 0,
) -> DuplexCommittedInput | None:
text = "".join(self._input.pending_text).strip()
audio_chunks = list(self._input.pending_audio)
if not text and not audio_chunks:
@@ -722,10 +860,11 @@ class DuplexSession:
self._bind_active_response_to_input_commit(self._input.commit_seq + 1)
self._input.commit_seq += 1
message = {"role": "user", "content": content}
self._conversation.messages.append(message)
if append_history:
self._conversation.messages.append(message)
self._input.pending_text.clear()
self._input.pending_audio.clear()
self._input.reserved_input_bytes = 0
self._input.reserved_input_bytes = max(0, int(retain_input_bytes))
self.turn_state = DuplexTurnState.USER_COMMITTED
return DuplexCommittedInput(
message=message,
@@ -734,6 +873,40 @@ class DuplexSession:
input_commit_seq=self._input.commit_seq,
)
@property
def pending_server_vad_turn(self) -> PendingServerVADTurn | None:
return self._input.pending_server_vad_turn
def stage_server_vad_turn(
self,
committed: DuplexCommittedInput,
*,
create_response: bool,
item_id: str,
) -> None:
if self._input.pending_server_vad_turn is not None:
raise RuntimeError("A Server VAD turn is already pending")
response_id = self.active_response_id
if response_id is not None:
# A pending user turn advances input_commit_seq before the active
# response completes. Reserve its current history position first.
self.reserve_history_item(f"item_{response_id}")
self._input.pending_server_vad_turn = PendingServerVADTurn(
committed=committed,
create_response=create_response,
item_id=item_id,
)
def activate_server_vad_turn(self) -> PendingServerVADTurn | None:
pending = self._input.pending_server_vad_turn
if pending is None:
return None
self._input.pending_server_vad_turn = None
self._conversation.messages.append(pending.committed.message)
self.register_history_item(pending.item_id, pending.committed.message)
self.turn_state = DuplexTurnState.USER_COMMITTED
return pending
def commit_native_audio_input(
self,
*,
@@ -1020,7 +1193,14 @@ class DuplexSession:
response_input_commit_seq = self._response.active_response_input_commit_seq
if response_input_commit_seq is None:
response_input_commit_seq = self.input_commit_seq
response_history_is_late = self.input_commit_seq > response_input_commit_seq
response_item_id = f"item_{response_id}" if response_id is not None else None
response_history_is_reserved = response_item_id is not None and (
response_item_id in self._conversation.item_ids
or response_item_id in self._conversation.history_item_placeholders
)
response_history_is_late = (
self.input_commit_seq > response_input_commit_seq and not response_history_is_reserved
)
assistant_text = "".join(self._response.assistant_text_buffer).strip()
message = None
if assistant_text:
@@ -1041,9 +1221,8 @@ class DuplexSession:
committed_text = ""
if commit_text and committed_text:
message = {"role": "assistant", "content": committed_text}
item_id = f"item_{response_id}" if response_id is not None else None
if item_id is not None and item_id in self._conversation.history_item_placeholders:
self._store_history_item_message(item_id, message)
if response_item_id is not None and response_history_is_reserved:
self._store_history_item_message(response_item_id, message)
else:
self._conversation.messages.append(message)
effective_playback_policy = playback_commit_policy or self.config.playback_commit_policy
@@ -1054,7 +1233,7 @@ class DuplexSession:
and effective_playback_policy == DuplexPlaybackCommitPolicy.ACK_ONLY.value
):
self.register_history_item(f"item_{response_id}", None)
elif response_id is not None and not assistant_text:
elif response_id is not None and (not assistant_text or not commit_text):
self._discard_history_item_placeholder(f"item_{response_id}")
self._response.assistant_text_buffer.clear()
if not preserve_request:
@@ -1113,6 +1292,11 @@ class DuplexSession:
)
def delete_history_item(self, item_id: str) -> bool:
removed_pending_turn = False
pending_server_vad_turn = self._input.pending_server_vad_turn
if pending_server_vad_turn is not None and pending_server_vad_turn.item_id == item_id:
self._input.pending_server_vad_turn = None
removed_pending_turn = True
response_id = item_id.removeprefix("item_") if item_id.startswith("item_") else None
message = self._conversation.item_ids.pop(item_id, None)
self._conversation.item_audio_text_marks.pop(item_id, None)
@@ -1125,7 +1309,7 @@ class DuplexSession:
if response_id is not None:
self._conversation.assistant_response_snapshots.pop(response_id, None)
if message is None:
return pending is not None or removed_placeholder
return removed_pending_turn or pending is not None or removed_placeholder
try:
self._conversation.messages.remove(message)
except ValueError:
@@ -1390,6 +1574,8 @@ class DuplexSession:
self._response.active_response_turn_id = None
self._response.active_response_input_commit_seq = None
self._response.active_response_awaits_input_commit = False
self.clear_server_vad_audio()
self._input.pending_server_vad_turn = None
self._clear_response_metrics()
self._restore_response_config()
@@ -1420,6 +1606,8 @@ class DuplexSession:
"playback": self.playback.as_dict(),
"capabilities": self.capabilities.as_dict(),
}
if self.config.turn_detection_configured:
payload["turn_detection"] = self.config.server_vad.as_dict() if self.config.server_vad is not None else None
if isinstance(self.config.extra_body.get("realtime_tools"), list):
payload["tools"] = self.config.extra_body["realtime_tools"]
if isinstance(self.config.extra_body.get("realtime_tool_choice"), str | dict):
File diff suppressed because it is too large. Load diff
+49 -13
View File
@@ -39,6 +39,28 @@ class RealtimeOutputProjector:
if event_type == "session.updated":
session = self._realtime_session_payload(event.get("session"))
return [{"type": "session.updated", "session": session}]
if event_type == "input_audio_buffer.speech_started":
item_id = event.get("item_id")
if isinstance(item_id, str):
self._active_input_item_id = item_id
self._input_speech_started = True
self._input_audio_buffer_has_audio = True
return [
{
"type": "input_audio_buffer.speech_started",
"audio_start_ms": max(0, int(event.get("audio_start_ms", 0) or 0)),
"item_id": item_id,
}
]
if event_type == "input_audio_buffer.speech_stopped":
self._input_speech_started = False
return [
{
"type": "input_audio_buffer.speech_stopped",
"audio_end_ms": max(0, int(event.get("audio_end_ms", 0) or 0)),
"item_id": event.get("item_id") or self._active_input_item_id,
}
]
if event_type in {
"session.resumed",
"session.heartbeat_ack",
@@ -201,14 +223,15 @@ class RealtimeOutputProjector:
),
]
if event_type == "input.committed":
event_item_id = event.get("realtime_item_id")
item_id = (
event_item_id
if isinstance(event_item_id, str) and event_item_id
else self._pop_pending_commit_item_id()
)
event_item_id = event.get("item_id")
# Acknowledgements carry their own identity; untracked silence
# commits must not consume another turn's item ID.
item_id = event_item_id if isinstance(event_item_id, str) and event_item_id else f"item_{uuid4().hex}"
# The reader may already have accepted audio for the next turn.
if self._active_input_item_id == item_id:
self._reset_realtime_input_buffer_state()
item = self._conversation_items.get(item_id)
created_payload: list[dict[str, object]] = []
payloads = [self._input_audio_buffer_committed_event(item_id=item_id)]
if item is None:
message = event.get("message")
no_response = event.get("no_response") is True
@@ -226,19 +249,18 @@ class RealtimeOutputProjector:
),
}
self._conversation_items[item_id] = item
created_payload.extend(self._conversation_item_added_events(item))
payloads.extend(self._conversation_item_added_events(item))
item["status"] = "completed"
payloads = created_payload + [
self._input_audio_buffer_committed_event(item_id=item_id, event=event),
]
transcription_event = self._input_audio_transcription_completed_event(item_id, item)
if transcription_event is not None:
payloads.append(transcription_event)
payloads.append(self._conversation_item_done_event(item))
return payloads
if event_type == "input.cancelled":
self._reset_realtime_input_buffer_state()
return [{"type": "input_audio_buffer.cleared"}]
if event_type == "input_audio_buffer.cleared":
self._reset_realtime_input_buffer_state()
return [{"type": "input_audio_buffer.cleared"}]
if event_type == "audio.cancelled":
response_id = event.get("response_id")
@@ -416,7 +438,14 @@ class RealtimeOutputProjector:
return [event]
message = str(raw_error or event.get("message") or "Duplex runtime error")
code = str(event.get("code") or "duplex_error")
return [self._realtime_error_payload(code, message, event_id=event.get("realtime_event_id"))]
return [
self._realtime_error_payload(
code,
message,
event_id=event.get("realtime_event_id") or event.get("event_id"),
param=event.get("param"),
)
]
if event_type == "session.closed":
return [{"type": "session.closed", "event": event}]
return [{"type": f"duplex.{event_type}", "event": event}]
@@ -490,7 +519,14 @@ class RealtimeOutputProjector:
},
},
)
payload.setdefault("turn_detection", payload.get("turn_detection"))
if self._turn_detection_configured or "turn_detection" in payload:
turn_detection = payload.get("turn_detection", self._turn_detection)
audio = dict(payload["audio"]) if isinstance(payload.get("audio"), dict) else {}
audio_input = dict(audio["input"]) if isinstance(audio.get("input"), dict) else {}
audio_input["turn_detection"] = turn_detection
audio["input"] = audio_input
payload["audio"] = audio
payload["turn_detection"] = turn_detection
payload.setdefault("input_audio_transcription", payload.get("input_audio_transcription"))
payload.setdefault("tracing", payload.get("tracing"))
return payload
@@ -42,6 +42,10 @@ class NativeRealtimeSessionProtocol(
def bind_sender(self, send_realtime_json) -> None:
self._send_realtime_json = send_realtime_json
def bind_native_input_append(self, enabled: bool) -> None:
"""Use the serving-selected input path, independent of interruption policy."""
self._native_input_append = enabled
def _default_session_payload(self) -> dict[str, object]:
payload: dict[str, object] = {
"model": self._default_model,
+37 -10
View File
@@ -24,6 +24,32 @@ REALTIME_OUTPUT_AUDIO_FORMATS = {
"g711_ulaw",
"g711_alaw",
}
REALTIME_PCM_DEFAULT_SAMPLE_RATE_HZ = 24_000
REALTIME_PCM_F32_DEFAULT_SAMPLE_RATE_HZ = 16_000
REALTIME_G711_DEFAULT_SAMPLE_RATE_HZ = 8_000
_RealtimeProtocolConfig = tuple[str, int, str, int | None, float]
_PendingTurnDetectionUpdate = tuple[
bool,
dict[str, object] | None,
asyncio.Event,
_RealtimeProtocolConfig,
]
def realtime_default_sample_rate_hz(fmt: object) -> int | None:
if not isinstance(fmt, str):
return None
normalized = fmt.lower()
if normalized in {"pcm16", "pcm_s16le", "s16le", "pcm", "audio/pcm"}:
return REALTIME_PCM_DEFAULT_SAMPLE_RATE_HZ
if normalized in {"pcm_f32le", "audio/pcm_f32le"}:
return REALTIME_PCM_F32_DEFAULT_SAMPLE_RATE_HZ
if normalized in {"g711_ulaw", "g711_alaw", "audio/g711_ulaw", "audio/g711_alaw"}:
return REALTIME_G711_DEFAULT_SAMPLE_RATE_HZ
return None
REALTIME_ERROR_TYPES_BY_CODE = {
"bad_event": "invalid_request_error",
"bad_audio": "invalid_request_error",
@@ -43,12 +69,16 @@ REALTIME_ERROR_TYPES_BY_CODE = {
"runtime_data_plane_text_without_audio": "server_error",
"response_error": "server_error",
"chat_error": "server_error",
"server_vad_initialization_failed": "server_error",
"server_vad_inference_failed": "server_error",
"duplex_session_busy": "rate_limit_error",
"resource_exhausted": "rate_limit_error",
"input_backpressure": "rate_limit_error",
"response_already_active": "invalid_request_error",
"response_not_active": "invalid_request_error",
"response_create_without_input": "invalid_request_error",
"input_audio_buffer_empty": "invalid_request_error",
"server_vad_manual_commit_unsupported": "invalid_request_error",
"missing_item_id": "invalid_request_error",
"item_not_found": "invalid_request_error",
"playback_item_mismatch": "invalid_request_error",
@@ -63,7 +93,6 @@ REALTIME_ERROR_TYPES_BY_CODE = {
"native_text_append_unsupported": "invalid_request_error",
"runtime_native_stage_role_required": "server_error",
"runtime_native_runner_kv_required": "server_error",
"server_vad_unavailable": "server_error",
}
@@ -109,12 +138,12 @@ class RealtimeSessionState:
_default_session_id: object | None = None
_default_extra_body: dict[str, object] = field(default_factory=dict)
_input_audio_format: str = "pcm16"
_input_sample_rate_hz: int = 16000
_input_sample_rate_hz: int = REALTIME_PCM_DEFAULT_SAMPLE_RATE_HZ
_output_audio_format: str = "pcm16"
_overlap_silence_rms: float = 0.003
_turn_detection: dict[str, object] | None = None
_server_vad: object | None = None
_pending_turn_detection_update: tuple[dict[str, object] | None, object | None, asyncio.Event] | None = None
_native_input_append: bool = False
_pending_turn_detection_update: _PendingTurnDetectionUpdate | None = None
_send_realtime_json: Any = None
_initial_session_update: bool = False
_input_speech_started: bool = False
@@ -123,13 +152,13 @@ class RealtimeSessionState:
_active_response_id: str | None = None
_last_response_id: str | None = None
_conversation_items: dict[str, dict[str, object]] = field(default_factory=dict)
_pending_commit_item_ids: asyncio.Queue[str] = field(default_factory=asyncio.Queue)
_last_conversation_item_id: str | None = None
_output_sample_rate_hz: int | None = None
_active_input_item_id: str | None = None
_input_audio_buffer_has_audio: bool = False
_input_audio_buffer_had_non_speech: bool = False
_input_audio_buffer_transcript_parts: list[str] = field(default_factory=list)
_turn_detection_configured: bool = False
@classmethod
def from_query_params(cls, query_params: Any) -> RealtimeSessionState:
@@ -197,10 +226,8 @@ class RealtimeStateOwner:
_output_audio_format: str = _RealtimeStateField()
_overlap_silence_rms: float = _RealtimeStateField()
_turn_detection: dict[str, object] | None = _RealtimeStateField()
_server_vad: object | None = _RealtimeStateField()
_pending_turn_detection_update: tuple[dict[str, object] | None, object | None, asyncio.Event] | None = (
_RealtimeStateField()
)
_native_input_append: bool = _RealtimeStateField()
_pending_turn_detection_update: _PendingTurnDetectionUpdate | None = _RealtimeStateField()
_send_realtime_json: Any = _RealtimeStateField()
_initial_session_update: bool = _RealtimeStateField()
_input_speech_started: bool = _RealtimeStateField()
@@ -209,10 +236,10 @@ class RealtimeStateOwner:
_active_response_id: str | None = _RealtimeStateField()
_last_response_id: str | None = _RealtimeStateField()
_conversation_items: dict[str, dict[str, object]] = _RealtimeStateField()
_pending_commit_item_ids: asyncio.Queue[str] = _RealtimeStateField()
_last_conversation_item_id: str | None = _RealtimeStateField()
_output_sample_rate_hz: int | None = _RealtimeStateField()
_active_input_item_id: str | None = _RealtimeStateField()
_input_audio_buffer_has_audio: bool = _RealtimeStateField()
_input_audio_buffer_had_non_speech: bool = _RealtimeStateField()
_input_audio_buffer_transcript_parts: list[str] = _RealtimeStateField()
_turn_detection_configured: bool = _RealtimeStateField()
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Iterable, Mapping, MutableMapping
from dataclasses import dataclass, field
from importlib import import_module
from typing import Any, Protocol
@@ -106,6 +107,104 @@ class ServingRuntimeSessionState(Protocol):
def clear_continuation(self) -> None: ...
class _TurnBasedPcmAppendBuffer:
"""Empty native-audio buffer for chat-fallback sessions.
Turn-based sessions keep input audio in ``DuplexSession`` and never use
the model-native append path. The runner still shares lifecycle and
cancellation code with native sessions, so it needs an inert buffer for
those common operations.
"""
@property
def pending_byte_count(self) -> int:
return 0
def clear(self) -> None:
return None
def clear_force_listen(self) -> None:
return None
def has_pending(self) -> bool:
return False
def has_reserved(self) -> bool:
return False
def prepare_append(
self,
payload: dict[str, object],
*,
operation_id: str,
chunk_period_ms: int,
allow_emit: bool,
) -> PcmAppendReservation | None:
del payload, operation_id, chunk_period_ms, allow_emit
raise RuntimeError("Model-native audio append is not enabled for this session")
def prepare_commit(
self,
*,
operation_id: str,
chunk_period_ms: int,
) -> PcmAppendReservation:
del operation_id, chunk_period_ms
raise RuntimeError("Model-native audio append is not enabled for this session")
def flush(self, *, chunk_period_ms: int) -> dict[str, object] | None:
del chunk_period_ms
return None
@dataclass(slots=True)
class TurnBasedServingSessionState:
"""Runner-local state for sessions served through chat fallback."""
audio_buffer: PcmAppendBuffer = field(default_factory=_TurnBasedPcmAppendBuffer)
input_since_commit: bool = False
speech_since_commit: bool = False
native_context_locked: bool = False
committed_audio_payload: dict[str, object] | None = None
committed_audio_operation_id: str | None = None
committed_audio_reserved_bytes: int = 0
deferred_response_create: bool = False
deferred_precreate_response: bool = False
data_plane_task: asyncio.Task[None] | None = None
data_plane_restart_requested: bool = False
continuation_owner_id: str | None = None
continuation_units: int = 0
pending_silence_task: asyncio.Task[bool] | None = None
pending_silence_owner_id: str | None = None
silence_continuation_scheduler: Callable[..., Awaitable[bool]] | None = None
def retain_committed_audio(
self,
payload: dict[str, object],
*,
operation_id: str | None,
reserved_bytes: int = 0,
) -> None:
self.committed_audio_payload = payload
self.committed_audio_operation_id = operation_id
self.committed_audio_reserved_bytes += max(0, int(reserved_bytes))
def clear_committed_audio(self) -> int:
reserved_bytes = self.committed_audio_reserved_bytes
self.committed_audio_payload = None
self.committed_audio_operation_id = None
self.committed_audio_reserved_bytes = 0
self.deferred_response_create = False
self.deferred_precreate_response = False
return reserved_bytes
def clear_continuation(self) -> None:
self.continuation_owner_id = None
self.continuation_units = 0
self.pending_silence_task = None
self.pending_silence_owner_id = None
class RuntimeDataPlane(Protocol):
def begin_request(self, request_id: str) -> None: ...
+22 -5
View File
@@ -39,6 +39,11 @@ class NativeRuntimeBridgeMixin:
}
async def _open_runtime_session(self, session: DuplexSession, send_json) -> dict[str, object] | bool:
# Turn-based sessions use the chat-fallback path and do not own a
# model-native duplex runtime. Do not send control messages merely
# because the shared engine client exposes the duplex RPC methods.
if not self._uses_serving_runtime_adapter(session.config):
return True
contract_error = self._native_runtime_contract_error(session)
if contract_error is not None:
await send_json(
@@ -535,6 +540,8 @@ class NativeRuntimeBridgeMixin:
session_config: dict[str, object] | None = None,
runtime_config: dict[str, object] | None = None,
) -> bool:
if not self._uses_serving_runtime_adapter(session.config):
return True
signal_turn = getattr(self._chat_service.engine_client, "signal_duplex_turn_async", None)
if not callable(signal_turn):
return True
@@ -582,6 +589,8 @@ class NativeRuntimeBridgeMixin:
return True
async def _close_runtime_session(self, session: DuplexSession, *, reason: str, send_json=None) -> bool:
if not self._uses_serving_runtime_adapter(session.config):
return True
close_session = getattr(self._chat_service.engine_client, "close_duplex_session_async", None)
if not callable(close_session):
return True
@@ -1301,11 +1310,19 @@ class NativeRuntimeBridgeMixin:
def _cleanup_duplex_session_state(self, session: DuplexSession) -> None:
session_id = session.session_id
self._serving_runtime_adapter.remove_session_state(session_id)
self._serving_runtime_adapter.data_plane.close_session(
session_id,
active_request_id=session.active_request_id,
)
pipeline = self._server_vad_pipelines.pop(session_id, None)
if pipeline is not None:
pipeline.reset()
self._realtime_vad_metrics.session_finished()
adapter = self._serving_runtime_adapter
if adapter is None:
self._serving_session_states.pop(session_id, None)
else:
adapter.remove_session_state(session_id)
adapter.data_plane.close_session(
session_id,
active_request_id=session.active_request_id,
)
def _encode_native_data_plane_audio(
self,
+528
View File
@@ -0,0 +1,528 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import asyncio
import hashlib
import threading
import time
from collections.abc import Mapping
from dataclasses import dataclass, replace
from pathlib import Path
from typing import TYPE_CHECKING, Literal, Protocol
import numpy as np
from vllm.transformers_utils.repo_utils import try_get_local_file
if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.audio_utils_mixin import StreamingAudioResampler
SILERO_VAD_REPO_ID = "istupakov/silero-vad-onnx"
SILERO_VAD_REVISION = "8b14476858ef240c50b3884bb38cc67290c1cc70"
SILERO_VAD_FILENAME = "silero_vad.onnx"
SILERO_VAD_SHA256 = "1a153a22f4509e292a94e67d6f9b85e8deb25b4988682b7e174c65279d8788e3"
SILERO_VAD_MIN_THRESHOLD = 0.15
SILERO_VAD_DEFAULT_MIN_SPEECH_DURATION_MS = 96
_SERVER_VAD_FIELDS = {
"type",
"threshold",
"prefix_padding_ms",
"silence_duration_ms",
"create_response",
"interrupt_response",
"min_speech_duration_ms",
}
@dataclass(frozen=True, slots=True)
class ServerVADConfig:
"""Validated OpenAI-compatible ``server_vad`` session configuration."""
type: Literal["server_vad"] = "server_vad"
threshold: float = 0.5
prefix_padding_ms: int = 300
silence_duration_ms: int = 500
create_response: bool = True
# Omission is resolved from runtime capabilities before the session is opened.
interrupt_response: bool | None = None
min_speech_duration_ms: int | None = None
@classmethod
def from_value(cls, value: object) -> ServerVADConfig:
if not isinstance(value, dict):
raise ValueError("turn_detection must be null or an object")
unknown = sorted(set(value) - _SERVER_VAD_FIELDS)
if unknown:
raise ValueError(f"Unknown server_vad field(s): {', '.join(unknown)}")
vad_type = value.get("type")
if vad_type != "server_vad":
raise ValueError("turn_detection.type must be 'server_vad'")
threshold = value.get("threshold", 0.5)
if isinstance(threshold, bool) or not isinstance(threshold, int | float) or not 0 <= threshold <= 1:
raise ValueError("server_vad.threshold must be a number between 0 and 1")
prefix_padding_ms = value.get("prefix_padding_ms", 300)
if isinstance(prefix_padding_ms, bool) or not isinstance(prefix_padding_ms, int) or prefix_padding_ms < 0:
raise ValueError("server_vad.prefix_padding_ms must be a non-negative integer")
silence_duration_ms = value.get("silence_duration_ms", 500)
if (
isinstance(silence_duration_ms, bool)
or not isinstance(silence_duration_ms, int)
or silence_duration_ms <= 0
):
raise ValueError("server_vad.silence_duration_ms must be a positive integer")
create_response = value.get("create_response", True)
if not isinstance(create_response, bool):
raise ValueError("server_vad.create_response must be a boolean")
interrupt_response = value.get("interrupt_response")
if "interrupt_response" in value and not isinstance(interrupt_response, bool):
raise ValueError("server_vad.interrupt_response must be a boolean")
min_speech_duration_ms = value.get("min_speech_duration_ms")
if min_speech_duration_ms is not None and (
isinstance(min_speech_duration_ms, bool)
or not isinstance(min_speech_duration_ms, int | float)
or not np.isfinite(float(min_speech_duration_ms))
or min_speech_duration_ms < 0
):
raise ValueError("server_vad.min_speech_duration_ms must be a non-negative number")
return cls(
type=vad_type,
threshold=float(threshold),
prefix_padding_ms=prefix_padding_ms,
silence_duration_ms=silence_duration_ms,
create_response=create_response,
interrupt_response=interrupt_response,
min_speech_duration_ms=(int(min_speech_duration_ms) if min_speech_duration_ms is not None else None),
)
def as_dict(self) -> dict[str, object]:
result: dict[str, object] = {
"type": self.type,
"threshold": self.threshold,
"prefix_padding_ms": self.prefix_padding_ms,
"silence_duration_ms": self.silence_duration_ms,
"create_response": self.create_response,
}
if self.interrupt_response is not None:
result["interrupt_response"] = self.interrupt_response
if self.min_speech_duration_ms is not None:
result["min_speech_duration_ms"] = self.min_speech_duration_ms
return result
def parse_session_turn_detection(
payload: Mapping[str, object],
) -> tuple[bool, ServerVADConfig | None]:
"""Extract and validate OpenAI-compatible turn detection aliases."""
configured_values: list[tuple[str, object]] = []
if "turn_detection" in payload:
configured_values.append(("turn_detection", payload["turn_detection"]))
audio = payload.get("audio")
if isinstance(audio, Mapping):
audio_input = audio.get("input")
if isinstance(audio_input, Mapping) and "turn_detection" in audio_input:
configured_values.append(("audio.input.turn_detection", audio_input["turn_detection"]))
if not configured_values:
return False, None
first_path, first_value = configured_values[0]
first_config = None if first_value is None else ServerVADConfig.from_value(first_value)
for field_path, value in configured_values[1:]:
config = None if value is None else ServerVADConfig.from_value(value)
if first_config is not None and config is not None:
# An omitted option must not conflict with an explicit alias value.
if first_config.interrupt_response is None:
first_config = replace(first_config, interrupt_response=config.interrupt_response)
if config.interrupt_response is None:
config = replace(config, interrupt_response=first_config.interrupt_response)
if config != first_config:
raise ValueError(f"{first_path} and {field_path} must not specify conflicting values")
return True, first_config
class SpeechDetectorBackend(Protocol):
"""Detector contract; per-stream state is owned by the pipeline."""
frame_samples: int
def new_state(self) -> object: ...
def infer(self, frame: np.ndarray, state: object) -> tuple[float, object]: ...
class SpeechDetectorBackendProvider(Protocol):
def get(self) -> SpeechDetectorBackend: ...
@dataclass(frozen=True, slots=True)
class SpeechEndpointDecision:
speech_started: bool = False
speech_stopped: bool = False
audio_start_ms: int | None = None
audio_end_ms: int | None = None
endpoint_delay_ms: int | None = None
class SpeechEndpointPolicy(Protocol):
@property
def speech_active(self) -> bool: ...
def update(
self,
probability: float,
*,
frame_start_sample: int,
frame_samples: int,
) -> SpeechEndpointDecision: ...
def reset(self) -> None: ...
class ThresholdEndpointPolicy:
"""Apply threshold and trailing-silence endpoint rules."""
def __init__(self, config: ServerVADConfig, *, sample_rate_hz: int) -> None:
self.config = config
self.sample_rate_hz = sample_rate_hz
self._speech_active = False
self._candidate_samples = 0
self._candidate_start_sample: int | None = None
self._silence_samples = 0
@property
def speech_active(self) -> bool:
return self._speech_active
def update(
self,
probability: float,
*,
frame_start_sample: int,
frame_samples: int,
) -> SpeechEndpointDecision:
if probability >= self.config.threshold:
self._silence_samples = 0
if not self._speech_active:
if self._candidate_samples == 0:
self._candidate_start_sample = frame_start_sample
self._candidate_samples += frame_samples
min_speech_duration_ms = self.config.min_speech_duration_ms or 0
min_speech_samples = max(1, round(min_speech_duration_ms * self.sample_rate_hz / 1000))
if self._candidate_samples < min_speech_samples:
return SpeechEndpointDecision()
self._speech_active = True
detected_start_sample = (
self._candidate_start_sample if self._candidate_start_sample is not None else frame_start_sample
)
detected_start_ms = round(detected_start_sample * 1000 / self.sample_rate_hz)
self._candidate_samples = 0
self._candidate_start_sample = None
return SpeechEndpointDecision(
speech_started=True,
audio_start_ms=max(0, detected_start_ms - self.config.prefix_padding_ms),
)
return SpeechEndpointDecision()
if not self._speech_active:
self._candidate_samples = 0
self._candidate_start_sample = None
return SpeechEndpointDecision()
# Match Silero v6.2's streaming VAD hysteresis: activation uses the
# configured threshold, while potential speech end starts only below
# ``max(threshold - 0.15, 0.01)``. Once a silence candidate exists,
# intermediate frames keep elapsed time moving but cannot themselves
# close the turn.
negative_threshold = max(self.config.threshold - SILERO_VAD_MIN_THRESHOLD, 0.01)
below_negative_threshold = probability < negative_threshold
if not below_negative_threshold and self._silence_samples == 0:
return SpeechEndpointDecision()
self._silence_samples += frame_samples
if not below_negative_threshold:
return SpeechEndpointDecision()
silence_limit = max(1, round(self.config.silence_duration_ms * self.sample_rate_hz / 1000))
if self._silence_samples < silence_limit:
return SpeechEndpointDecision()
# OpenAI defines ``audio_end_ms`` as the end of the audio sent to the
# model, including the trailing silence used for endpoint detection.
audio_end_sample = frame_start_sample + frame_samples
endpoint_delay_ms = round(self._silence_samples * 1000 / self.sample_rate_hz)
self.reset()
return SpeechEndpointDecision(
speech_stopped=True,
audio_end_ms=max(0, round(audio_end_sample * 1000 / self.sample_rate_hz)),
endpoint_delay_ms=endpoint_delay_ms,
)
def reset(self) -> None:
self._speech_active = False
self._candidate_samples = 0
self._candidate_start_sample = None
self._silence_samples = 0
@dataclass(frozen=True, slots=True)
class ServerVADFrame:
samples: np.ndarray
decision: SpeechEndpointDecision
@dataclass(frozen=True, slots=True)
class ServerVADBatch:
frames: tuple[ServerVADFrame, ...]
inference_ms: float
class ServerVADPipeline:
"""Frame continuous 16 kHz audio and apply acoustic endpointing.
The pipeline retains only input-resampling residuals, a partial frame,
detector state, and timing counters. Commit-eligible prefix and utterance
audio remain session-owned.
"""
sample_rate_hz = 16_000
def __init__(
self,
backend: SpeechDetectorBackend,
config: ServerVADConfig,
*,
endpoint_policy: SpeechEndpointPolicy | None = None,
) -> None:
self.backend = backend
self.config = config
self.endpoint_policy = endpoint_policy or ThresholdEndpointPolicy(
config,
sample_rate_hz=self.sample_rate_hz,
)
self._input_resampler: StreamingAudioResampler | None = None
self._source_sample_rate_hz: int | None = None
self._scratch = np.empty(0, dtype=np.float32)
self._detector_state = backend.new_state()
self._processed_samples = 0
self._stream_start_ms = 0
@property
def speech_active(self) -> bool:
return self.endpoint_policy.speech_active
@property
def source_sample_rate_hz(self) -> int | None:
return self._source_sample_rate_hz
@property
def scratch_bytes(self) -> int:
resampler_bytes = self._input_resampler.scratch_bytes if self._input_resampler is not None else 0
return int(self._scratch.nbytes) + resampler_bytes
async def push_pcm16(
self,
samples: bytes,
*,
source_sample_rate_hz: int,
) -> ServerVADBatch:
"""Normalize chunked mono PCM16 input and run endpoint detection."""
if isinstance(source_sample_rate_hz, bool) or source_sample_rate_hz not in {16_000, 24_000}:
raise ValueError("server_vad PCM16 input sample rate must be 16000 or 24000 Hz")
if len(samples) % np.dtype("<i2").itemsize:
raise ValueError("server_vad PCM16 input contains an incomplete sample")
pcm16 = np.frombuffer(samples, dtype="<i2")
if self._source_sample_rate_hz is not None and source_sample_rate_hz != self._source_sample_rate_hz:
raise ValueError("server_vad input sample rate cannot change within a continuous audio stream")
if pcm16.size and self._source_sample_rate_hz is None:
from vllm_omni.entrypoints.openai.audio_utils_mixin import StreamingAudioResampler
self._input_resampler = (
None
if source_sample_rate_hz == self.sample_rate_hz
else StreamingAudioResampler(source_sample_rate_hz, self.sample_rate_hz)
)
self._source_sample_rate_hz = source_sample_rate_hz
normalized = np.ascontiguousarray(pcm16, dtype=np.float32) * np.float32(1.0 / 32768.0)
if self._input_resampler is not None:
normalized = self._input_resampler.process(normalized)
return await self.push(normalized)
async def push(self, samples: np.ndarray) -> ServerVADBatch:
normalized = np.ascontiguousarray(samples, dtype=np.float32).reshape(-1)
return await asyncio.to_thread(self._push_sync, normalized)
def _push_sync(self, samples: np.ndarray) -> ServerVADBatch:
started_at = time.perf_counter()
if self._scratch.size:
samples = np.concatenate((self._scratch, samples))
frame_samples = int(self.backend.frame_samples)
complete_samples = samples.size - samples.size % frame_samples
# Retained state must not keep the whole input chunk alive through a view.
self._scratch = samples[complete_samples:].copy()
results: list[ServerVADFrame] = []
for offset in range(0, complete_samples, frame_samples):
frame = np.ascontiguousarray(samples[offset : offset + frame_samples], dtype=np.float32)
frame_start = self._processed_samples
probability, self._detector_state = self.backend.infer(frame, self._detector_state)
probability = min(1.0, max(0.0, float(probability)))
decision = self.endpoint_policy.update(
probability,
frame_start_sample=frame_start,
frame_samples=frame_samples,
)
if decision.speech_started and decision.audio_start_ms is not None:
# Prefix audio before the most recent reset is no longer available.
decision = replace(decision, audio_start_ms=max(self._stream_start_ms, decision.audio_start_ms))
self._processed_samples += frame_samples
results.append(
ServerVADFrame(
samples=frame,
decision=decision,
)
)
return ServerVADBatch(
frames=tuple(results),
inference_ms=(time.perf_counter() - started_at) * 1000,
)
def reset(self) -> None:
"""Discard stream state while preserving the session-relative audio clock."""
self._stream_start_ms = round(self._processed_samples * 1000 / self.sample_rate_hz)
self._input_resampler = None
self._source_sample_rate_hz = None
self._scratch = np.empty(0, dtype=np.float32)
self._detector_state = self.backend.new_state()
self.endpoint_policy.reset()
@dataclass(frozen=True, slots=True)
class _SileroVADState:
model_state: np.ndarray
context: np.ndarray
class SileroVADBackend:
"""Shared ONNX Runtime Silero v6.2 detector running on CPU."""
sample_rate_hz = 16_000
frame_samples = 512
context_samples = 64
model_state_shape = (2, 1, 128)
def __init__(self, model_path: str | Path) -> None:
try:
import onnxruntime as ort
except ImportError as exc: # pragma: no cover - platform packaging supplies ORT.
raise RuntimeError("server_vad requires ONNX Runtime") from exc
session_options = ort.SessionOptions()
session_options.inter_op_num_threads = 1
session_options.intra_op_num_threads = 1
self.model_path = Path(model_path)
self._session = ort.InferenceSession(
str(self.model_path),
providers=["CPUExecutionProvider"],
sess_options=session_options,
)
input_names = {item.name for item in self._session.get_inputs()}
if "input" not in input_names or "sr" not in input_names or "state" not in input_names:
raise RuntimeError(f"Unsupported Silero ONNX input contract: {sorted(input_names)}")
self._warm_up()
def _warm_up(self) -> None:
self.infer(np.zeros(self.frame_samples, dtype=np.float32), self.new_state())
def new_state(self) -> _SileroVADState:
return _SileroVADState(
model_state=np.zeros(self.model_state_shape, dtype=np.float32),
context=np.zeros((1, self.context_samples), dtype=np.float32),
)
def infer(self, frame: np.ndarray, state: object) -> tuple[float, object]:
if not isinstance(state, _SileroVADState):
raise TypeError("Silero detector state must be created by SileroVADBackend.new_state()")
model_state = np.ascontiguousarray(state.model_state, dtype=np.float32)
context = np.ascontiguousarray(state.context, dtype=np.float32)
if model_state.shape != self.model_state_shape:
raise ValueError(f"Silero model state must have shape {self.model_state_shape}, got {model_state.shape}")
expected_context_shape = (1, self.context_samples)
if context.shape != expected_context_shape:
raise ValueError(f"Silero context must have shape {expected_context_shape}, got {context.shape}")
audio = np.ascontiguousarray(frame, dtype=np.float32).reshape(1, -1)
if audio.shape[1] != self.frame_samples:
raise ValueError(
f"Silero detector frame must contain exactly {self.frame_samples} samples, got {audio.shape[1]}"
)
model_input = np.concatenate((context, audio), axis=1)
inputs = {
"input": model_input,
"state": model_state,
"sr": np.asarray(self.sample_rate_hz, dtype=np.int64),
}
# ONNX Runtime permits concurrent Run calls on one CPU session. Stream
# state is explicit, so independent pipelines can safely share the session.
output = self._session.run(None, inputs)
if len(output) < 2:
raise RuntimeError("Silero ONNX model did not return probability and model state")
probability = float(np.asarray(output[0]).reshape(-1)[0])
next_state = _SileroVADState(
model_state=np.ascontiguousarray(output[1], dtype=np.float32),
context=np.ascontiguousarray(model_input[:, -self.context_samples :], dtype=np.float32),
)
return probability, next_state
class SileroVADBackendProvider:
"""Resolve, verify, and load one Silero model instance per process."""
def __init__(self, *, model_path: str | None = None) -> None:
self.model_path = model_path
self._backend: SileroVADBackend | None = None
self._lock = threading.Lock()
def get(self) -> SileroVADBackend:
if self._backend is not None:
return self._backend
with self._lock:
if self._backend is None:
path = self._resolve_local_artifact()
self._verify_checksum(path)
self._backend = SileroVADBackend(path)
return self._backend
def _resolve_local_artifact(self) -> Path:
if self.model_path:
path = Path(self.model_path).expanduser()
if path.is_file():
return path
raise RuntimeError(f"Configured Silero VAD model does not exist: {path}")
cached = try_get_local_file(
model=SILERO_VAD_REPO_ID,
file_name=SILERO_VAD_FILENAME,
revision=SILERO_VAD_REVISION,
)
if isinstance(cached, Path) and cached.is_file():
return cached
raise RuntimeError(
"Silero VAD artifact is not available locally. Pre-download the pinned artifact "
"or configure duplex_session.server_vad_model_path."
)
def _verify_checksum(self, path: Path) -> None:
digest = hashlib.sha256(path.read_bytes()).hexdigest()
if digest != SILERO_VAD_SHA256:
raise RuntimeError(f"Silero VAD checksum mismatch for {path}: expected {SILERO_VAD_SHA256}, got {digest}")
+276 -50
View File
@@ -8,9 +8,9 @@ import base64
import binascii
import inspect
import json
from collections.abc import Mapping
from collections.abc import Mapping, MutableMapping
from contextlib import suppress
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING
import numpy as np
@@ -49,12 +49,21 @@ from vllm_omni.entrypoints.duplex.runtime_adapter import (
ServingRuntimeAdapter,
ServingRuntimeConfigError,
ServingRuntimeSessionState,
TurnBasedServingSessionState,
load_serving_runtime_adapter,
validate_serving_runtime_adapter,
)
from vllm_omni.entrypoints.duplex.runtime_bridge import (
NativeRuntimeBridgeMixin,
)
from vllm_omni.entrypoints.duplex.server_vad import (
SILERO_VAD_DEFAULT_MIN_SPEECH_DURATION_MS,
SILERO_VAD_MIN_THRESHOLD,
ServerVADPipeline,
SileroVADBackendProvider,
SpeechDetectorBackendProvider,
parse_session_turn_detection,
)
from vllm_omni.entrypoints.duplex.session_attachment import (
DuplexJournalGapError,
DuplexSessionAttachmentRegistry,
@@ -68,6 +77,7 @@ from vllm_omni.entrypoints.duplex.websocket import (
DuplexSessionTasks,
DuplexWebSocketActor,
)
from vllm_omni.metrics.realtime import RealtimeVADMetrics
if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
@@ -85,6 +95,7 @@ class _DuplexSessionHandshake:
session: DuplexSession
resumed: bool = False
attachment_generation: int | None = None
event_id: str | None = None
class OmniDuplexSessionHandler(
@@ -105,16 +116,27 @@ class OmniDuplexSessionHandler(
self,
*,
chat_service: OmniOpenAIServingChat,
served_model_name: str | None = None,
config_timeout_s: float = _DEFAULT_CONFIG_TIMEOUT_S,
idle_timeout_s: float = _DEFAULT_IDLE_TIMEOUT_S,
log_stats: bool = True,
duplex_session_config: DuplexSessionRuntimeConfig | None = None,
serving_runtime_adapter: ServingRuntimeAdapter | None = None,
serving_runtime_adapter_path: str | None = None,
server_vad_backend_provider: SpeechDetectorBackendProvider | None = None,
) -> None:
self._chat_service = chat_service
self._config_timeout_s = config_timeout_s
self._idle_timeout_s = idle_timeout_s
self._duplex_session_config = duplex_session_config or DuplexSessionRuntimeConfig()
self._server_vad_backend_provider = server_vad_backend_provider or SileroVADBackendProvider(
model_path=self._duplex_session_config.server_vad_model_path,
)
self._server_vad_pipelines: dict[str, ServerVADPipeline] = {}
self._realtime_vad_metrics = RealtimeVADMetrics(
served_model_name or chat_service.model_config.model,
log_stats=log_stats,
)
self._registry = DuplexSessionRegistry(
DuplexCapabilities(
supports_model_native_turn_policy=False,
@@ -131,6 +153,7 @@ class OmniDuplexSessionHandler(
"duplex_serving_adapter_path",
None,
)
self._serving_runtime_adapter: ServingRuntimeAdapter | None
if serving_runtime_adapter is not None:
self._serving_runtime_adapter = validate_serving_runtime_adapter(serving_runtime_adapter)
elif isinstance(adapter_path, str) and adapter_path:
@@ -139,7 +162,10 @@ class OmniDuplexSessionHandler(
self._encode_native_data_plane_audio,
)
else:
raise ValueError("A duplex serving runtime adapter must be explicitly configured")
self._serving_runtime_adapter = None
self._serving_session_states: MutableMapping[str, ServingRuntimeSessionState] = (
self._serving_runtime_adapter.session_states if self._serving_runtime_adapter is not None else {}
)
self._session_tasks: dict[str, DuplexSessionTasks] = {}
self._realtime_protocols: dict[str, NativeRealtimeSessionProtocol] = {}
self._lease_generations: dict[str, int] = {}
@@ -223,7 +249,7 @@ class OmniDuplexSessionHandler(
active_response_task.cancel()
with suppress(asyncio.CancelledError):
await active_response_task
native = self._serving_runtime_adapter.session_states.get(session.session_id)
native = self._serving_session_states.get(session.session_id)
if native is not None and native.data_plane_task is not None:
data_plane_task = native.data_plane_task
native.data_plane_task = None
@@ -794,8 +820,27 @@ class OmniDuplexSessionHandler(
duration_ms = cls._input_audio_duration_ms(event, payload)
return 0 < duration_ms <= session.config.overlap_short_ack_ms
def _create_runtime_session_state(self) -> ServingRuntimeSessionState:
adapter = self._serving_runtime_adapter
if adapter is not None:
return adapter.create_session_state()
return TurnBasedServingSessionState()
def _runtime_session_state(self, session: DuplexSession) -> ServingRuntimeSessionState:
return self._serving_runtime_adapter.session_state(session.session_id)
adapter = self._serving_runtime_adapter
if adapter is not None:
return adapter.session_state(session.session_id)
state = self._serving_session_states.get(session.session_id)
if state is None:
state = TurnBasedServingSessionState()
self._serving_session_states[session.session_id] = state
return state
def _require_serving_runtime_adapter(self) -> ServingRuntimeAdapter:
adapter = self._serving_runtime_adapter
if adapter is None:
raise RuntimeError("Model-native duplex runtime is not configured for this session")
return adapter
def _should_force_listen_for_auto_response_overlap(
self,
@@ -823,21 +868,25 @@ class OmniDuplexSessionHandler(
*,
session: DuplexSession,
) -> bool:
for key in ("is_speech", "speech"):
value = event.get(key)
if isinstance(value, bool):
return value
vad = event.get("vad")
if isinstance(vad, dict):
value = vad.get("is_speech")
if isinstance(value, bool):
return value
probability = vad.get("speech_probability", vad.get("probability"))
# Prefer serving-derived fields in ``payload`` over optional client
# hints retained in ``event``. In server-VAD mode the pipeline, rather
# than the client or the RMS fallback below, owns speech classification.
for source in (payload, event):
for key in ("is_speech", "speech"):
value = source.get(key)
if isinstance(value, bool):
return value
vad = source.get("vad")
if isinstance(vad, dict):
value = vad.get("is_speech")
if isinstance(value, bool):
return value
probability = vad.get("speech_probability", vad.get("probability"))
if isinstance(probability, int | float):
return float(probability) >= 0.5
probability = source.get("speech_probability")
if isinstance(probability, int | float):
return float(probability) >= 0.5
probability = event.get("speech_probability")
if isinstance(probability, int | float):
return float(probability) >= 0.5
fmt = payload.get("format")
audio = payload.get("audio")
@@ -925,14 +974,26 @@ class OmniDuplexSessionHandler(
)
return None
config = DuplexSessionConfig.from_event(event)
try:
config = DuplexSessionConfig.from_event(event)
except ValueError as exc:
error: dict[str, object] = {
"type": "error",
"error": str(exc),
"code": "invalid_request_error",
}
event_id = event.get("_realtime_event_id")
if isinstance(event_id, str) and event_id:
error["event_id"] = event_id
await send_json(error)
return None
if config.idle_timeout_s == _DEFAULT_IDLE_TIMEOUT_S:
config.idle_timeout_s = self._idle_timeout_s
use_native_runtime = self._uses_serving_runtime_adapter(config)
runtime_adapter = self._serving_runtime_adapter if self._uses_serving_runtime_adapter(config) else None
runtime_config: dict[str, object] = {}
if use_native_runtime:
if runtime_adapter is not None:
try:
runtime_config = await self._serving_runtime_adapter.prepare_runtime_config(
runtime_config = await runtime_adapter.prepare_runtime_config(
config,
model_config=getattr(self._chat_service, "model_config", None),
)
@@ -944,14 +1005,20 @@ class OmniDuplexSessionHandler(
return None
session_id = event.get("session_id") if isinstance(event.get("session_id"), str) else None
session = self._registry.create(config=config, session_id=session_id)
if use_native_runtime:
if runtime_adapter is not None:
session.replace_capabilities(
self._serving_runtime_adapter.capabilities(
max_sessions=self._duplex_session_config.max_sessions,
)
runtime_adapter.capabilities(max_sessions=self._duplex_session_config.max_sessions)
)
session.replace_runtime_config(runtime_config)
return _DuplexSessionHandshake(session=session)
session_payload = event.get("session")
self._resolve_server_vad_defaults(
session, config, session_payload if isinstance(session_payload, dict) else event
)
event_id = event.get("_realtime_event_id")
return _DuplexSessionHandshake(
session=session,
event_id=event_id if isinstance(event_id, str) and event_id else None,
)
async def _resume_session_handshake(
self,
@@ -1151,7 +1218,8 @@ class OmniDuplexSessionHandler(
return None
def _uses_serving_runtime_adapter(self, config: DuplexSessionConfig) -> bool:
return self._serving_runtime_adapter.is_enabled(config)
adapter = self._serving_runtime_adapter
return adapter is not None and adapter.is_enabled(config)
def _runtime_session_update_error(
self,
@@ -1161,7 +1229,7 @@ class OmniDuplexSessionHandler(
if not self._uses_native_input_append(session):
return None
try:
self._serving_runtime_adapter.validate_client_extra_body(payload.get("extra_body"))
self._require_serving_runtime_adapter().validate_client_extra_body(payload.get("extra_body"))
except ServingRuntimeConfigError as exc:
return {
"type": "error",
@@ -1178,24 +1246,109 @@ class OmniDuplexSessionHandler(
) -> dict[str, object]:
if not self._uses_native_input_append(session):
return dict(session.runtime_config)
return self._serving_runtime_adapter.runtime_config_for_update(
return self._require_serving_runtime_adapter().runtime_config_for_update(
candidate_config,
dict(session.runtime_config),
)
def _resolve_server_vad_defaults(
self,
session: DuplexSession,
config: DuplexSessionConfig,
payload: dict[str, object],
) -> None:
"""Resolve omitted options without overriding explicit client policies."""
if config.server_vad is None:
return
if config.server_vad.interrupt_response is None:
config.server_vad = replace(config.server_vad, interrupt_response=self._uses_native_input_append(session))
extra = payload.get("extra_body")
overlap_policy = extra.get("overlap_policy") if isinstance(extra, dict) else None
if not isinstance(overlap_policy, str):
overlap_policy = payload.get("overlap_policy")
config.overlap_policy = (
overlap_policy
if isinstance(overlap_policy, str)
else "barge_in_on_speech"
if config.server_vad.interrupt_response
else "listen_only"
)
def _runtime_session_candidate_update_error(
self,
session: DuplexSession,
candidate_config: DuplexSessionConfig,
*,
realtime_protocol: NativeRealtimeSessionProtocol | None = None,
) -> dict[str, object] | None:
if candidate_config.server_vad is not None:
desired_policy = "barge_in_on_speech" if candidate_config.server_vad.interrupt_response else "listen_only"
if candidate_config.overlap_policy != desired_policy:
return {
"type": "error",
"code": "unsupported_turn_detection",
"error": f"overlap_policy conflicts with turn_detection; expected {desired_policy!r}",
"param": "overlap_policy",
}
if not self._uses_native_input_append(session):
if candidate_config.overlap_policy != DuplexOverlapPolicy.BARGE_IN_ON_SPEECH.value:
return None
return {
"type": "error",
"code": "server_vad_requires_native_duplex",
"error": "Realtime server_vad requires a model-native duplex runtime",
}
if (
candidate_config.server_vad is not None
and candidate_config.server_vad.min_speech_duration_ms is not None
):
return {
"type": "error",
"code": "unsupported_turn_detection",
"error": "server_vad.min_speech_duration_ms is only supported by model-native duplex runtimes",
}
requests_interruption = (
candidate_config.server_vad is not None and candidate_config.server_vad.interrupt_response
) or candidate_config.overlap_policy == DuplexOverlapPolicy.BARGE_IN_ON_SPEECH.value
if requests_interruption:
return {
"type": "error",
"code": "server_vad_requires_native_duplex",
"error": (
"Realtime server_vad interruption requires a model-native duplex runtime; "
"set interrupt_response=false for turn-based models"
),
}
if candidate_config.server_vad is not None and realtime_protocol is not None:
session_payload = candidate_config.extra_body.get("realtime_session_payload")
audio_error = realtime_protocol.validate_server_vad_input_audio(
session_payload if isinstance(session_payload, dict) else {}
)
if audio_error is not None:
return {
"type": "error",
"code": "unsupported_turn_detection",
"error": audio_error,
"param": "audio.input.format",
}
return None
if candidate_config.server_vad is not None:
if not candidate_config.server_vad.interrupt_response:
return {
"type": "error",
"session_id": session.session_id,
"code": "unsupported_turn_detection",
"error": "Model-native duplex server_vad requires interrupt_response=true",
}
if candidate_config.server_vad.threshold <= SILERO_VAD_MIN_THRESHOLD:
return {
"type": "error",
"session_id": session.session_id,
"code": "unsupported_turn_detection",
"error": (
f"Model-native duplex server_vad.threshold must be greater than {SILERO_VAD_MIN_THRESHOLD}"
),
}
if not candidate_config.server_vad.create_response:
return {
"type": "error",
"session_id": session.session_id,
"code": "unsupported_turn_detection",
"error": "Model-native duplex server_vad requires create_response=true",
}
if (
candidate_config.instructions != session.config.instructions
and self._runtime_session_state(session).native_context_locked
@@ -1280,7 +1433,7 @@ class OmniDuplexSessionHandler(
session: DuplexSession,
committed: DuplexCommittedInput,
*,
realtime_item_id: object | None = None,
item_id: object | None = None,
) -> dict[str, object]:
payload: dict[str, object] = {
"type": "input.committed",
@@ -1290,15 +1443,15 @@ class OmniDuplexSessionHandler(
"history_len": len(session.history),
"message": committed.message,
}
if isinstance(realtime_item_id, str) and realtime_item_id:
payload["realtime_item_id"] = realtime_item_id
if isinstance(item_id, str) and item_id:
payload["item_id"] = item_id
return payload
@staticmethod
def _commit_native_audio_input(
session: DuplexSession,
*,
realtime_item_id: object | None = None,
item_id: object | None = None,
transcript: object | None = None,
turn_id: int | None = None,
) -> DuplexCommittedInput:
@@ -1307,8 +1460,8 @@ class OmniDuplexSessionHandler(
transcript=clean_transcript or None,
turn_id=turn_id,
)
if isinstance(realtime_item_id, str) and realtime_item_id:
session.register_history_item(realtime_item_id, committed.message)
if isinstance(item_id, str) and item_id:
session.register_history_item(item_id, committed.message)
return committed
@staticmethod
@@ -1316,7 +1469,7 @@ class OmniDuplexSessionHandler(
session: DuplexSession,
*,
committed: DuplexCommittedInput | None = None,
realtime_item_id: object | None = None,
item_id: object | None = None,
transcript: object | None = None,
) -> dict[str, object]:
message = committed.message if committed is not None else None
@@ -1342,12 +1495,15 @@ class OmniDuplexSessionHandler(
}
if isinstance(transcript, str) and transcript:
payload["transcript"] = transcript
if isinstance(realtime_item_id, str) and realtime_item_id:
payload["realtime_item_id"] = realtime_item_id
if isinstance(item_id, str) and item_id:
payload["item_id"] = item_id
return payload
@staticmethod
def _apply_session_update(session: DuplexSession, payload: dict[str, object]) -> dict[str, object] | None:
def _apply_session_update(
self,
session: DuplexSession,
payload: dict[str, object],
) -> dict[str, object] | None:
model = payload.get("model")
audio_config = payload.get("audio")
audio_input = audio_config.get("input") if isinstance(audio_config, dict) else None
@@ -1385,6 +1541,14 @@ class OmniDuplexSessionHandler(
extra_body_payload = normalize_native_duplex_key(dict(extra_body_payload))
payload = {**payload, "extra_body": extra_body_payload}
if isinstance(extra_body_payload, dict) and NATIVE_DUPLEX_KEY in extra_body_payload:
requested_native_duplex = extra_body_payload[NATIVE_DUPLEX_KEY]
if not isinstance(requested_native_duplex, bool):
return {
"type": "error",
"session_id": session.session_id,
"code": "invalid_request_error",
"error": "extra_body.native_duplex must be a boolean",
}
current_native_duplex = native_duplex_opt_in(session.config.extra_body)
if current_native_duplex is not None and extra_body_payload[NATIVE_DUPLEX_KEY] != current_native_duplex:
return {
@@ -1498,8 +1662,69 @@ class OmniDuplexSessionHandler(
session.config.extra_body["realtime_session_payload"] = (
NativeRealtimeSessionProtocol._json_safe_realtime_payload(payload)
)
try:
turn_detection_configured, server_vad = parse_session_turn_detection(payload)
if turn_detection_configured:
vad_config = replace(session.config, server_vad=server_vad)
self._resolve_server_vad_defaults(session, vad_config, payload)
server_vad = vad_config.server_vad
if (
not self._uses_native_input_append(session)
and session.turn_detection_config_locked
and (not session.config.turn_detection_configured or server_vad != session.config.server_vad)
):
return {
"type": "error",
"session_id": session.session_id,
"code": "unsupported_turn_detection",
"error": "turn_detection cannot be changed after the first audio append",
}
session.config.turn_detection_configured = True
session.config.server_vad = server_vad
session.config.overlap_policy = vad_config.overlap_policy
except ValueError as exc:
return {
"type": "error",
"session_id": session.session_id,
"code": "invalid_request_error",
"error": str(exc),
}
return None
async def _configure_server_vad(self, session: DuplexSession) -> None:
pipeline = await self._prepare_server_vad_pipeline(session, session.config)
self._install_server_vad_pipeline(session, pipeline)
def _install_server_vad_pipeline(
self,
session: DuplexSession,
pipeline: ServerVADPipeline | None,
) -> None:
existing = self._server_vad_pipelines.pop(session.session_id, None)
if existing is not None:
existing.reset()
self._realtime_vad_metrics.session_finished()
if pipeline is None:
return
self._server_vad_pipelines[session.session_id] = pipeline
self._realtime_vad_metrics.session_started()
async def _prepare_server_vad_pipeline(
self,
session: DuplexSession,
config: DuplexSessionConfig,
) -> ServerVADPipeline | None:
if config.server_vad is None:
return None
backend = await asyncio.to_thread(self._server_vad_backend_provider.get)
pipeline_config = config.server_vad
if self._uses_native_input_append(session) and pipeline_config.min_speech_duration_ms is None:
pipeline_config = replace(
pipeline_config,
min_speech_duration_ms=SILERO_VAD_DEFAULT_MIN_SPEECH_DURATION_MS,
)
return ServerVADPipeline(backend, pipeline_config)
def _apply_response_create_options(
self,
session: DuplexSession,
@@ -1580,7 +1805,7 @@ class OmniDuplexSessionHandler(
response_extra.update(
(key, value)
for key, value in extra_body.items()
if key not in self._serving_runtime_adapter.private_runtime_config_keys
if key not in self._require_serving_runtime_adapter().private_runtime_config_keys
)
else:
response_extra.update(extra_body)
@@ -1841,7 +2066,8 @@ class OmniDuplexSessionHandler(
# Epoch-scoped request ids prevent state reuse, but explicitly
# release projector/parser cursors so cancelled epochs do not
# accumulate until the whole session closes.
self._serving_runtime_adapter.data_plane.close_stream(old_request_id)
if self._serving_runtime_adapter is not None:
self._serving_runtime_adapter.data_plane.close_stream(old_request_id)
await self._abort_request_background(
session,
old_request_id,
+462 -52
View File
@@ -4,17 +4,23 @@
from __future__ import annotations
import asyncio
import binascii
import json
import uuid
from contextlib import suppress
from copy import deepcopy
import numpy as np
import pybase64 as base64
from fastapi import WebSocket, WebSocketDisconnect
from vllm.logger import init_logger
from vllm_omni.engine.duplex.lease import DuplexLeaseActivity
from vllm_omni.engine.duplex.messages import DuplexFence
from vllm_omni.entrypoints.duplex.audio import convert_input_audio_with_rate
from vllm_omni.entrypoints.duplex.audio import (
convert_input_audio_with_rate,
validate_input_sample_rate_hz,
)
from vllm_omni.entrypoints.duplex.commit_policy import (
CommitAction,
CommitSnapshot,
@@ -36,6 +42,7 @@ from vllm_omni.entrypoints.duplex.runtime_adapter import (
ServingRuntimeSessionState,
payload_turn_id,
)
from vllm_omni.entrypoints.duplex.server_vad import ServerVADPipeline
from vllm_omni.entrypoints.duplex.session_attachment import (
DuplexJournalOverflowError,
)
@@ -138,7 +145,7 @@ class DuplexSessionRunnerMixin:
await actor.send_json(raw_payload)
realtime_protocol.bind_sender(send_realtime_raw)
native: ServingRuntimeSessionState = self._serving_runtime_adapter.create_session_state()
native: ServingRuntimeSessionState = self._create_runtime_session_state()
def begin_close(reason: str) -> None:
actor.closing = True
@@ -291,6 +298,120 @@ class DuplexSessionRunnerMixin:
return True
return False
def start_chat_response() -> None:
assert session is not None
async def run_and_notify() -> None:
task = asyncio.current_task()
try:
await self._run_response(session, emit_event)
finally:
await actor.enqueue_event(
{
"type": "__chat_response_finished__",
"_response_task": task,
}
)
actor.active_response_task = asyncio.create_task(run_and_notify())
def activate_waiting_server_vad_turn() -> None:
assert session is not None
pending_turn = session.activate_server_vad_turn()
if pending_turn is not None and pending_turn.create_response:
start_chat_response()
def correlate_realtime_error(
payload: dict[str, object],
event_id: object,
) -> dict[str, object]:
if isinstance(event_id, str) and event_id:
payload["event_id"] = event_id
return payload
async def reject_and_clear_server_vad_input(
pipeline: ServerVADPipeline,
*,
code: str,
error: str,
metric_reason: str,
event_id: object,
) -> None:
assert session is not None
session.cancel_pending_input()
pipeline.reset()
self._realtime_vad_metrics.error(metric_reason)
await emit_event(
correlate_realtime_error(
{
"type": "error",
"session_id": session.session_id,
"code": code,
"error": error,
},
event_id,
)
)
await emit_event(
{
"type": "input_audio_buffer.cleared",
"session_id": session.session_id,
}
)
async def commit_server_vad_turn(
*,
item_id: str,
create_response: bool,
retain_input_bytes: int,
event_id: object = None,
) -> None:
assert session is not None
# Retain ownership until the actor consumes the terminal event so
# queued audio cannot overtake pending-turn activation.
active_response = actor.active_response_task is not None
if active_response and session.pending_server_vad_turn is not None:
released = session.discard_uncommitted_server_vad_utterance()
session.release_input_bytes(released)
self._realtime_vad_metrics.error("pending_turn_backpressure")
await emit_event(
correlate_realtime_error(
{
"type": "error",
"session_id": session.session_id,
"code": "input_backpressure",
"error": "Only one committed Server VAD turn may wait for the active response",
},
event_id,
)
)
return
if not session.stage_server_vad_audio_for_commit():
return
committed = session.commit_user_input(
append_history=not active_response,
retain_input_bytes=retain_input_bytes,
)
if committed is None:
return
if active_response:
session.stage_server_vad_turn(
committed,
create_response=create_response,
item_id=item_id,
)
else:
session.register_history_item(item_id, committed.message)
await emit_event(
self._input_committed_payload(
session,
committed,
item_id=item_id,
)
)
if create_response and not active_response:
start_chat_response()
def clear_completed_pending_silence() -> None:
task = native.pending_silence_task
if task is not None and task.done():
@@ -679,11 +800,18 @@ class DuplexSessionRunnerMixin:
if handshake is None:
return
session = handshake.session
if initial_update_error := self._runtime_session_candidate_update_error(session, session.config):
await emit_event(initial_update_error)
if initial_update_error := self._runtime_session_candidate_update_error(
session, session.config, realtime_protocol=realtime_protocol
):
await emit_event(
correlate_realtime_error(
initial_update_error,
handshake.event_id,
)
)
return
if handshake.resumed:
native = self._serving_runtime_adapter.session_states[session.session_id]
native = self._serving_session_states[session.session_id]
actor.tasks = self._session_tasks[session.session_id]
persisted_protocol = self._realtime_protocols.get(session.session_id)
if persisted_protocol is None:
@@ -697,18 +825,36 @@ class DuplexSessionRunnerMixin:
self._ensure_lifecycle_listener()
reader_task = asyncio.create_task(read_event_loop(), name="duplex-session-reader")
else:
self._serving_runtime_adapter.session_states[session.session_id] = native
self._serving_session_states[session.session_id] = native
self._session_tasks[session.session_id] = actor.tasks
if realtime_protocol is not None:
self._realtime_protocols[session.session_id] = realtime_protocol
native.silence_continuation_scheduler = schedule_native_silence_continuation
if realtime_protocol is not None:
realtime_protocol.bind_native_input_append(self._uses_native_input_append(session))
session.config.playback_commit_policy = DuplexPlaybackCommitPolicy.ACK_ONLY.value
if not handshake.resumed:
open_result = await self._open_runtime_session(session, emit_event)
if open_result is False:
return
runtime_opened = True
try:
await self._configure_server_vad(session)
except Exception as exc:
logger.exception("Server VAD initialization failed: %s", exc)
self._realtime_vad_metrics.error("initialization")
await emit_event(
correlate_realtime_error(
{
"type": "error",
"session_id": session.session_id,
"code": "server_vad_initialization_failed",
"error": str(exc),
},
handshake.event_id,
)
)
return
created_attachment = await self._attachment_registry.create(
session.session_id,
incarnation=session.incarnation,
@@ -734,7 +880,7 @@ class DuplexSessionRunnerMixin:
if isinstance(open_result, dict):
created_payload["runtime_control"] = self._redact_runtime_control_result(open_result)
if realtime_protocol is not None:
realtime_protocol.commit_realtime_turn_detection_update()
realtime_protocol.commit_realtime_turn_detection_update(session.config.server_vad)
await emit_event(created_payload)
resume_credential_delivered = session.capabilities.supports_session_resume
reader_task = asyncio.create_task(read_event_loop(), name="duplex-session-reader")
@@ -746,6 +892,14 @@ class DuplexSessionRunnerMixin:
if event_type == "__replaced_attachment__":
return
if event_type == "__chat_response_finished__":
completed_task = event.get("_response_task")
if completed_task is not actor.active_response_task:
continue
actor.active_response_task = None
activate_waiting_server_vad_turn()
continue
if event_type == "__timeout__":
begin_close("timeout")
native.audio_buffer.clear()
@@ -896,6 +1050,9 @@ class DuplexSessionRunnerMixin:
if event_type == "input_audio_buffer.clear":
native.audio_buffer.clear()
session.release_all_input_bytes()
server_vad_pipeline = self._server_vad_pipelines.get(session.session_id)
if server_vad_pipeline is not None:
server_vad_pipeline.reset()
native.input_since_commit = False
native.speech_since_commit = False
native.clear_committed_audio()
@@ -932,10 +1089,8 @@ class DuplexSessionRunnerMixin:
if event_type == "response.cancel":
requested_response_id = event.get("response_id")
has_active_response_work = native_response_in_progress()
if (
isinstance(requested_response_id, str)
and session.active_response_id is not None
and requested_response_id != session.active_response_id
if isinstance(requested_response_id, str) and (
session.active_response_id is None or requested_response_id != session.active_response_id
):
await emit_event(
{
@@ -946,6 +1101,7 @@ class DuplexSessionRunnerMixin:
}
)
continue
if event_type == "response.cancel":
if not has_active_response_work:
if realtime_protocol is not None and isinstance(requested_response_id, str):
continue
@@ -1056,6 +1212,7 @@ class DuplexSessionRunnerMixin:
}
)
actor.active_response_task = None
activate_waiting_server_vad_turn()
continue
if not cancelled:
await self._cancel_pending_input(session, emit_event, reason="barge_in")
@@ -1077,6 +1234,7 @@ class DuplexSessionRunnerMixin:
):
continue
actor.active_response_task = None
activate_waiting_server_vad_turn()
continue
if event_type == "turn.signal":
@@ -1119,6 +1277,27 @@ class DuplexSessionRunnerMixin:
)
reject_update()
continue
if (
realtime_protocol is not None
and not self._uses_native_input_append(session)
and session.config.server_vad is not None
and session.turn_detection_config_locked
and realtime_protocol.pending_realtime_input_audio_config_changed()
):
await emit_update_event(
{
"type": "error",
"session_id": session.session_id,
"code": "unsupported_audio_format",
"error": (
"session.update cannot change Server VAD input format or sample rate "
"after the first audio append"
),
"param": "audio.input.format",
}
)
reject_update()
continue
runtime_update_error = self._runtime_session_update_error(session, payload)
if runtime_update_error is not None:
await emit_update_event(runtime_update_error)
@@ -1131,6 +1310,7 @@ class DuplexSessionRunnerMixin:
update_error = self._apply_session_update(session, payload)
finally:
session.replace_config(previous_config)
server_vad_changed = candidate_config.server_vad != previous_config.server_vad
if update_error is not None:
await emit_update_event(update_error)
reject_update()
@@ -1138,6 +1318,7 @@ class DuplexSessionRunnerMixin:
runtime_update_error = self._runtime_session_candidate_update_error(
session,
candidate_config,
realtime_protocol=realtime_protocol,
)
if runtime_update_error is not None:
await emit_update_event(runtime_update_error)
@@ -1159,19 +1340,46 @@ class DuplexSessionRunnerMixin:
)
reject_update()
continue
if not await self._signal_runtime_session(
candidate_server_vad_pipeline = None
if server_vad_changed:
try:
candidate_server_vad_pipeline = await self._prepare_server_vad_pipeline(
session,
candidate_config,
)
except Exception as exc:
logger.exception("Server VAD session update failed: %s", exc)
self._realtime_vad_metrics.error("initialization")
await emit_update_event(
{
"type": "error",
"session_id": session.session_id,
"code": "server_vad_initialization_failed",
"error": str(exc),
}
)
reject_update()
continue
runtime_updated = await self._signal_runtime_session(
session,
turn_event,
emit_update_event,
session_config=candidate_config.as_dict(),
runtime_config=candidate_runtime_config,
):
)
if not runtime_updated:
reject_update()
continue
session.replace_config(candidate_config)
session.replace_runtime_config(candidate_runtime_config)
if server_vad_changed:
self._install_server_vad_pipeline(
session,
candidate_server_vad_pipeline,
)
if realtime_protocol is not None:
realtime_protocol.commit_realtime_turn_detection_update()
realtime_protocol.bind_native_input_append(self._uses_native_input_append(session))
realtime_protocol.commit_realtime_turn_detection_update(session.config.server_vad)
await emit_update_event(
{
"type": "session.updated",
@@ -1229,7 +1437,10 @@ class DuplexSessionRunnerMixin:
expected_model_turn_id=session.turn_id,
)
continue
message = self._realtime_item_to_history_message(item)
history_item = payload.get("history_item") if isinstance(payload, dict) else None
if not isinstance(history_item, dict):
history_item = item
message = self._realtime_item_to_history_message(history_item)
item_id = item.get("id") if isinstance(item, dict) else None
if message is not None:
session.append_history_message(message)
@@ -1346,24 +1557,30 @@ class DuplexSessionRunnerMixin:
default_sample_rate_hz = 16000
sr_raw = event.get("sample_rate_hz") or event.get("sample_rate")
sample_rate_hz = sr_raw if isinstance(sr_raw, int | float) else default_sample_rate_hz
try:
audio, fmt, sample_rate_hz = convert_input_audio_with_rate(
audio,
fmt,
sample_rate_hz=sample_rate_hz,
)
except ValueError as exc:
await emit_event({"type": "error", "error": str(exc), "code": "bad_event"})
continue
if isinstance(fmt, str) and fmt.lower() in {"pcm16", "pcm_s16le", "s16le"}:
await emit_event(
{
"type": "error",
"error": "input_audio_buffer.append pcm16 audio could not be decoded",
"code": "bad_audio",
}
)
continue
native_input = self._uses_native_input_append(session)
server_vad_config = session.config.server_vad
turn_based_server_vad = server_vad_config is not None and not native_input
if not turn_based_server_vad:
try:
audio, fmt, sample_rate_hz = convert_input_audio_with_rate(
audio,
fmt,
sample_rate_hz=sample_rate_hz,
)
except ValueError as exc:
await emit_event({"type": "error", "error": str(exc), "code": "bad_event"})
continue
if isinstance(fmt, str) and fmt.lower() in {"pcm16", "pcm_s16le", "s16le"}:
await emit_event(
{
"type": "error",
"error": "input_audio_buffer.append pcm16 audio could not be decoded",
"code": "bad_audio",
}
)
continue
if server_vad_config is None:
session.lock_turn_detection_config()
force_listen = bool(event.get("force_listen", False))
payload = {
"type": "audio",
@@ -1377,11 +1594,186 @@ class DuplexSessionRunnerMixin:
frames = [frame for frame in video_frames if isinstance(frame, str) and frame]
if frames:
payload["video_frames"] = frames
if server_vad_config is not None:
realtime_event_id = event.get("_realtime_event_id")
pipeline = self._server_vad_pipelines.get(session.session_id)
if pipeline is None:
await emit_event(
correlate_realtime_error(
{
"type": "error",
"session_id": session.session_id,
"code": "internal_error",
"error": "server_vad is configured but its detector pipeline is unavailable",
},
realtime_event_id,
)
)
continue
speech_was_active = pipeline.speech_active
reserved_bytes = 0
previous_scratch_bytes = pipeline.scratch_bytes
try:
if native_input:
if fmt != "pcm_f32le" or sample_rate_hz != pipeline.sample_rate_hz:
raise ValueError("native server_vad input must be mono PCM float32 at 16000 Hz")
raw_audio = base64.b64decode(audio, validate=True)
if len(raw_audio) % np.dtype("<f4").itemsize:
raise ValueError("native server_vad input contains an incomplete float32 sample")
normalized_audio = np.frombuffer(raw_audio, dtype="<f4")
accepted_audio = normalized_audio.size > 0
else:
if fmt != "pcm16":
raise ValueError("server_vad input must be mono PCM16")
source_sample_rate_hz = validate_input_sample_rate_hz(sample_rate_hz)
if source_sample_rate_hz not in {16_000, 24_000}:
raise ValueError("server_vad PCM16 input sample rate must be 16000 or 24000 Hz")
if (
pipeline.source_sample_rate_hz is not None
and source_sample_rate_hz != pipeline.source_sample_rate_hz
):
raise ValueError(
"server_vad input sample rate cannot change within a continuous audio stream"
)
try:
raw_audio = base64.b64decode(audio, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError(
"server_vad input audio must be valid base64-encoded PCM16"
) from exc
if len(raw_audio) % np.dtype("<i2").itemsize:
raise ValueError("server_vad PCM16 input contains an incomplete sample")
source_samples = len(raw_audio) // np.dtype("<i2").itemsize
accepted_audio = source_samples > 0
except (binascii.Error, ValueError) as exc:
await emit_event(
correlate_realtime_error(
{
"type": "error",
"session_id": session.session_id,
"code": "bad_audio",
"error": str(exc),
"param": "audio",
},
realtime_event_id,
)
)
continue
if not native_input:
normalized_sample_upper_bound = (
source_samples
if source_sample_rate_hz == pipeline.sample_rate_hz
else ((source_samples + 2) // 3) * 2
)
reserved_bytes = normalized_sample_upper_bound * np.dtype(np.float32).itemsize
if not session.reserve_input_bytes(
reserved_bytes,
limit=self._duplex_session_config.max_pending_input_bytes_per_session,
):
await reject_and_clear_server_vad_input(
pipeline,
code="input_backpressure",
error="Server VAD input exceeds the per-session input limit",
metric_reason="input_backpressure",
event_id=realtime_event_id,
)
continue
try:
vad_batch = (
await pipeline.push(normalized_audio)
if native_input
else await pipeline.push_pcm16(
raw_audio,
source_sample_rate_hz=source_sample_rate_hz,
)
)
except Exception as exc:
logger.exception("Server VAD inference failed: %s", exc)
await reject_and_clear_server_vad_input(
pipeline,
code="server_vad_inference_failed",
error=str(exc),
metric_reason="inference",
event_id=realtime_event_id,
)
continue
if not native_input:
retained_delta = (
sum(frame.samples.nbytes for frame in vad_batch.frames)
+ pipeline.scratch_bytes
- previous_scratch_bytes
)
session.release_input_bytes(max(0, reserved_bytes - retained_delta))
self._realtime_vad_metrics.observe_inference(vad_batch.inference_ms)
prefix_samples = round(server_vad_config.prefix_padding_ms * pipeline.sample_rate_hz / 1000)
speech_started = False
speech_stopped = False
for frame_index, vad_frame in enumerate(vad_batch.frames):
decision = vad_frame.decision
if not native_input:
released_bytes = session.append_server_vad_frame(
vad_frame.samples,
speech_started=decision.speech_started,
speech_stopped=decision.speech_stopped,
prefix_samples=prefix_samples,
)
session.release_input_bytes(released_bytes)
if decision.speech_started:
speech_started = True
item_id = f"item_{uuid.uuid4().hex}"
session.begin_server_vad_speech(item_id)
await emit_event(
{
"type": "input_audio_buffer.speech_started",
"session_id": session.session_id,
"item_id": item_id,
"audio_start_ms": decision.audio_start_ms or 0,
}
)
if decision.speech_stopped:
speech_stopped = True
item_id = session.finish_server_vad_speech() or f"item_{uuid.uuid4().hex}"
await emit_event(
{
"type": "input_audio_buffer.speech_stopped",
"session_id": session.session_id,
"item_id": item_id,
"audio_end_ms": decision.audio_end_ms or 0,
}
)
if decision.endpoint_delay_ms is not None:
self._realtime_vad_metrics.observe_endpoint_delay(decision.endpoint_delay_ms)
if not native_input:
remaining_frame_bytes = sum(
frame.samples.nbytes for frame in vad_batch.frames[frame_index + 1 :]
)
await commit_server_vad_turn(
item_id=item_id,
create_response=server_vad_config.create_response,
retain_input_bytes=remaining_frame_bytes + pipeline.scratch_bytes,
event_id=realtime_event_id,
)
if accepted_audio:
session.lock_turn_detection_config()
if not native_input:
continue
speech_active = pipeline.speech_active
contained_speech = speech_was_active or speech_started or speech_stopped or speech_active
payload["is_speech"] = contained_speech
payload["vad"] = {
"backend": "silero",
"is_speech": contained_speech,
"speech_active": speech_active,
"speech_started": speech_started,
"speech_stopped": speech_stopped,
}
if speech_active:
payload["force_listen"] = True
# Speech/silence tag for the Stage0 turn-ended latch.
payload["is_speech"] = self._input_looks_like_speech(event, payload, session=session)
defer_native_append = False
buffer_overlap_audio = True
if self._uses_native_input_append(session):
if native_input:
mark_pending_silence_superseded()
overlap_active = native_response_in_progress() and (
not self._session_auto_responds(session)
@@ -1570,7 +1962,7 @@ class DuplexSessionRunnerMixin:
payload = pcm_reservation.payload
else:
session.append_audio(audio, fmt=fmt, sample_rate_hz=sample_rate_hz)
if self._uses_native_input_append(session):
if native_input:
await start_native_append(
payload,
final=False,
@@ -1582,11 +1974,23 @@ class DuplexSessionRunnerMixin:
continue
if event_type in {"input.commit", "input_audio_buffer.commit", "response.create"}:
realtime_item_id = event.get("realtime_item_id")
if (
event_type == "response.create"
and session.config.server_vad is not None
and actor.active_response_task is not None
):
await emit_event(
{
"type": "error",
"session_id": session.session_id,
"code": "response_already_active",
"error": "response.create must wait for the active response to complete",
}
)
continue
item_id = event.get("item_id")
realtime_validated_audio_commit = (
event_type == "input_audio_buffer.commit"
and isinstance(realtime_item_id, str)
and bool(realtime_item_id)
event_type == "input_audio_buffer.commit" and isinstance(item_id, str) and bool(item_id)
)
if (
self._uses_native_input_append(session)
@@ -1604,6 +2008,7 @@ class DuplexSessionRunnerMixin:
{
"type": "input.committed",
"session_id": session.session_id,
"item_id": item_id,
"turn_id": session.turn_id,
"epoch": session.epoch,
"empty": True,
@@ -1699,6 +2104,7 @@ class DuplexSessionRunnerMixin:
{
"type": "input.committed",
"session_id": session.session_id,
"item_id": item_id,
"turn_id": session.turn_id,
"epoch": session.epoch,
"empty": True,
@@ -1736,13 +2142,13 @@ class DuplexSessionRunnerMixin:
native.speech_since_commit = False
committed = self._commit_native_audio_input(
session,
realtime_item_id=event.get("realtime_item_id"),
item_id=event.get("item_id"),
transcript=event.get("transcript"),
)
committed_payload = self._native_audio_committed_payload(
session,
committed=committed,
realtime_item_id=event.get("realtime_item_id"),
item_id=event.get("item_id"),
transcript=event.get("transcript"),
)
committed_payload["overlap_deferred"] = True
@@ -1777,7 +2183,7 @@ class DuplexSessionRunnerMixin:
data_plane_turn_id = session.turn_id
committed = self._commit_native_audio_input(
session,
realtime_item_id=event.get("realtime_item_id"),
item_id=event.get("item_id"),
transcript=event.get("transcript"),
turn_id=data_plane_turn_id,
)
@@ -1785,7 +2191,7 @@ class DuplexSessionRunnerMixin:
self._native_audio_committed_payload(
session,
committed=committed,
realtime_item_id=event.get("realtime_item_id"),
item_id=event.get("item_id"),
transcript=event.get("transcript"),
)
)
@@ -1883,14 +2289,14 @@ class DuplexSessionRunnerMixin:
native.input_since_commit = False
committed = self._commit_native_audio_input(
session,
realtime_item_id=event.get("realtime_item_id"),
item_id=event.get("item_id"),
transcript=event.get("transcript"),
)
await emit_event(
self._native_audio_committed_payload(
session,
committed=committed,
realtime_item_id=event.get("realtime_item_id"),
item_id=event.get("item_id"),
transcript=event.get("transcript"),
)
)
@@ -1949,7 +2355,7 @@ class DuplexSessionRunnerMixin:
committed = (
self._commit_native_audio_input(
session,
realtime_item_id=event.get("realtime_item_id"),
item_id=event.get("item_id"),
transcript=event.get("transcript"),
)
if native_had_uncommitted_audio
@@ -1959,7 +2365,7 @@ class DuplexSessionRunnerMixin:
self._native_audio_committed_payload(
session,
committed=committed,
realtime_item_id=event.get("realtime_item_id"),
item_id=event.get("item_id"),
transcript=event.get("transcript"),
)
)
@@ -1968,19 +2374,20 @@ class DuplexSessionRunnerMixin:
{
"type": "input.committed",
"session_id": session.session_id,
"item_id": item_id,
"empty": True,
}
)
continue
if committed is not None:
realtime_item_id = event.get("realtime_item_id")
if isinstance(realtime_item_id, str):
session.register_history_item(realtime_item_id, committed.message)
item_id = event.get("item_id")
if isinstance(item_id, str):
session.register_history_item(item_id, committed.message)
await emit_event(
self._input_committed_payload(
session,
committed,
realtime_item_id=realtime_item_id,
item_id=item_id,
)
)
if not should_create_response:
@@ -1992,7 +2399,10 @@ class DuplexSessionRunnerMixin:
emit_event,
reason="new_response",
)
actor.active_response_task = asyncio.create_task(self._run_response(session, emit_event))
if session.config.server_vad is not None and not self._uses_native_input_append(session):
start_chat_response()
else:
actor.active_response_task = asyncio.create_task(self._run_response(session, emit_event))
continue
await emit_event(
-181
View File
@@ -1,181 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import binascii
from collections.abc import Callable
from dataclasses import dataclass
import numpy as np
import pybase64 as base64
SILERO_VAD_MIN_THRESHOLD = 0.15
class ServerVADUnavailableError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class SileroVADConfig:
threshold: float = 0.5
prefix_padding_ms: int = 300
silence_duration_ms: int = 500
min_speech_duration_ms: int = 96
@dataclass(frozen=True, slots=True)
class StreamingVADResult:
is_speech: bool
speech_active: bool
speech_started: bool = False
speech_stopped: bool = False
speech_probability: float = 0.0
speech_start_ms: int | None = None
speech_end_ms: int | None = None
class SileroStreamingVAD:
_SAMPLE_RATE_HZ = 16_000
_WINDOW_SAMPLES = 512
def __init__(
self,
config: SileroVADConfig,
*,
frame_scorer: Callable[[np.ndarray], float] | None = None,
) -> None:
self.config = config
self._frame_scorer = frame_scorer
self._model: object | None = None
self._clear_stream_state()
def _clear_stream_state(self) -> None:
self._pending = np.empty(0, dtype=np.float32)
self._speech_active = False
self._candidate_samples = 0
self._candidate_start_sample: int | None = None
self._silence_samples = 0
self._processed_samples = 0
self._resample_rate_hz: int | None = None
self._resample_remainder = 0
def reset(self) -> None:
self._clear_stream_state()
reset_states = getattr(self._model, "reset_states", None)
if callable(reset_states):
reset_states()
def process_base64(
self,
audio: object,
*,
fmt: object,
sample_rate_hz: object,
) -> StreamingVADResult:
if fmt != "pcm_f32le" or not isinstance(audio, str):
raise ValueError("Silero server VAD requires decoded pcm_f32le audio")
try:
raw = base64.b64decode(audio, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError("Silero server VAD received invalid base64 audio") from exc
if len(raw) % 4:
raise ValueError("Silero server VAD received an incomplete pcm_f32le frame")
samples = np.frombuffer(raw, dtype="<f4").astype(np.float32, copy=False)
rate = int(sample_rate_hz) if isinstance(sample_rate_hz, int | float) else self._SAMPLE_RATE_HZ
if rate <= 0:
raise ValueError("Silero server VAD requires a positive sample rate")
if rate != self._SAMPLE_RATE_HZ:
if self._resample_rate_hz != rate:
self._resample_rate_hz = rate
self._resample_remainder = 0
numerator = samples.size * self._SAMPLE_RATE_HZ + self._resample_remainder
target_size, self._resample_remainder = divmod(numerator, rate)
if target_size == 0:
samples = np.empty(0, dtype=np.float32)
elif samples.size == 1:
samples = np.full(target_size, samples[0], dtype=np.float32)
else:
source_x = np.linspace(0.0, 1.0, num=samples.size, endpoint=True)
target_x = np.linspace(0.0, 1.0, num=target_size, endpoint=True)
samples = np.interp(target_x, source_x, samples).astype(np.float32)
else:
self._resample_rate_hz = None
self._resample_remainder = 0
return self.process(samples)
def process(self, samples: np.ndarray) -> StreamingVADResult:
samples = np.asarray(samples, dtype=np.float32).reshape(-1)
if samples.size:
self._pending = np.concatenate((self._pending, samples))
started = False
stopped = False
start_ms: int | None = None
end_ms: int | None = None
max_probability = 0.0
contained_speech = self._speech_active
min_speech_samples = self.config.min_speech_duration_ms * 16
min_silence_samples = self.config.silence_duration_ms * 16
negative_threshold = max(0.0, self.config.threshold - SILERO_VAD_MIN_THRESHOLD)
while self._pending.size >= self._WINDOW_SAMPLES:
frame = self._pending[: self._WINDOW_SAMPLES]
self._pending = self._pending[self._WINDOW_SAMPLES :]
frame_start = self._processed_samples
self._processed_samples += self._WINDOW_SAMPLES
probability = min(1.0, max(0.0, float(self._score_frame(frame))))
max_probability = max(max_probability, probability)
if self._speech_active:
contained_speech = True
if probability < negative_threshold:
self._silence_samples += self._WINDOW_SAMPLES
if self._silence_samples >= min_silence_samples:
speech_end_sample = self._processed_samples - self._silence_samples
self._speech_active = False
self._silence_samples = 0
stopped = True
end_ms = max(0, int(round(speech_end_sample / 16)))
else:
self._silence_samples = 0
continue
if probability >= self.config.threshold:
if self._candidate_samples == 0:
self._candidate_start_sample = frame_start
self._candidate_samples += self._WINDOW_SAMPLES
if self._candidate_samples >= min_speech_samples:
candidate_start = self._candidate_start_sample or 0
prefix_samples = self.config.prefix_padding_ms * 16
self._speech_active = True
self._candidate_samples = self._silence_samples = 0
self._candidate_start_sample = None
contained_speech = True
started = True
start_ms = max(0, int(round((candidate_start - prefix_samples) / 16)))
else:
self._candidate_samples = 0
self._candidate_start_sample = None
return StreamingVADResult(
contained_speech, self._speech_active, started, stopped, max_probability, start_ms, end_ms
)
def _score_frame(self, frame: np.ndarray) -> float:
if self._frame_scorer is not None:
return float(self._frame_scorer(frame))
if self._model is None:
try:
import torch
from silero_vad import load_silero_vad
except ImportError as exc:
raise ServerVADUnavailableError(
"server_vad requires the optional 'silero-vad' package; install vllm-omni[server-vad]"
) from exc
self._model = load_silero_vad(onnx=False)
self._frame_scorer = lambda value: float(
self._model(torch.from_numpy(np.ascontiguousarray(value)), self._SAMPLE_RATE_HZ).item()
)
return float(self._frame_scorer(frame))
+5 -3
View File
@@ -1290,10 +1290,12 @@ async def omni_init_app_state(
state.openai_serving_duplex = None
if state.openai_serving_chat is not None and should_enable_duplex_endpoint(
state.stage_configs,
config_path=getattr(args, "deploy_config", None),
config_path=getattr(engine_client, "config_path", None) or getattr(args, "deploy_config", None),
):
state.openai_serving_duplex = OmniDuplexSessionHandler(
chat_service=state.openai_serving_chat,
served_model_name=model_name,
log_stats=state.log_stats,
duplex_session_config=getattr(engine_client, "duplex_session_config", None),
serving_runtime_adapter_path=getattr(engine_client, "duplex_serving_adapter_path", None),
)
@@ -1850,8 +1852,8 @@ async def realtime_websocket(websocket: WebSocket):
logger.warning("Duplex warmup still running after 120 s; admitting the client anyway.")
duplex_handler = getattr(websocket.app.state, "openai_serving_duplex", None)
duplex_query = websocket.query_params.get("duplex")
use_duplex_realtime = (
duplex_handler is not None and isinstance(duplex_query, str) and duplex_query.lower() in {"1", "true", "on"}
use_duplex_realtime = duplex_handler is not None and (
duplex_query is None or (isinstance(duplex_query, str) and duplex_query.lower() in {"1", "true", "on"})
)
if use_duplex_realtime and duplex_handler is not None:
await duplex_handler.handle_realtime_session(websocket)
+131 -63
View File
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import math
from io import BytesIO
import numpy as np
@@ -19,85 +20,152 @@ logger = init_logger(__name__)
class StreamingAudioResampler:
"""Stateful resampler for streaming mono audio.
"""Stateful polyphase resampler for streaming mono float audio.
Only integer downsampling ratios are supported so every input chunk can
produce output without buffering the complete stream.
Retains filter state so output is invariant to input chunk boundaries.
"""
_half_filter_width = 10
_kaiser_beta = 5.0
_max_polyphase_factor = 2_048
def __init__(self, source_rate: int, target_rate: int):
if source_rate <= 0 or target_rate <= 0:
raise ValueError("Audio sample rates must be positive")
self.source_rate = source_rate
self.target_rate = target_rate
if source_rate < target_rate or source_rate % target_rate != 0:
raise ValueError(
"Streaming audio resampling requires an integer downsampling ratio, "
f"got {source_rate} Hz to {target_rate} Hz"
rate_gcd = math.gcd(source_rate, target_rate)
self._up = target_rate // rate_gcd
self._down = source_rate // rate_gcd
if max(self._up, self._down) > self._max_polyphase_factor:
raise ValueError("source and target sample rates have an unsupported resampling ratio")
self._half_len, self._phase_kernels = self._design_polyphase_filter()
self._history_samples = self._phase_kernels.shape[1] - 1
self.reset()
def _design_polyphase_filter(self) -> tuple[int, np.ndarray]:
if self._up == self._down:
return 0, np.ones((1, 1), dtype=np.float32)
max_rate = max(self._up, self._down)
half_len = self._half_filter_width * max_rate
offsets = np.arange(-half_len, half_len + 1, dtype=np.float64)
cutoff = 1.0 / max_rate
taps = cutoff * np.sinc(cutoff * offsets)
taps *= np.kaiser(taps.size, self._kaiser_beta)
taps /= np.sum(taps)
taps *= self._up
taps = np.ascontiguousarray(taps, dtype=np.float32)
phases = [taps[phase :: self._up] for phase in range(self._up)]
phase_width = max(phase.size for phase in phases)
kernels = np.zeros((self._up, phase_width), dtype=np.float32)
for phase_index, phase in enumerate(phases):
kernels[phase_index, -phase.size :] = phase[::-1]
return half_len, kernels
@property
def scratch_bytes(self) -> int:
pending_samples = self._ceil_div(self._input_samples * self._up, self._down) - self._output_samples
return max(0, pending_samples) * np.dtype(np.float32).itemsize
@staticmethod
def _ceil_div(numerator: int, denominator: int) -> int:
return -(-numerator // denominator)
def _render_outputs(
self,
combined: np.ndarray,
*,
first_output: int,
output_count: int,
input_start: int,
) -> np.ndarray:
if output_count <= 0:
return np.empty(0, dtype=np.float32)
output_indexes = np.arange(first_output, first_output + output_count, dtype=np.int64)
filter_indexes = output_indexes * self._down + self._half_len
source_indexes = filter_indexes // self._up
phases = filter_indexes % self._up
windows = np.lib.stride_tricks.sliding_window_view(
combined,
self._phase_kernels.shape[1],
)
selected_windows = windows[source_indexes - input_start]
return np.sum(
selected_windows * self._phase_kernels[phases],
axis=1,
dtype=np.float32,
)
def _push(self, chunk: np.ndarray) -> np.ndarray:
if chunk.size == 0:
return np.empty(0, dtype=np.float32)
if self._flushed:
raise RuntimeError("cannot process audio after the resampler has been finalized; call reset first")
combined = np.concatenate((self._history, chunk))
next_input_samples = self._input_samples + chunk.size
stable_output_samples = max(
0,
self._ceil_div(next_input_samples * self._up - self._half_len, self._down),
)
output = self._render_outputs(
combined,
first_output=self._output_samples,
output_count=stable_output_samples - self._output_samples,
input_start=self._input_samples,
)
if self._history_samples:
self._history = combined[-self._history_samples :].copy()
self._input_samples = next_input_samples
self._output_samples = stable_output_samples
return output
def _flush(self) -> np.ndarray:
if self._flushed:
return np.empty(0, dtype=np.float32)
total_output_samples = self._ceil_div(self._input_samples * self._up, self._down)
output_count = total_output_samples - self._output_samples
if output_count:
last_output = total_output_samples - 1
last_source_index = (last_output * self._down + self._half_len) // self._up
right_padding = max(0, last_source_index - self._input_samples + 1)
combined = np.concatenate((self._history, np.zeros(right_padding, dtype=np.float32)))
output = self._render_outputs(
combined,
first_output=self._output_samples,
output_count=output_count,
input_start=self._input_samples,
)
self._ratio = source_rate // target_rate
self._buffer = np.empty((0,), dtype=np.float32)
self._buffer_start = 0
self._total_samples = 0
self._next_center = 0
if self._ratio == 1:
self._taps = np.ones((1,), dtype=np.float32)
else:
# Use a Kaiser-windowed sinc and leave a small transition band
# below the target Nyquist frequency.
num_taps = 20 * self._ratio + 1
half = num_taps // 2
offsets = np.arange(num_taps, dtype=np.float64) - half
cutoff = 0.95 / self._ratio
taps = cutoff * np.sinc(cutoff * offsets) * np.kaiser(num_taps, 5.0)
self._taps = (taps / taps.sum()).astype(np.float32)
output = np.empty(0, dtype=np.float32)
self._output_samples = total_output_samples
self._flushed = True
return output
def process(self, audio: np.ndarray, *, final: bool = False) -> np.ndarray:
chunk = np.asarray(audio, dtype=np.float32)
if chunk.ndim != 1:
raise ValueError(f"Streaming audio resampling only supports mono audio, got shape {chunk.shape}")
chunk = np.ascontiguousarray(chunk, dtype=np.float32)
output = self._push(chunk)
if not final:
return output
tail = self._flush()
if not output.size:
return tail
if not tail.size:
return output
return np.concatenate((output, tail))
if self._ratio == 1:
return chunk
if chunk.size:
self._buffer = np.concatenate((self._buffer, chunk))
self._total_samples += int(chunk.size)
half = self._taps.size // 2
max_center = self._total_samples - 1 if final else self._total_samples - 1 - half
if self._next_center > max_center:
return np.empty((0,), dtype=np.float32)
centers = np.arange(self._next_center, max_center + 1, self._ratio, dtype=np.int64)
first_center = int(centers[0])
last_center = int(centers[-1])
window_start = first_center - half
window_end = last_center + half
segment = np.zeros((window_end - window_start + 1,), dtype=np.float32)
copy_start = max(window_start, self._buffer_start, 0)
copy_end = min(window_end + 1, self._buffer_start + self._buffer.size)
if copy_end > copy_start:
dst_start = copy_start - window_start
src_start = copy_start - self._buffer_start
segment[dst_start : dst_start + copy_end - copy_start] = self._buffer[
src_start : src_start + copy_end - copy_start
]
windows = np.lib.stride_tricks.sliding_window_view(segment, self._taps.size)[:: self._ratio]
output = windows @ self._taps[::-1]
self._next_center = last_center + self._ratio
keep_from = max(0, self._next_center - half)
drop = min(max(keep_from - self._buffer_start, 0), self._buffer.size)
if drop:
self._buffer = self._buffer[drop:]
self._buffer_start += int(drop)
return np.asarray(output, dtype=np.float32)
def reset(self) -> None:
self._history = np.zeros(self._history_samples, dtype=np.float32)
self._input_samples = 0
self._output_samples = 0
self._flushed = False
class AudioMixin:
+6
View File
@@ -161,6 +161,12 @@ AUDIO_UNDERRUN_S = METRIC_PREFIX + AUDIO_UNDERRUN + "_s"
AUDIO_CONTINUITY_OK_METRIC = METRIC_PREFIX + AUDIO_CONTINUITY_OK
AUDIO_SKIPPED_REQUESTS_METRIC = METRIC_PREFIX + AUDIO_SKIPPED_REQUESTS
# Realtime Server VAD serving metrics.
REALTIME_VAD_ACTIVE_SESSIONS = METRIC_PREFIX + "realtime_vad_active_sessions"
REALTIME_VAD_INFERENCE_LATENCY_S = METRIC_PREFIX + "realtime_vad_inference_latency_s"
REALTIME_VAD_ENDPOINT_DELAY_S = METRIC_PREFIX + "realtime_vad_endpoint_delay_s"
REALTIME_VAD_ERRORS = METRIC_PREFIX + "realtime_vad_errors"
# ============================================================================
# Diffusion family (per-stage + per-replica diffusion timing breakdowns)
+60
View File
@@ -0,0 +1,60 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
from prometheus_client import Counter, Gauge, Histogram
from vllm_omni.metrics import definitions as defs
_active_sessions = Gauge(
defs.REALTIME_VAD_ACTIVE_SESSIONS,
"Number of active Realtime sessions using server-side VAD.",
labelnames=list(defs.PIPELINE_LABELS),
)
_inference_latency = Histogram(
defs.REALTIME_VAD_INFERENCE_LATENCY_S,
"Server VAD inference time for one appended audio batch, in seconds.",
labelnames=list(defs.PIPELINE_LABELS),
buckets=defs.SECONDS_FAST_BUCKETS,
)
_endpoint_delay = Histogram(
defs.REALTIME_VAD_ENDPOINT_DELAY_S,
"Detected silence between the last speech frame and the committed endpoint, in seconds.",
labelnames=list(defs.PIPELINE_LABELS),
buckets=defs.SECONDS_FAST_BUCKETS,
)
_errors = Counter(
defs.REALTIME_VAD_ERRORS,
"Server VAD errors and input-overflow rejections by reason.",
labelnames=list(defs.FAILED_LABELS),
)
class RealtimeVADMetrics:
def __init__(self, model_name: str, log_stats: bool = True) -> None:
self._model_name = model_name
self._log_stats = log_stats
self._active_sessions = _active_sessions.labels(model_name=model_name)
self._inference_latency = _inference_latency.labels(model_name=model_name)
self._endpoint_delay = _endpoint_delay.labels(model_name=model_name)
def session_started(self) -> None:
if self._log_stats:
self._active_sessions.inc()
def session_finished(self) -> None:
if self._log_stats:
self._active_sessions.dec()
def observe_inference(self, latency_ms: float) -> None:
if self._log_stats:
self._inference_latency.observe(latency_ms / 1000)
def observe_endpoint_delay(self, delay_ms: int) -> None:
if self._log_stats:
self._endpoint_delay.observe(delay_ms / 1000)
def error(self, reason: str) -> None:
if self._log_stats:
_errors.labels(model_name=self._model_name, reason=reason).inc()