diff --git a/docs/serving/realtime_duplex_api.md b/docs/serving/realtime_duplex_api.md
index c18b416c7..a20750090 100644
--- a/docs/serving/realtime_duplex_api.md
+++ b/docs/serving/realtime_duplex_api.md
@@ -768,7 +768,10 @@ events with `server_event_seq > 40` are replayed in order)
```
with camera frames (omni video; rides the append that closes a 1 s model
-unit; each entry is a **bare** base64 JPEG/PNG — no `data:` URL prefix)
+unit; each entry is a **bare** base64 JPEG/PNG — no `data:` URL prefix).
+Only a model whose Stage 0 interleaves a frame track at unit boundaries takes
+this field; a turn model has no such boundary and rejects it, expecting
+`conversation.item.create` with `input_image` content instead.
```json
{
diff --git a/examples/online_serving/minicpmo/README.md b/examples/online_serving/minicpmo/README.md
index 67b268a77..ffd50167f 100644
--- a/examples/online_serving/minicpmo/README.md
+++ b/examples/online_serving/minicpmo/README.md
@@ -316,3 +316,12 @@ them unless `--allow-invalid-clock` is explicit.
[`examples/offline_inference/minicpmo/`](../../offline_inference/minicpmo/)
- Recipe:
[`recipes/OpenBMB/MiniCPM-o-4_5.md`](../../../recipes/OpenBMB/MiniCPM-o-4_5.md)
+
+### Shared realtime UI implementation
+
+The `python -m examples.online_serving.minicpmo.realtime_web` command remains
+available with the same MiniCPM defaults and required `--ref-audio`. Its assets
+now live in [the shared realtime UI](../realtime_web/README.md), with a dedicated
+`minicpm-native` profile preserving native duplex input, playback ACKs and camera
+frames. Qwen3 uses a separate profile in the same shell, supporting manual STT
+turns or Server VAD turns with speech interruption.
diff --git a/examples/online_serving/minicpmo/realtime_web/server.py b/examples/online_serving/minicpmo/realtime_web/server.py
index ee78fff74..1ef12d186 100644
--- a/examples/online_serving/minicpmo/realtime_web/server.py
+++ b/examples/online_serving/minicpmo/realtime_web/server.py
@@ -1,185 +1,23 @@
-"""Static host and same-origin Realtime proxy for the full-duplex demo."""
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
-from __future__ import annotations
+"""Backward-compatible MiniCPM entry point for the shared realtime UI."""
-import argparse
-import asyncio
-import base64
-import contextlib
-import hashlib
-import json
-import logging
-from pathlib import Path
-from urllib.parse import urlencode
+from examples.online_serving.realtime_web.server import (
+ APP_DIR,
+ STATIC_DIR,
+ _join_ws_url,
+ build_app,
+)
+from examples.online_serving.realtime_web.server import (
+ main as _main,
+)
-import uvicorn
-import websockets
-from fastapi import FastAPI, WebSocket, WebSocketDisconnect
-from fastapi.responses import HTMLResponse, Response
-from fastapi.staticfiles import StaticFiles
-
-logger = logging.getLogger(__name__)
-APP_DIR = Path(__file__).parent / "app"
-STATIC_DIR = APP_DIR / "static"
-
-
-def _join_ws_url(base: str, path: str, query: str) -> str:
- return base.rstrip("/") + path + (("?" + query) if query else "")
-
-
-async def _pump_client_to_backend(client: WebSocket, backend) -> None:
- try:
- while True:
- message = await client.receive()
- if message["type"] == "websocket.disconnect":
- await backend.close()
- return
- if message.get("text") is not None:
- await backend.send(message["text"])
- elif message.get("bytes") is not None:
- await backend.send(message["bytes"])
- except (WebSocketDisconnect, websockets.ConnectionClosed):
- with contextlib.suppress(Exception):
- await backend.close()
-
-
-async def _pump_backend_to_client(client: WebSocket, backend) -> None:
- try:
- async for message in backend:
- if isinstance(message, bytes):
- await client.send_bytes(message)
- else:
- await client.send_text(message)
- except (WebSocketDisconnect, websockets.ConnectionClosed):
- return
- except RuntimeError as exc:
- if "websocket.send" in str(exc) and "websocket.close" in str(exc):
- return
- raise
-
-
-def _expected_proxy_close(exc: BaseException) -> bool:
- if isinstance(exc, (WebSocketDisconnect, websockets.ConnectionClosed, asyncio.CancelledError)):
- return True
- return isinstance(exc, RuntimeError) and "websocket.send" in str(exc) and "websocket.close" in str(exc)
-
-
-def build_app(
- *,
- ws_backend: str = "ws://127.0.0.1:8099",
- model: str = "openbmb/MiniCPM-o-4_5",
- public_realtime_url: str | None = None,
- ref_audio: str | None = None,
-) -> FastAPI:
- app = FastAPI(title="Experimental Full-Duplex Web Demo")
- index_path = APP_DIR / "index.html"
- app_version_hash = hashlib.sha256()
- for asset_path in (
- STATIC_DIR / "app.js",
- STATIC_DIR / "pcm_worklet.js",
- STATIC_DIR / "playback_worklet.js",
- ):
- app_version_hash.update(asset_path.read_bytes())
- app_version = app_version_hash.hexdigest()[:12]
-
- ref_audio_uri: str | None = None
- if ref_audio:
- ref_path = Path(ref_audio)
- if not ref_path.is_file():
- raise SystemExit(f"--ref-audio not found: {ref_audio}")
- encoded = base64.b64encode(ref_path.read_bytes()).decode("ascii")
- ref_audio_uri = f"data:audio/wav;base64,{encoded}"
-
- @app.get("/", response_class=HTMLResponse)
- def index() -> HTMLResponse:
- config = json.dumps(
- {
- "model": model,
- "realtimePath": public_realtime_url or "v1/realtime",
- "refAudio": ref_audio_uri,
- "appVersion": app_version,
- },
- ensure_ascii=True,
- )
- html = (
- index_path.read_text(encoding="utf-8")
- .replace("__FULL_DUPLEX_CONFIG__", config)
- .replace("__FULL_DUPLEX_APP_VERSION__", app_version)
- )
- return HTMLResponse(html, headers={"Cache-Control": "no-store"})
-
- @app.get("/healthz")
- def healthz() -> Response:
- return Response(content="ok", media_type="text/plain")
-
- @app.websocket("/v1/realtime")
- async def realtime_proxy(websocket: WebSocket) -> None:
- await websocket.accept()
- query = urlencode(websocket.query_params.multi_items())
- backend_url = _join_ws_url(ws_backend, "/v1/realtime", query)
- logger.info("Proxying Realtime WebSocket to %s", backend_url)
- try:
- async with websockets.connect(
- backend_url,
- max_size=64 * 1024 * 1024,
- ) as backend:
- tasks = {
- asyncio.create_task(_pump_client_to_backend(websocket, backend)),
- asyncio.create_task(_pump_backend_to_client(websocket, backend)),
- }
- done, pending = await asyncio.wait(
- tasks,
- return_when=asyncio.FIRST_COMPLETED,
- )
- for task in pending:
- task.cancel()
- await asyncio.gather(*pending, return_exceptions=True)
- for result in await asyncio.gather(*done, return_exceptions=True):
- if isinstance(result, BaseException) and not _expected_proxy_close(result):
- raise result
- except (WebSocketDisconnect, websockets.ConnectionClosed):
- return
- except Exception:
- logger.exception("Realtime WebSocket proxy failed")
- with contextlib.suppress(Exception):
- await websocket.close(code=1011)
-
- app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
- return app
+__all__ = ["APP_DIR", "STATIC_DIR", "_join_ws_url", "build_app", "main"]
def main() -> None:
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--host", default="0.0.0.0")
- parser.add_argument("--port", type=int, default=7862)
- parser.add_argument("--ws-backend", default="ws://127.0.0.1:8099")
- parser.add_argument(
- "--public-realtime-url",
- help="Browser-visible ws:// or wss:// Realtime URL; defaults to the same-origin proxy.",
- )
- parser.add_argument("--model", default="openbmb/MiniCPM-o-4_5")
- parser.add_argument(
- "--ref-audio",
- required=True,
- help=(
- "Reference voice wav for TTS voice cloning, e.g. the "
- "official MiniCPM-o-Demo assets/ref_audio/ref_minicpm_signature.wav"
- ),
- )
- args = parser.parse_args()
-
- logging.basicConfig(level=logging.INFO)
- uvicorn.run(
- build_app(
- ws_backend=args.ws_backend,
- model=args.model,
- public_realtime_url=args.public_realtime_url,
- ref_audio=args.ref_audio,
- ),
- host=args.host,
- port=args.port,
- log_level="info",
- )
+ _main(default_profile="minicpm-native")
if __name__ == "__main__":
diff --git a/examples/online_serving/qwen3_omni/README.md b/examples/online_serving/qwen3_omni/README.md
index 8d6014db2..5eb9a2042 100644
--- a/examples/online_serving/qwen3_omni/README.md
+++ b/examples/online_serving/qwen3_omni/README.md
@@ -320,24 +320,17 @@ python openai_realtime_client.py \
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
+base_config: /path/to/vllm-omni/vllm_omni/deploy/qwen3_omni_duplex.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
-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.
+For the engine-owned plugin, use an overlay based on
+`vllm_omni/deploy/qwen3_omni_duplex.yaml`, with `duplex_session.server_vad_model_path`
+pointing to your local artifact. See the [complete duplex setup](../realtime_web/README.md#qwen3-with-server-vad-automatic-turns).
+The default `qwen3_omni_moe.yaml` remains a turn deployment. Duplex mode serves
+`/v1/realtime?duplex=1` and `/v1/chat/completions`; the legacy STT handler requires
+a turn deployment.
The Python client supports the following command-line arguments:
@@ -584,3 +577,53 @@ The gradio script supports the following arguments:
- `--ip`: Host/IP for Gradio server (default: 127.0.0.1)
- `--port`: Port for Gradio server (default: 7861)
- `--share`: Share the Gradio demo publicly (creates a public link)
+
+## Browser voice call (shared realtime UI)
+
+Run these commands from the repository root with the vLLM-Omni environment activated.
+
+### Without VAD: manually submit each turn
+
+Start the backend:
+
+```bash
+vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
+```
+
+In another terminal, check backend readiness and start the UI:
+
+```bash
+curl --fail http://127.0.0.1:8091/health
+python -m examples.online_serving.qwen3_omni.realtime_web \
+ --backend ws://127.0.0.1:8091 --stt --port 7863
+```
+
+Open `http://localhost:7863`, start a session, speak, and press **Send turn**.
+`--stt` is the default. Each turn uses a separate connection without shared model
+history; the conversation shown in the browser is a local log.
+
+### With VAD: automatically submit after silence
+
+Start the backend with a Server VAD deployment overlay and a compatible Silero
+ONNX artifact, following the
+[complete VAD setup](../realtime_web/README.md#qwen3-with-server-vad-automatic-turns).
+After its health check succeeds, start the UI:
+
+```bash
+python -m examples.online_serving.qwen3_omni.realtime_web \
+ --backend ws://127.0.0.1:8091 --vad --port 7863
+```
+
+Speak and pause for 500 ms to submit automatically. `--vad` configures the client;
+it does not load a VAD model or change the backend deployment by itself.
+
+VAD mode keeps uploading audio during replies and supports speech interruption.
+The engine-owned Qwen duplex plugin maintains conversation history and tracks
+played replies through playback acknowledgements. Each committed utterance starts
+a new generation request; the model does not use native streaming-input KV decoding.
+STT mode pauses microphone upload while the reply is generated and played.
+In VAD mode, click **Camera** to upload sampled frames with your spoken question;
+see [camera setup and limits](../realtime_web/README.md#qwen-vad-camera-input).
+STT remains audio-only. To switch back to manual turns, use the default turn
+deployment, restart the UI with `--stt`, refresh the page, and reconnect. Stop the existing UI before reusing port 7863.
+See the [shared UI guide](../realtime_web/README.md) for HTTPS access and testing.
diff --git a/examples/online_serving/qwen3_omni/openai_realtime_client.py b/examples/online_serving/qwen3_omni/openai_realtime_client.py
index 0a98d69a0..2a6bca0fe 100644
--- a/examples/online_serving/qwen3_omni/openai_realtime_client.py
+++ b/examples/online_serving/qwen3_omni/openai_realtime_client.py
@@ -130,7 +130,7 @@ async def run_client(
"prefix_padding_ms": 300,
"silence_duration_ms": 500,
"create_response": True,
- "interrupt_response": False,
+ "interrupt_response": True,
},
}
},
@@ -233,6 +233,7 @@ async def run_client(
continue
if event_type == "response.done" and server_vad:
+ await ws.send(json.dumps({"type": "session.close"}))
break
if event_type == "error":
diff --git a/examples/online_serving/qwen3_omni/realtime_web/__init__.py b/examples/online_serving/qwen3_omni/realtime_web/__init__.py
new file mode 100644
index 000000000..14c5daad2
--- /dev/null
+++ b/examples/online_serving/qwen3_omni/realtime_web/__init__.py
@@ -0,0 +1,4 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+
+"""Qwen3-Omni turn-based browser call UI."""
diff --git a/examples/online_serving/qwen3_omni/realtime_web/__main__.py b/examples/online_serving/qwen3_omni/realtime_web/__main__.py
new file mode 100644
index 000000000..698179528
--- /dev/null
+++ b/examples/online_serving/qwen3_omni/realtime_web/__main__.py
@@ -0,0 +1,7 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+
+from examples.online_serving.realtime_web.server import main
+
+if __name__ == "__main__":
+ main(default_profile="qwen3-turn")
diff --git a/examples/online_serving/realtime_web/README.md b/examples/online_serving/realtime_web/README.md
new file mode 100644
index 000000000..854aa4718
--- /dev/null
+++ b/examples/online_serving/realtime_web/README.md
@@ -0,0 +1,221 @@
+# Shared Realtime Web UI
+
+One browser shell provides microphone capture, speaker playback, a level meter,
+conversation text, and an event log. Model profiles own the wire protocol.
+This implements [RFC #7222](https://github.com/vllm-project/vllm-omni/issues/7222).
+
+| Profile | Turn control | Camera | Playback acknowledgement |
+| --- | --- | --- | --- |
+| `minicpm-native` | Model-controlled listen/speak, continuous audio input | Frames accompany audio | Yes |
+| `qwen3-turn --stt` (default) | User presses **Send turn** | No | No |
+| `qwen3-turn --vad` | Server detects trailing silence; speech interrupts replies | Sampled frames with each spoken turn | Yes |
+
+Qwen3 VAD uses an engine-owned duplex plugin: microphone upload continues
+while replies stream, and speech can interrupt generation and playback. The
+checkpoint still generates committed turns; this is not native streaming-input
+KV decoding. STT remains explicit-turn and audio-only.
+
+## MiniCPM: existing command stays valid
+
+Start your MiniCPM backend using the [existing deployment instructions](../minicpmo/README.md), then:
+
+```bash
+python -m examples.online_serving.minicpmo.realtime_web \
+ --ws-backend ws://127.0.0.1:8099 \
+ --ref-audio /path/to/ref_minicpm_signature.wav
+```
+
+The compatibility wrapper serves the shared assets with `minicpm-native`.
+The current native query, `extra_body`, reference voice, continuous microphone
+upload, camera frames and playback acknowledgements are retained.
+
+## Qwen3: explicit-turn STT adapter
+
+Start the backend, then the UI in a second terminal:
+
+```bash
+vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091
+
+python -m examples.online_serving.qwen3_omni.realtime_web \
+ --backend ws://127.0.0.1:8091 --stt --port 7863
+```
+
+Open `http://localhost:7863`, start a session, speak, and press **Send turn**.
+Wait for the answer to finish playing before speaking again. The UI opens a
+fresh STT connection for each turn; the displayed conversation is a local log,
+and **previous turns are not provided as model history**. The STT endpoint
+accepts only model selection, so system-prompt controls are hidden in this mode.
+
+The adapter follows the shipped `qwen3_omni/openai_realtime_client.py` and
+`vllm_omni/entrypoints/openai/realtime_connection.py`:
+
+1. Explicit `duplex=0` selects the legacy STT handler on a turn deployment.
+ It does not enable STT on a duplex deployment.
+2. Send `{type: "session.update", model: ...}` and `commit(final=false)`.
+3. Stream mono PCM16 at 16 kHz; **Send turn** flushes buffered audio, then sends `commit(final=true)`.
+4. `response.output_audio.delta.audio` contains PCM; `response.output_audio.done`
+ terminates this adapter, including a response with no audio.
+5. The local implementation emits **model output** on `transcription.*`, so the
+ UI displays it as assistant text. This differs from the early RFC's suggested
+ user-transcript mapping. The VAD adapter also supports `response.output_text.*`
+ and audio-transcript events without duplicating the answer.
+
+## Qwen3 with Server VAD (automatic turns)
+
+Run commands from the repository root with the vLLM-Omni environment activated.
+Prepare the pinned Silero v6.2 ONNX artifact as described in the
+[Qwen3 Server VAD instructions](../qwen3_omni/README.md#realtime-websocket-client-openai_realtime_clientpy).
+The backend requires ONNX Runtime and a compatible local artifact; setting
+`--vad` on the UI alone does not enable VAD on the backend.
+
+Create an overlay named `qwen3_vad.yaml`, replacing both paths with absolute paths
+on the backend host:
+
+```yaml
+base_config: /path/to/vllm-omni/vllm_omni/deploy/qwen3_omni_duplex.yaml
+duplex_session:
+ server_vad_model_path: /path/to/silero_vad.onnx
+```
+
+The base configuration enables duplex mode and sets the per-prompt audio and image limits.
+
+Start the backend:
+
+```bash
+vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 \
+ --deploy-config qwen3_vad.yaml
+```
+
+In a second terminal, wait until the health check succeeds, then start the UI:
+
+```bash
+curl --fail http://127.0.0.1:8091/health
+python -m examples.online_serving.qwen3_omni.realtime_web \
+ --backend ws://127.0.0.1:8091 --vad --port 7863
+```
+
+Open `http://localhost:7863` and start a session. Speak and pause: the server
+commits the turn automatically after 500 ms of silence. No **Send turn** action
+is needed. Defaults are a speech threshold of 0.5 and 300 ms of prefix padding.
+Speak again to interrupt an answer. The engine owns input buffering, VAD,
+request cancellation, playback acknowledgements, and conversation history.
+Each committed utterance starts a normal Thinker → Talker → Code2Wav request;
+it does not append input to a live Qwen KV cache. The capability response reports
+`implementation_level: turn_based_duplex` and `supports_core_resumable_request: false`.
+
+The plugin retains up to four audio inputs (including the current turn), pruning
+older audio at an 8 MiB base64 budget; one current utterance is bounded by the
+engine's pending-input limit. The UI reports playback progress both on normal
+drain and on interruption. Qwen does not provide text/audio alignment, so assistant
+text enters history only after the complete audio response has been generated and
+acknowledged as played. A partially played answer contributes no guessed text
+prefix. Image conversation items remain available until deleted. Model context limits still apply.
+The bundled duplex deploy allows four audio inputs and eight images per prompt.
+Use headphones to avoid speaker audio triggering VAD interruption. Reference
+voices and tool calls are not supported by this plugin.
+
+### Switching between VAD and manual turns
+
+| Mode | Backend | UI flag | Submit a turn |
+| --- | --- | --- | --- |
+| Without VAD | Default Qwen turn deployment | `--stt` (default) | Press **Send turn** |
+| With VAD | Server VAD configuration and Silero artifact | `--vad` | Pause after speaking |
+
+Stop the existing UI process before starting another on the same port, then
+refresh the browser and reconnect. To use STT, start a backend with the default
+turn deployment and restart the UI with `--stt`. The duplex backend exposes
+Realtime duplex and ChatCompletion; it does not expose the legacy STT handler.
+To enable VAD on a default backend, first restart that backend with the overlay.
+Make `--backend` match the actual backend port; the UI and backend use separate
+ports. `Connection refused` means the target backend is unavailable: check its
+startup log and `/health` before connecting.
+
+VAD connects with `duplex=1` and sends nested `session.audio.input` configuration
+with `create_response: true` and `interrupt_response: true`. It sends playback
+ACKs when speaker audio drains. `response.audio.done` / `response.output_audio.done`
+only drain playback; `response.done` terminates a turn. Backpressure displays a
+notice without pausing the microphone. Failed handshakes time out after 15 seconds;
+Silero initialization failures display a specific error.
+
+After updating the backend Python code, restart the backend, refresh the UI, and
+start a new session so the plugin and browser assets use the updated code. A
+successful `/health` check confirms readiness, not which deployment mode is active.
+
+## Qwen VAD camera input
+
+After starting a session with `--vad`, click **Camera** and allow access. The UI
+sends one JPEG per second, resized to a longest side of 448 pixels, independently
+of microphone uploads (including while muted). Each frame is a user conversation
+item using the [OpenAI Realtime image input](https://developers.openai.com/api/docs/guides/realtime-conversations#image-inputs) shape:
+
+```json
+{"type":"conversation.item.create","item":{"id":"camera_1","type":"message","role":"user","content":[{"type":"input_image","image_url":"data:image/jpeg;base64,..."}]}}
+```
+
+Images become conversation context immediately; they do not start a response.
+Speak and let VAD commit your audio, or send `response.create` after adding an
+image/text item. A manual `input_audio_buffer.commit` only commits audio; follow
+it with `response.create` to generate. Images persist across audio commits,
+interruptions, and audio-buffer clears. Remove them with `conversation.item.delete`.
+The browser retains eight camera items and deletes the oldest before adding a
+ninth. Camera-off stops capture but leaves those items in the conversation.
+
+This deployment accepts JPEG/PNG Base64 data URLs with a total image-context
+budget of eight images and 4 MiB; external image URLs are not supported. Clients
+must delete old image items when reaching the limit. There is no precise
+video/audio timestamp alignment or native streaming-video KV update.
+
+### Protocol compatibility boundary
+
+Qwen VAD uses `session.update`, 24 kHz PCM16 `input_audio_buffer.append`,
+`conversation.item.create` with `input_image`/`input_text`, and `response.create`.
+The engine resamples input to Qwen's 16 kHz processing rate. Qwen rejects the
+custom `video_frames` field on an append: it answers whole turns, so it has no
+audio unit for a frame track to align to. MiniCPM keeps that field for its
+native camera protocol, where Stage 0 interleaves frames at unit boundaries.
+
+This is a supported subset, not full OpenAI Realtime API compatibility.
+`duplex=1`, `overlap_policy`, `playback.ack`, and `session.close` remain vLLM-Omni
+extensions. The legacy `--stt` mode is unchanged and is not the standard Realtime
+conversation API. This demo does not add WebRTC, semantic VAD, or tool calling.
+
+## Shared host options
+
+The shared entry point can select either profile explicitly:
+
+```bash
+python -m examples.online_serving.realtime_web --profile qwen3-turn \
+ --backend ws://127.0.0.1:8091 --stt --port 7863
+```
+
+- `--backend` / `--ws-backend`: backend WebSocket origin (not the full `/v1/realtime` URL).
+- `--model`: override the wrapper's model name.
+- `--public-realtime-url`: optional browser-visible WebSocket URL; otherwise the
+ static host proxies `/v1/realtime` on the same origin.
+- `--host`, `--port`: UI bind address and port (default port 7862).
+- `--ref-audio`: required for MiniCPM; rejected for Qwen3.
+
+Microphone and camera access require `localhost` or HTTPS. For a remote backend,
+use an SSH tunnel to the UI host or an HTTPS reverse proxy with WebSocket support.
+An explicit `wss://` public URL retains its scheme.
+
+## Validation
+
+```bash
+node --test tests/examples/test_realtime_web_profiles.cjs
+pytest tests/examples/test_minicpmo_realtime_web_server.py \
+ tests/examples/test_minicpmo_realtime_web_static.py \
+ tests/examples/test_realtime_web_profiles.py
+pytest -m cpu tests/model_executor/models/qwen3_omni/test_duplex_plugin.py \
+ tests/engine/duplex/test_session_runner.py \
+ tests/engine/test_duplex_orchestrator.py
+```
+
+These CPU tests cover profile messages, two STT turns, VAD terminal/drain ordering,
+backpressure, native capture during playback, server config injection, and shared
+worklets. Backend regressions cover current audio inclusion in history-based
+prompts, delayed playback ACK ordering, and no extra generation during silence
+after a completed VAD turn. Hardware acceptance still requires a MiniCPM call
+with camera/barge-in and two Qwen turns against each enabled backend, including
+speech interruption in VAD mode; simulated tests do not
+establish model quality or hardware latency.
diff --git a/examples/online_serving/realtime_web/__init__.py b/examples/online_serving/realtime_web/__init__.py
new file mode 100644
index 000000000..9e82f8967
--- /dev/null
+++ b/examples/online_serving/realtime_web/__init__.py
@@ -0,0 +1,4 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+
+"""Shared browser audio UI and model-specific realtime profiles."""
diff --git a/examples/online_serving/realtime_web/__main__.py b/examples/online_serving/realtime_web/__main__.py
new file mode 100644
index 000000000..618f3c542
--- /dev/null
+++ b/examples/online_serving/realtime_web/__main__.py
@@ -0,0 +1,7 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
+
+from .server import main
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/online_serving/minicpmo/realtime_web/app/index.html b/examples/online_serving/realtime_web/app/index.html
similarity index 76%
rename from examples/online_serving/minicpmo/realtime_web/app/index.html
rename to examples/online_serving/realtime_web/app/index.html
index 46ab15907..a38a731e4 100644
--- a/examples/online_serving/minicpmo/realtime_web/app/index.html
+++ b/examples/online_serving/realtime_web/app/index.html
@@ -4,37 +4,35 @@
-