[Bugfix] Fix Qwen3-Omni realtime playback interruption and conversation history (#7791)
Signed-off-by: psv666 <2693925048@qq.com>
This commit is contained in:
1 parent
d3396fc5fb
commit
2d0b03f717
13 files changed
+1292
-68
No files matched your search
@@ -18,20 +18,26 @@ import io
|
||||
import json
|
||||
import os
|
||||
import wave
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from pathlib import Path
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import websockets
|
||||
import yaml
|
||||
|
||||
from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import validated_input_wav
|
||||
from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import validated_input_wav, validated_soft_interrupt_wav
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.media import (
|
||||
convert_audio_bytes_to_text,
|
||||
cosine_similarity_text,
|
||||
generate_synthetic_audio,
|
||||
)
|
||||
from tests.helpers.runtime import OmniServerParams
|
||||
from tests.helpers.runtime import OmniServer, OmniServerParams, get_model_prefix
|
||||
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.engine.duplex.contracts import duplex_resource_request_belongs_to_session
|
||||
from vllm_omni.engine.duplex.vad import (
|
||||
SILERO_VAD_FILENAME,
|
||||
SILERO_VAD_REPO_ID,
|
||||
@@ -361,6 +367,15 @@ def _assert_realtime_accuracy(
|
||||
|
||||
|
||||
class TestQwen3OmniRealtimeWebSocket:
|
||||
@pytest.fixture(scope="class")
|
||||
def omni_server(self, request, run_level):
|
||||
# Release the legacy turn deployment before the duplex class below
|
||||
# starts another model server on the same pair of GPUs.
|
||||
from tests.helpers.fixtures.runtime import omni_fixture_lock
|
||||
from tests.helpers.runtime import iter_omni_server
|
||||
|
||||
yield from iter_omni_server(request, run_level, omni_fixture_lock)
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
|
||||
@@ -518,3 +533,394 @@ class TestQwen3OmniRealtimeWebSocket:
|
||||
|
||||
_assert_realtime_smoke(result)
|
||||
_assert_realtime_accuracy(result)
|
||||
|
||||
|
||||
# These regressions use the engine-owned Qwen duplex plugin. The older class
|
||||
# above intentionally exercises the separate turn/STT deployment.
|
||||
_DUPLEX_REPEATS = int(os.environ.get("VLLM_OMNI_TEST_QWEN_DUPLEX_REPEATS", "20"))
|
||||
_DUPLEX_LONG_INSTRUCTIONS = "Answer the user's question in English using at least eight complete sentences."
|
||||
_DUPLEX_SHORT_INSTRUCTIONS = "Answer the user's question in one short English sentence."
|
||||
|
||||
|
||||
class _DuplexTestRuntime(NamedTuple):
|
||||
server: OmniServer
|
||||
processor: Any
|
||||
artifacts: Path
|
||||
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def qwen_duplex_server(tmp_path_factory, cached_silero_vad_artifact, run_level):
|
||||
"""Load real weights once for all interruption repetitions and history checks."""
|
||||
from transformers import AutoProcessor
|
||||
|
||||
assert run_level in {"advanced_model", "full_model"}, "Duplex playback acceptance requires real weights"
|
||||
artifacts = tmp_path_factory.mktemp("qwen_duplex_playback")
|
||||
deploy = artifacts / "deploy.yaml"
|
||||
# An absolute base keeps the production YAML's relative inheritance intact
|
||||
# when the test's Silero override lives in a temporary directory.
|
||||
deploy.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"base_config": get_deploy_config_path("qwen3_omni_duplex.yaml"),
|
||||
"duplex_session": {"server_vad_model_path": cached_silero_vad_artifact},
|
||||
}
|
||||
)
|
||||
)
|
||||
assert load_deploy_config(deploy).session_mode == "duplex"
|
||||
model = get_model_prefix() + MODEL
|
||||
processor = AutoProcessor.from_pretrained(model, trust_remote_code=True)
|
||||
with OmniServer(
|
||||
model,
|
||||
["--deploy-config", str(deploy), "--init-timeout", "900", "--stage-init-timeout", "600"],
|
||||
env_dict={"VLLM_OMNI_QWEN_VISUAL_DEBUG_DIR": str(artifacts)},
|
||||
) as server:
|
||||
yield _DuplexTestRuntime(server=server, processor=processor, artifacts=artifacts)
|
||||
|
||||
|
||||
def _duplex_fixture_pcm(path: Path, *, duration_s: float | None = None) -> bytes:
|
||||
"""Replay checked-in speech at the web client's negotiated 24 kHz PCM16 rate."""
|
||||
pcm = np.frombuffer(_pcm16_mono_16k_from_wav_bytes(path.read_bytes()), dtype="<i2")
|
||||
if duration_s is not None:
|
||||
pcm = pcm[: int(duration_s * 16000)]
|
||||
positions = np.arange(len(pcm) * 3 // 2) * (2.0 / 3.0)
|
||||
return np.interp(positions, np.arange(len(pcm)), pcm).astype("<i2").tobytes()
|
||||
|
||||
|
||||
def _duplex_response_id(event: dict) -> str | None:
|
||||
return event.get("response_id") or event.get("response", {}).get("id")
|
||||
|
||||
|
||||
def _duplex_response_events(events: list[dict], response_id: str, event_type: str) -> list[dict]:
|
||||
return [event for event in events if event["type"] == event_type and _duplex_response_id(event) == response_id]
|
||||
|
||||
|
||||
async def _duplex_receive_until(ws, events: list[dict], predicate, *, timeout_s: float = 180) -> None:
|
||||
# One deadline covers the whole operation, including an endless stream that
|
||||
# would otherwise reset a per-recv timeout forever.
|
||||
async def receive() -> None:
|
||||
while not predicate():
|
||||
raw = await ws.recv()
|
||||
assert isinstance(raw, str), "Expected JSON Realtime events"
|
||||
event = json.loads(raw)
|
||||
events.append(event)
|
||||
assert event.get("type") != "error", event
|
||||
|
||||
await asyncio.wait_for(receive(), timeout=timeout_s)
|
||||
|
||||
|
||||
async def _duplex_send_pcm(ws, pcm: bytes, *, paced: bool = False, trailing_silence: bool = True) -> None:
|
||||
if trailing_silence:
|
||||
pcm += bytes(24000 * 2) # One second exceeds the configured 500 ms endpoint.
|
||||
chunk_bytes = 24000 * 2 // 5
|
||||
for offset in range(0, len(pcm), chunk_bytes):
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "input_audio_buffer.append",
|
||||
"audio": base64.b64encode(pcm[offset : offset + chunk_bytes]).decode(),
|
||||
}
|
||||
)
|
||||
)
|
||||
if paced:
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
|
||||
async def _duplex_add_image(ws) -> None:
|
||||
from PIL import Image
|
||||
|
||||
image = io.BytesIO()
|
||||
Image.new("RGB", (448, 336), (32, 96, 160)).save(image, format="JPEG")
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"id": "camera_1",
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": "data:image/jpeg;base64," + base64.b64encode(image.getvalue()).decode(),
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _duplex_recorded_session(runtime, log_path: Path, *, instructions: str, vad: bool = True):
|
||||
events: list[dict] = []
|
||||
server = runtime.server
|
||||
async with websockets.connect(
|
||||
f"ws://{server.host}:{server.port}/v1/realtime?duplex=1", max_size=64 * 1024 * 1024
|
||||
) as ws:
|
||||
failed = True
|
||||
try:
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"model": server.model,
|
||||
"instructions": instructions,
|
||||
"temperature": 0,
|
||||
"max_output_tokens": 384 if vad else 64,
|
||||
"audio": {
|
||||
"input": {
|
||||
"format": {"type": "audio/pcm", "rate": 24000},
|
||||
"turn_detection": {
|
||||
"type": "server_vad",
|
||||
"threshold": 0.5,
|
||||
"prefix_padding_ms": 300,
|
||||
"silence_duration_ms": 500,
|
||||
"create_response": True,
|
||||
"interrupt_response": True,
|
||||
}
|
||||
if vad
|
||||
else None,
|
||||
},
|
||||
"output": {"format": {"type": "audio/pcm", "rate": 24000}},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
await _duplex_receive_until(ws, events, lambda: any(e["type"] == "session.updated" for e in events))
|
||||
session = next(e["session"] for e in events if e["type"] == "session.updated")
|
||||
if vad:
|
||||
accepted = session["audio"]["input"]["turn_detection"]
|
||||
assert accepted["interrupt_response"] is True
|
||||
assert accepted["create_response"] is True
|
||||
await _duplex_add_image(ws)
|
||||
yield ws, events, session["id"]
|
||||
failed = False
|
||||
finally:
|
||||
# Closing the transport alone leaves a resumable session occupying
|
||||
# the deployment's single session slot. Always release the session.
|
||||
try:
|
||||
await ws.send(json.dumps({"type": "session.close"}))
|
||||
await _duplex_receive_until(
|
||||
ws, events, lambda: any(e["type"] == "session.closed" for e in events), timeout_s=15
|
||||
)
|
||||
except Exception:
|
||||
if not failed:
|
||||
raise
|
||||
finally:
|
||||
log_path.write_text(json.dumps(events, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def _duplex_assert_prompt(
|
||||
runtime,
|
||||
session_id: str,
|
||||
*,
|
||||
audio_count: int,
|
||||
instructions: str,
|
||||
request_count: int,
|
||||
assistant_texts: list[str] | None = None,
|
||||
):
|
||||
captures = []
|
||||
for path in runtime.artifacts.glob("*/input.json"):
|
||||
capture = json.loads(path.read_text())
|
||||
if duplex_resource_request_belongs_to_session(capture["request_id"], session_id):
|
||||
captures.append((path.stat().st_mtime_ns, capture))
|
||||
assert len(captures) == request_count, captures
|
||||
capture = max(captures, key=lambda item: item[0])[1]
|
||||
assert capture["audio_count"] == audio_count
|
||||
# The camera belongs to the first user turn and leaves with its audio.
|
||||
image_retained = request_count <= 4
|
||||
assert len(capture["images"]) == int(image_retained)
|
||||
messages: list[dict[str, Any]] = [{"role": "system", "content": instructions}]
|
||||
if assistant_texts is None:
|
||||
assistant_texts = [""] * (audio_count - 1)
|
||||
assert len(assistant_texts) == audio_count - 1
|
||||
for index in range(audio_count):
|
||||
if index:
|
||||
messages.append({"role": "assistant", "content": assistant_texts[index - 1]})
|
||||
parts = [{"type": "image"}] if index == 0 and image_retained else []
|
||||
parts.append({"type": "audio"})
|
||||
messages.append({"role": "user", "content": parts})
|
||||
expected = runtime.processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
||||
assert capture["prompt"] == expected
|
||||
# Use the checkpoint tokenizer too: a repr(messages) processor stub cannot
|
||||
# establish that an empty assistant survives as a real turn separator.
|
||||
tokenizer = runtime.processor.tokenizer
|
||||
actual_ids = tokenizer.encode(capture["prompt"], add_special_tokens=False)
|
||||
separator_ids = tokenizer.encode("<|im_start|>assistant\n<|im_end|>\n", add_special_tokens=False)
|
||||
assert sum(
|
||||
actual_ids[index : index + len(separator_ids)] == separator_ids for index in range(len(actual_ids))
|
||||
) == assistant_texts.count("")
|
||||
|
||||
|
||||
def _duplex_assert_completed_audio(events: list[dict], response_id: str) -> None:
|
||||
terminal = _duplex_response_events(events, response_id, "response.done")
|
||||
assert len(terminal) == 1, terminal
|
||||
assert terminal[0]["response"]["status"] == "completed", terminal
|
||||
audio = _duplex_response_events(events, response_id, "response.output_audio.delta")
|
||||
assert sum(len(base64.b64decode(event["delta"])) for event in audio) > 4800
|
||||
assert any(
|
||||
event.get("delta")
|
||||
for event in events
|
||||
if _duplex_response_id(event) == response_id
|
||||
and event["type"] in {"response.output_text.delta", "response.output_audio_transcript.delta"}
|
||||
), "The follow-up must produce text as well as audio"
|
||||
|
||||
|
||||
async def _run_duplex_playback_interruption(runtime, log_path: Path, *, generation_finished: bool) -> None:
|
||||
async with _duplex_recorded_session(runtime, log_path, instructions=_DUPLEX_LONG_INSTRUCTIONS) as (
|
||||
ws,
|
||||
events,
|
||||
session_id,
|
||||
):
|
||||
await _duplex_send_pcm(ws, _duplex_fixture_pcm(validated_input_wav()))
|
||||
await _duplex_receive_until(ws, events, lambda: any(e["type"] == "response.created" for e in events))
|
||||
old_id = next(_duplex_response_id(e) for e in events if e["type"] == "response.created")
|
||||
assert old_id
|
||||
await _duplex_receive_until(
|
||||
ws,
|
||||
events,
|
||||
lambda: (
|
||||
sum(
|
||||
len(base64.b64decode(e["delta"]))
|
||||
for e in _duplex_response_events(events, old_id, "response.output_audio.delta")
|
||||
)
|
||||
> 4800
|
||||
),
|
||||
)
|
||||
if generation_finished:
|
||||
await _duplex_receive_until(ws, events, lambda: _duplex_response_events(events, old_id, "response.done"))
|
||||
await ws.send(
|
||||
json.dumps({"type": "playback.ack", "response_id": old_id, "played_ms": 100, "committed_ms": 100})
|
||||
)
|
||||
await _duplex_receive_until(ws, events, lambda: any(e["type"] == "playback.acknowledged" for e in events))
|
||||
ack = next(e["event"] for e in events if e["type"] == "playback.acknowledged")
|
||||
assert ack["item_id"] == f"item_{old_id}"
|
||||
assert ack["committed_ms"] == 100
|
||||
assert ack["playback"]["sent_ms"] > 100
|
||||
cursor = len(events)
|
||||
sender = asyncio.create_task(
|
||||
_duplex_send_pcm(ws, _duplex_fixture_pcm(validated_soft_interrupt_wav(), duration_s=4.7), paced=True)
|
||||
)
|
||||
try:
|
||||
await _duplex_receive_until(
|
||||
ws, events, lambda: any(e["type"] == "input_audio_buffer.speech_started" for e in events[cursor:])
|
||||
)
|
||||
speech_index = next(
|
||||
i for i in range(cursor, len(events)) if events[i]["type"] == "input_audio_buffer.speech_started"
|
||||
)
|
||||
if not generation_finished:
|
||||
assert not _duplex_response_events(events[:speech_index], old_id, "response.done"), (
|
||||
"Generation completed before speech interruption; the in-progress scenario was not exercised"
|
||||
)
|
||||
await _duplex_receive_until(
|
||||
ws,
|
||||
events,
|
||||
lambda: any(e["type"] == "response.done" and _duplex_response_id(e) != old_id for e in events[cursor:]),
|
||||
)
|
||||
await sender
|
||||
finally:
|
||||
sender.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await sender
|
||||
new_id = next(
|
||||
_duplex_response_id(e)
|
||||
for e in events[cursor:]
|
||||
if e["type"] == "response.created" and _duplex_response_id(e) != old_id
|
||||
)
|
||||
# Include everything observed through session.closed when checking duplicates.
|
||||
terminals = _duplex_response_events(events, old_id, "response.done")
|
||||
assert len(terminals) == 1, terminals
|
||||
assert terminals[0]["response"]["status"] == ("completed" if generation_finished else "cancelled")
|
||||
if generation_finished:
|
||||
cleared = _duplex_response_events(events, old_id, "output_audio_buffer.cleared")
|
||||
assert len(cleared) == 1, cleared
|
||||
boundary = events.index(cleared[0])
|
||||
else:
|
||||
boundary = events.index(terminals[0])
|
||||
assert not _duplex_response_events(events[boundary + 1 :], old_id, "response.output_audio.delta")
|
||||
assert new_id and new_id != old_id
|
||||
_duplex_assert_completed_audio(events, new_id)
|
||||
_duplex_assert_prompt(runtime, session_id, audio_count=2, instructions=_DUPLEX_LONG_INSTRUCTIONS, request_count=2)
|
||||
|
||||
|
||||
async def _run_duplex_history_pruning(runtime, log_path: Path) -> None:
|
||||
async with _duplex_recorded_session(runtime, log_path, instructions=_DUPLEX_SHORT_INSTRUCTIONS, vad=False) as (
|
||||
ws,
|
||||
events,
|
||||
session_id,
|
||||
):
|
||||
response_ids = []
|
||||
heard_texts: list[str] = []
|
||||
pcm = _duplex_fixture_pcm(validated_input_wav())
|
||||
for turn in range(6):
|
||||
cursor = len(events)
|
||||
await _duplex_send_pcm(ws, pcm, trailing_silence=False)
|
||||
await ws.send(json.dumps({"type": "input_audio_buffer.commit"}))
|
||||
await ws.send(json.dumps({"type": "response.create"}))
|
||||
await _duplex_receive_until(ws, events, lambda: any(e["type"] == "response.done" for e in events[cursor:]))
|
||||
response_id = next(_duplex_response_id(e) for e in events[cursor:] if e["type"] == "response.done")
|
||||
assert response_id and response_id not in response_ids
|
||||
response_ids.append(response_id)
|
||||
_duplex_assert_completed_audio(events, response_id)
|
||||
_duplex_assert_prompt(
|
||||
runtime,
|
||||
session_id,
|
||||
audio_count=min(turn + 1, 4),
|
||||
instructions=_DUPLEX_SHORT_INSTRUCTIONS,
|
||||
request_count=turn + 1,
|
||||
assistant_texts=heard_texts[-3:],
|
||||
)
|
||||
# Complete playback so pruning is checked against real assistant
|
||||
# answers paired with retained user audio. Repeated empty assistant
|
||||
# turns can make Qwen emit EOS; interruption tests cover those slots.
|
||||
done = _duplex_response_events(events, response_id, "response.done")[0]["response"]
|
||||
played_ms = done["metadata"]["playback"]["sent_ms"]
|
||||
heard_texts.append(
|
||||
"".join(
|
||||
e["delta"]
|
||||
for e in _duplex_response_events(events, response_id, "response.output_audio_transcript.delta")
|
||||
).strip()
|
||||
)
|
||||
assert heard_texts[-1]
|
||||
cursor = len(events)
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "playback.ack",
|
||||
"response_id": response_id,
|
||||
"played_ms": played_ms,
|
||||
"committed_ms": played_ms,
|
||||
}
|
||||
)
|
||||
)
|
||||
await _duplex_receive_until(
|
||||
ws, events, lambda: any(e["type"] == "playback.acknowledged" for e in events[cursor:])
|
||||
)
|
||||
ack = next(e["event"] for e in events[cursor:] if e["type"] == "playback.acknowledged")
|
||||
assert ack["item_id"] == f"item_{response_id}"
|
||||
assert ack["committed_ms"] == ack["playback"]["sent_ms"] == played_ms
|
||||
assert ack["history_committed"] is True
|
||||
for response_id in response_ids:
|
||||
_duplex_assert_completed_audio(events, response_id)
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=2)
|
||||
class TestQwen3OmniDuplexPlayback:
|
||||
@pytest.mark.parametrize("generation_finished", [False, True], ids=["generating", "queued-playback"])
|
||||
@pytest.mark.parametrize("repetition", range(_DUPLEX_REPEATS))
|
||||
def test_speech_interrupts_playback(
|
||||
self, qwen_duplex_server, tmp_path: Path, generation_finished: bool, repetition: int
|
||||
):
|
||||
"""Reuse one live deployment; every repetition opens an independent call."""
|
||||
asyncio.run(
|
||||
_run_duplex_playback_interruption(
|
||||
qwen_duplex_server, tmp_path / f"events-{repetition}.json", generation_finished=generation_finished
|
||||
)
|
||||
)
|
||||
|
||||
def test_mixed_image_audio_history_pruning(self, qwen_duplex_server, tmp_path: Path):
|
||||
asyncio.run(_run_duplex_history_pruning(qwen_duplex_server, tmp_path / "events.json"))
|
||||
Reference in new issue
Block a user