#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Serve the bundled Asr-demo UI and bridge it to Qwen-ASR's realtime WS API.
Run from the Qwen-Asr repository root on the server where Qwen-ASR is listening:
python scripts/test_qwen_ws_with_asr_demo.py \
--backend-url ws://127.0.0.1:33050/ws/v1/asr/qwen
Then forward the UI port from your workstation with `ssh -L 8188:127.0.0.1:8188
user@server` and open http://127.0.0.1:8188. The bundled frontend means the
Asr-demo checkout is not needed on the server. The served HTML gets a small Qwen
label and file-type adjustment, while this bridge translates the legacy protocol.
Requires the `websockets` package, which is already listed by the project.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import mimetypes
import re
from http import HTTPStatus
from pathlib import Path
from typing import Any
from urllib.parse import unquote, urlsplit
try:
from websockets.exceptions import ConnectionClosed
from websockets.legacy.client import connect
from websockets.legacy.server import serve
except ImportError as exc:
raise SystemExit(
"Missing websockets. Activate the Qwen-Asr or Asr-demo Python environment, "
"or install it with `python -m pip install websockets`."
) from exc
LOGGER = logging.getLogger("qwen_ws_frontend_test")
DEFAULT_BACKEND_URL = "ws://127.0.0.1:33050/ws/v1/asr/qwen"
# Keep the test UI in-repository so a server deployment needs only this project.
DEFAULT_FRONTEND_DIR = Path(__file__).resolve().parent / "qwen_ws_test_frontend"
def _as_bool(value: Any) -> bool:
"""Normalize the 0/1 values emitted by the existing browser UI."""
if isinstance(value, str):
return value.strip().lower() not in {"", "0", "false", "no", "off"}
return bool(value)
def _qwen_start_message(frontend_message: dict[str, Any]) -> dict[str, Any]:
"""Translate Asr-demo's flat start options into Qwen-ASR's payload shape."""
source = str(frontend_message.get("source", "mic")).lower()
filename = str(frontend_message.get("file_name", ""))
suffix = Path(filename).suffix.lower()
# Qwen's current websocket decoder accepts raw PCM16 or a WAV container.
if source == "mic" or suffix == ".pcm":
audio_format = "pcm"
elif suffix == ".wav":
audio_format = "wav"
else:
raise ValueError(
"Qwen WebSocket test input must be microphone audio, .pcm, or .wav; "
f"received {filename or source!r}."
)
payload: dict[str, Any] = {
"format": audio_format,
"sample_rate": 16000,
"enable_speaker": _as_bool(frontend_message.get("speaker_diarization", 1)),
"enable_speaker_identification": False,
"enable_native_partial_stream": True,
}
# The reused UI exposes hotwords as a comma-separated string; Qwen accepts
# this text through its realtime context prompt.
hotwords = str(frontend_message.get("hotwords", "")).strip()
if hotwords:
payload["context"] = hotwords
return {"type": "start", "payload": payload}
async def _relay_browser_to_qwen(browser: Any, qwen: Any) -> None:
"""Forward audio frames and adapt the legacy UI's control messages."""
audio_format = "pcm"
wav_buffer = bytearray()
async for event in browser:
if isinstance(event, str):
message = json.loads(event)
msg_type = message.get("type")
if msg_type == "start":
try:
message = _qwen_start_message(message)
except ValueError as exc:
await browser.send(json.dumps({"type": "error", "message": str(exc)}))
return
audio_format = message["payload"]["format"]
elif msg_type == "eof":
# The demo signals end-of-file; Qwen finalizes a stream on stop.
if audio_format == "wav" and wav_buffer:
# Keep the WAV container intact; arbitrary network chunks do
# not each contain a WAV header and cannot be decoded alone.
await qwen.send(bytes(wav_buffer))
wav_buffer.clear()
message = {"type": "stop"}
elif msg_type == "stop" and audio_format == "wav" and wav_buffer:
await qwen.send(bytes(wav_buffer))
wav_buffer.clear()
await qwen.send(json.dumps(message, ensure_ascii=False))
continue
if isinstance(event, bytes):
if audio_format == "wav":
wav_buffer.extend(event)
else:
await qwen.send(event)
async def _relay_qwen_to_browser(qwen: Any, browser: Any) -> None:
"""Pass Qwen-ASR's voice_id/start/sentences/end/error events to the UI."""
async for message in qwen:
await browser.send(message)
async def _bridge_websocket(browser: Any, backend_url: str) -> None:
try:
async with connect(backend_url, max_size=None) as qwen:
LOGGER.info("Connected to Qwen-ASR WebSocket: %s", backend_url)
tasks = {
asyncio.create_task(_relay_browser_to_qwen(browser, qwen)),
asyncio.create_task(_relay_qwen_to_browser(qwen, browser)),
}
done, pending = await asyncio.wait(
tasks,
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
# Surface real relay failures while treating disconnects as normal.
for task in done:
error = task.exception()
if error and not isinstance(error, ConnectionClosed):
raise error
except ConnectionClosed:
pass
except Exception as exc:
LOGGER.exception("WebSocket bridge failed")
try:
await browser.send(json.dumps({"type": "error", "message": str(exc)}))
except Exception:
pass
finally:
try:
await browser.close(code=1000)
except Exception:
pass
def _qwen_compatible_index(frontend_dir: Path) -> bytes:
"""Adjust labels and selectable formats in the served copy of index only."""
html = (frontend_dir / "index.html").read_text(encoding="utf-8")
html = html.replace(
'id="engineModel" value="16k_zh_en_speaker" readonly',
'id="engineModel" value="Qwen3-ASR realtime" readonly',
)
html = re.sub(
r'()',
r"\1.pcm,.wav\2",
html,
)
html = re.sub(
r'().*?()',
r"\1Mic streams live; files: 16 kHz mono PCM or WAV (sent after upload).\2",
html,
)
return html.encode("utf-8")
def _make_http_handler(frontend_dir: Path):
async def process_request(path: str, headers: Any):
parsed = urlsplit(path)
if parsed.path == "/ws" and headers.get("Upgrade", "").lower() == "websocket":
return None
if parsed.path == "/ws":
return (
HTTPStatus.UPGRADE_REQUIRED,
[("Content-Type", "text/plain")],
b"WebSocket upgrade required",
)
if parsed.path == "/api/stop":
# The UI also sends stop over WebSocket; avoid its legacy 404 fetch.
return (
HTTPStatus.OK,
[("Content-Type", "application/json")],
b'{"status":"ok","message":"Stop is sent over the WebSocket."}',
)
if parsed.path == "/":
body = _qwen_compatible_index(frontend_dir)
content_type = "text/html; charset=utf-8"
else:
asset_path = (frontend_dir / unquote(parsed.path).lstrip("/")).resolve()
if not asset_path.is_relative_to(frontend_dir):
return HTTPStatus.FORBIDDEN, [], b"Forbidden"
if not asset_path.is_file():
return HTTPStatus.NOT_FOUND, [], b"Not found"
body = asset_path.read_bytes()
content_type = mimetypes.guess_type(asset_path.name)[0] or "application/octet-stream"
if content_type.startswith("text/") or content_type in {
"application/javascript",
"application/json",
}:
content_type += "; charset=utf-8"
return HTTPStatus.OK, [("Content-Type", content_type)], body
return process_request
async def run_server(frontend_dir: Path, backend_url: str, host: str, port: int) -> None:
async def handle_frontend_socket(browser: Any) -> None:
if urlsplit(browser.path).path != "/ws":
await browser.close(code=1008, reason="Use /ws")
return
await _bridge_websocket(browser, backend_url)
async with serve(
handle_frontend_socket,
host,
port,
process_request=_make_http_handler(frontend_dir),
max_size=None,
):
LOGGER.info("Open Asr-demo UI at http://127.0.0.1:%s", port)
LOGGER.info("Forwarding realtime ASR to %s", backend_url)
await asyncio.Future()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Serve the Asr-demo frontend and connect it to Qwen-ASR realtime WS."
)
parser.add_argument(
"--backend-url",
default=DEFAULT_BACKEND_URL,
help=f"Qwen-ASR websocket URL (default: {DEFAULT_BACKEND_URL})",
)
parser.add_argument(
"--frontend-dir",
type=Path,
default=DEFAULT_FRONTEND_DIR,
help="Optional override for the bundled Asr-demo frontend directory.",
)
parser.add_argument(
"--host",
default="127.0.0.1",
help="Local UI bind address. Keep localhost for browser microphone permission.",
)
parser.add_argument("--port", type=int, default=8188, help="Local UI HTTP port.")
parser.add_argument(
"--log-level",
choices=("critical", "error", "warning", "info", "debug"),
default="info",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
frontend_dir = args.frontend_dir.resolve()
if not (frontend_dir / "index.html").is_file():
raise SystemExit(
f"Test frontend not found at {frontend_dir}. "
"Restore scripts/qwen_ws_test_frontend or pass --frontend-dir."
)
logging.basicConfig(level=getattr(logging, args.log_level.upper()))
try:
asyncio.run(run_server(frontend_dir, args.backend_url, args.host, args.port))
except KeyboardInterrupt:
LOGGER.info("Frontend test bridge stopped.")
if __name__ == "__main__":
main()