[Bugfix] Fix Qwen3-Omni realtime playback interruption and conversation history (#7791)

Signed-off-by: psv666 <2693925048@qq.com>
This commit is contained in:
psv666 authored and GitHub committed 2026-09-20 14:42:17 +00:00
1 parent d3396fc5fb
commit 2d0b03f717
13 files changed
+1292 -68

No files matched your search

+68 -1
View File
@@ -11,7 +11,7 @@ import inspect
import pytest
from vllm_omni.engine.duplex import events as events_module
from vllm_omni.engine.duplex.commands import TruncateItem
from vllm_omni.engine.duplex.commands import ClearOutputAudio, TruncateItem
from vllm_omni.engine.duplex.events import (
REALTIME_ERROR_TYPES_BY_CODE,
AudioDelta,
@@ -41,6 +41,7 @@ from vllm_omni.engine.duplex.events import (
from vllm_omni.engine.duplex.realtime_events import (
RealtimeProjectionState,
project_internal_event,
resolve_clear_output_audio,
resolve_truncate_item,
retrieve_item_events,
)
@@ -376,6 +377,72 @@ def test_projection_of_output_audio_buffer_clear_emits_cleared_before_terminals(
assert state.item_truncation_cursors["item_resp_1"] == (0, 250)
@pytest.mark.parametrize("reason", ["turn_detected", "barge_in", "output_audio_buffer_clear"])
@pytest.mark.parametrize("committed_ms", [1000, 10000])
def test_completed_response_cancellation_only_clears_pending_playback(reason, committed_ms):
state = RealtimeProjectionState(session_id="duplex-playback")
project_internal_event(state, {"type": "response.created", "response_id": "resp_1"})
project_internal_event(state, {"type": "response.done", "response_id": "resp_1"})
cancelled = project_internal_event(
state,
{
"type": "audio.cancelled",
"response_id": "resp_1",
"reason": reason,
"committed_ms": committed_ms,
"playback": {"sent_ms": 10000, "committed_ms": committed_ms},
},
)
if reason == "output_audio_buffer_clear" or committed_ms < 10000:
assert _types(cancelled) == ["output_audio_buffer.cleared"]
assert cancelled[0].response_id == "resp_1"
else:
assert cancelled == []
@pytest.mark.parametrize("explicit_response_id", [False, True])
def test_clear_latest_completed_response_reaches_playback_owner(explicit_response_id):
state = RealtimeProjectionState(session_id="duplex-clear")
project_internal_event(state, {"type": "response.created", "response_id": "resp_1"})
project_internal_event(state, {"type": "response.done", "response_id": "resp_1"})
control = resolve_clear_output_audio(
state, ClearOutputAudio(response_id="resp_1" if explicit_response_id else None, event_id="clear_1")
)
# Generation completion does not prove the client has drained its audio.
# A cleared event must follow the engine clearing its playback cursor.
assert control.events == []
assert control.payloads == [
{
"type": "output_audio_buffer.clear",
"reason": "output_audio_buffer.clear",
"response_id": "resp_1",
"realtime_event_id": "clear_1",
}
]
@pytest.mark.parametrize("new_response_finished", [False, True])
def test_late_clear_of_completed_response_does_not_touch_new_response(new_response_finished):
state = RealtimeProjectionState(session_id="duplex-clear")
project_internal_event(state, {"type": "response.created", "response_id": "resp_1"})
project_internal_event(state, {"type": "response.done", "response_id": "resp_1"})
project_internal_event(state, {"type": "response.created", "response_id": "resp_2"})
if new_response_finished:
project_internal_event(state, {"type": "response.done", "response_id": "resp_2"})
control = resolve_clear_output_audio(state, ClearOutputAudio(response_id="resp_1"))
assert control.payloads == []
assert _types(control.events) == ["output_audio_buffer.cleared"]
assert control.events[0].response_id == "resp_1"
assert state.active_response_id == (None if new_response_finished else "resp_2")
assert state.last_response_id == "resp_2"
def test_one_truncate_command_truncates_retrieved_transcript_once():
state = RealtimeProjectionState(session_id="duplex-truncate")
state.conversation_items["item_resp_1"] = {
@@ -21,6 +21,7 @@ from vllm_omni.engine.duplex.config import (
from vllm_omni.engine.duplex.contracts import DuplexFence
from vllm_omni.engine.duplex.events import TurnEvent
from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession, DuplexFenceMismatchError
from vllm_omni.engine.duplex.session.playback_ledger import apply_playback_ack
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.capabilities import (
minicpmo45_native_capabilities,
)
@@ -241,6 +242,83 @@ def test_history_commit_with_ack_only_policy_defers_unacknowledged_text():
assert f"item_{response_id}" in session.pending_history_item_ids
@pytest.mark.parametrize("audio_complete", [False, True])
def test_unaligned_response_keeps_empty_turn_before_later_user_input(audio_complete):
session = _session(config=DuplexSessionConfig(playback_commit_policy="ack_only"))
first = session.commit_audio_input(transcript="recite a poem")
response_id = session.begin_response()
session.reserve_history_item(f"item_{response_id}")
session.append_assistant_text("An answer the user has only partly heard")
session.mark_audio_sent(
duration_ms=10_000,
text_requires_complete_audio=True,
audio_complete=audio_complete,
)
assert session.end_response(commit_text=True) is None
session.barge_in()
session.clear_playback_cursor()
second = session.commit_audio_input(transcript="stop")
empty_answer = {"role": "assistant", "content": ""}
assert session.history == (first.message, empty_answer, second.message)
ack = apply_playback_ack(session, {"response_id": response_id, "played_ms": 6_981})
assert ack[0].to_realtime()["event"]["history_committed"] is False
assert session.history == (first.message, empty_answer, second.message)
if audio_complete:
ack = apply_playback_ack(session, {"response_id": response_id, "played_ms": 10_000})
assert ack[0].to_realtime()["event"]["history_committed"] is True
assert session.history == (
first.message,
{"role": "assistant", "content": "An answer the user has only partly heard"},
second.message,
)
@pytest.mark.parametrize(("reserve_slot", "assistant_text"), [(True, ""), (False, "unplayed answer")])
def test_unaligned_response_only_materializes_an_existing_answer_slot(reserve_slot, assistant_text):
session = _session(config=DuplexSessionConfig(playback_commit_policy="ack_only"))
first = session.commit_audio_input(transcript="first input")
response_id = session.begin_response()
if reserve_slot:
session.reserve_history_item(f"item_{response_id}")
session.append_assistant_text(assistant_text)
session.mark_audio_sent(duration_ms=10_000, text_requires_complete_audio=True, audio_complete=True)
assert session.end_response(commit_text=reserve_slot) is None
assert session.history == (first.message,)
@pytest.mark.parametrize("operation", ["delete", "truncate"])
def test_removing_one_empty_assistant_preserves_the_other_turn_boundary(operation):
session = _session(config=DuplexSessionConfig(playback_commit_policy="ack_only"))
user_inputs = []
response_ids = []
for transcript in ("first input", "second input"):
user_inputs.append(session.commit_audio_input(transcript=transcript).message)
response_id = session.begin_response()
response_ids.append(response_id)
session.reserve_history_item(f"item_{response_id}")
session.append_assistant_text("unplayed answer")
session.mark_audio_sent(duration_ms=10_000, text_requires_complete_audio=True, audio_complete=True)
session.end_response(commit_text=True)
item_id = f"item_{response_ids[1]}"
if operation == "delete":
assert session.delete_history_item(item_id) is True
else:
# A full ACK releases the pending snapshot. Truncation then edits the
# stored message into another empty dict before removing that item.
ack = apply_playback_ack(session, {"response_id": response_ids[1], "played_ms": 10_000})
assert ack[0].to_realtime()["event"]["history_committed"] is True
assert session.truncate_history_item(item_id, audio_end_ms=0, hard=True) is True
assert session.history == (user_inputs[0], {"role": "assistant", "content": ""}, user_inputs[1])
ack = apply_playback_ack(session, {"response_id": response_ids[1], "played_ms": 10_000})
assert ack[0].to_realtime()["error"]["code"] == "playback_item_not_found"
assert session.history == (user_inputs[0], {"role": "assistant", "content": ""}, user_inputs[1])
def test_truncate_history_item_uses_response_alignment_marks():
session = _session()
response_id = session.begin_response()
@@ -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"))
+190 -2
View File
@@ -112,11 +112,28 @@ function shell(profileName, adapter = 'stt', options = {}) {
constructor(options) { this.sampleRate = options.sampleRate; this.audioWorklet = { addModule: async () => {} }; this.destination = {}; }
createMediaStreamSource() { return { connect() {} }; }
createGain() { return { gain: {}, connect() {} }; }
decodeAudioData(bytes) { return options.decodeAudioData(bytes); }
async resume() {}
async close() {}
}
class AudioWorkletNode {
constructor(_context, name) { this.name = name; this.sent = []; nodes.push(this); this.port = { postMessage: (message) => this.sent.push(message) }; }
constructor(_context, name) {
this.name = name; this.sent = []; nodes.push(this);
this.port = { postMessage: (message) => { this.sent.push(message); this.player?.handleMessage(message); } };
if (options.realPlayback && name === 'fullduplex-pcm-playback') {
let Playback;
const node = this;
const worker = vm.createContext({
sampleRate: 24000,
AudioWorkletProcessor: class {
constructor() { this.port = { postMessage: message => queueMicrotask(() => node.port.onmessage?.({ data: message })) }; }
},
registerProcessor: (_name, cls) => { Playback = cls; },
});
vm.runInContext(fs.readFileSync(path.join(root, 'playback_worklet.js'), 'utf8'), worker);
this.player = new Playback();
}
}
connect(target) { return target; }
}
const ctx = vm.createContext({
@@ -138,7 +155,7 @@ function shell(profileName, adapter = 'stt', options = {}) {
// Expose closure controls only in the test VM; production has no test API.
const source = fs.readFileSync(path.join(root, 'app.js'), 'utf8').replace(/\}\)\(\);\s*$/, `
globalThis.testUI = { startSession, stopSession, handleEvent, playbackDrained,
microphoneUploadEnabled, flushCapture,
microphoneUploadEnabled, flushCapture, awaitQueue: () => audioChain,
capture() { pendingCapture.push(new Int16Array([100, 200])); },
state() { return { running, connectionReady, assistantActive }; }
};
@@ -410,3 +427,174 @@ test('the camera retires its oldest image instead of exhausting the session budg
assert.equal(plain(p.initialMessages(config, 'x')).length > 0, true);
assert.equal(plain(p.imageMessages('JPEG')).length, 1);
});
// Exercise the real WebSocket dispatch queue together with the actual playback
// processor: sending a clear message alone does not prove the speaker is silent.
function receive(app, event) {
app.sockets[0].onmessage({ data: JSON.stringify(event) });
}
function audioChunk(responseId, extra = {}) {
const pcm = new Int16Array(24000).fill(12000);
return { type: 'response.output_audio.delta', response_id: responseId,
delta: Buffer.from(pcm.buffer).toString('base64'), format: 'pcm16', sample_rate_hz: 24000, ...extra };
}
function renderPlayback(player) {
const output = new Float32Array(128);
player.process([], [[output]]);
return output;
}
async function playingQwen(options = {}) {
const app = shell('qwen3-turn', 'vad', { realPlayback: true, ...options });
await app.ui.startSession();
receive(app, { type: 'response.created', response: { id: 'old' } });
receive(app, audioChunk('old'));
await app.ui.awaitQueue();
app.player = app.nodes.find(node => node.name === 'fullduplex-pcm-playback').player;
// Advance through the initial 400 ms playback buffer.
for (let index = 0; index < 75; index++) renderPlayback(app.player);
assert.ok(renderPlayback(app.player).some(sample => sample !== 0));
return app;
}
function assertSilent(player) {
assert.equal(player.bufferedFrames(), 0, 'interrupted audio must leave the playback queue');
assert.ok(renderPlayback(player).every(sample => sample === 0), 'speaker must output silence');
}
for (const terminal of [false, true]) {
test(`Qwen speech interrupts actual playback after generation completed=${terminal}`, async () => {
const app = await playingQwen();
if (terminal) {
receive(app, { type: 'response.output_audio.done', response_id: 'old' });
receive(app, { type: 'response.done', response: { id: 'old', status: 'completed' } });
await app.ui.awaitQueue();
}
receive(app, { type: 'input_audio_buffer.speech_started', item_id: 'next', audio_start_ms: 1000 });
await app.ui.awaitQueue();
assertSilent(app.player);
assert.equal(app.ui.microphoneUploadEnabled(), true);
const ack = app.sockets[0].sent.find(event => event.type === 'playback.ack');
assert.equal(ack.response_id, 'old');
assert.equal(ack.played_ms, 5, 'ack only the 128 frames actually played, not all queued audio');
await app.ui.stopSession({ terminal: false });
});
}
test('Qwen cancellation preempts pending decode and does not delay the next response', async () => {
let releaseDecode;
const app = await playingQwen({ decodeAudioData: () => new Promise(resolve => { releaseDecode = resolve; }) });
receive(app, audioChunk('old', { format: 'wav' }));
await flushTasks();
assert.equal(typeof releaseDecode, 'function');
receive(app, { type: 'response.done', response: { id: 'old', status: 'cancelled' } });
await flushTasks();
assertSilent(app.player);
receive(app, { type: 'response.created', response: { id: 'new' } });
receive(app, audioChunk('new'));
await flushTasks();
assert.equal(app.player.activeResponseId, 'new');
assert.equal(app.player.bufferedFrames(), 24000);
releaseDecode({ sampleRate: 24000, getChannelData: () => new Float32Array(24000).fill(0.4) });
await flushTasks();
assert.equal(app.player.bufferedFrames(), 24000, 'old decoded audio must not enter the new response');
await app.ui.stopSession({ terminal: false });
});
test('Qwen interruption rejects queued and late audio for the cancelled response', async () => {
const app = await playingQwen();
receive(app, audioChunk('old'));
receive(app, { type: 'response.done', response: { id: 'old', status: 'cancelled' } });
receive(app, audioChunk('old'));
await app.ui.awaitQueue();
assertSilent(app.player);
await app.ui.stopSession({ terminal: false });
});
test('late cancellation of an old Qwen response does not clear a newer response', async () => {
const app = await playingQwen();
receive(app, { type: 'response.done', response: { id: 'old', status: 'cancelled' } });
await app.ui.awaitQueue();
receive(app, { type: 'response.created', response: { id: 'new' } });
receive(app, audioChunk('new'));
await app.ui.awaitQueue();
receive(app, { type: 'output_audio_buffer.cleared', response_id: 'old' });
receive(app, { type: 'response.done', response: { id: 'old', status: 'cancelled' } });
await app.ui.awaitQueue();
assert.equal(app.player.activeResponseId, 'new');
assert.equal(app.player.bufferedFrames(), 24000);
assert.equal(app.ui.state().assistantActive, true);
await app.ui.stopSession({ terminal: false });
});
test('Qwen speech interruption follows accepted turn detection; MiniCPM mapping stays unchanged', () => {
const qwen = profiles['qwen3-turn']({ adapter: 'vad' });
const speech = { type: 'input_audio_buffer.speech_started', item_id: 'next' };
assert.equal(qwen.mapEvent(speech).kind, 'interrupt');
qwen.mapEvent({ type: 'session.updated', session: { audio: { input: { turn_detection: {
type: 'server_vad', interrupt_response: false,
} } } } });
assert.equal(qwen.mapEvent(speech).kind, 'ignore');
qwen.mapEvent({ type: 'session.updated', session: { audio: { input: { turn_detection: null } } } });
assert.equal(qwen.mapEvent(speech).kind, 'ignore');
qwen.mapEvent({ type: 'session.updated', session: { audio: { input: { turn_detection: {
type: 'server_vad', interrupt_response: true,
} } } } });
assert.equal(qwen.mapEvent(speech).kind, 'interrupt');
assert.equal(profiles['qwen3-turn']({ adapter: 'stt' }).mapEvent(speech).kind, 'ignore');
assert.equal(profiles['minicpm-native']().mapEvent(speech).kind, 'ignore');
});
test('late old-response controls cannot invalidate a newer pending decode', async () => {
let releaseDecode;
const app = await playingQwen({ decodeAudioData: () => new Promise(resolve => { releaseDecode = resolve; }) });
receive(app, { type: 'input_audio_buffer.speech_started', item_id: 'next', audio_start_ms: 1000 });
await app.ui.awaitQueue();
receive(app, { type: 'response.created', response: { id: 'new' } });
receive(app, audioChunk('new', { format: 'wav' }));
await flushTasks();
assert.equal(typeof releaseDecode, 'function');
receive(app, { type: 'output_audio_buffer.cleared', response_id: 'old' });
receive(app, { type: 'response.done', response: { id: 'old', status: 'cancelled' } });
releaseDecode({ sampleRate: 24000, getChannelData: () => new Float32Array(24000).fill(0.4) });
await app.ui.awaitQueue();
assert.equal(app.player.activeResponseId, 'new');
assert.equal(app.player.bufferedFrames(), 24000);
assert.equal(app.ui.state().assistantActive, true);
await app.ui.stopSession({ terminal: false });
});
test('normal completion still waits for decoding before draining playback', async () => {
let releaseDecode;
const app = await playingQwen({ decodeAudioData: () => new Promise(resolve => { releaseDecode = resolve; }) });
receive(app, audioChunk('old', { format: 'wav' }));
await flushTasks();
receive(app, { type: 'response.output_audio.done', response_id: 'old' });
receive(app, { type: 'response.done', response: { id: 'old', status: 'completed' } });
await flushTasks();
assert.equal(app.player.drain, null);
releaseDecode({ sampleRate: 24000, getChannelData: () => new Float32Array(24000).fill(0.4) });
await app.ui.awaitQueue();
assert.equal(app.player.bufferedFrames(), 47872);
assert.equal(app.player.drain.responseId, 'old');
await app.ui.stopSession({ terminal: false });
});
test('interrupting an old decode preserves a newer response already waiting in the event queue', async () => {
let releaseDecode;
const app = await playingQwen({ decodeAudioData: () => new Promise(resolve => { releaseDecode = resolve; }) });
receive(app, audioChunk('old', { format: 'wav' }));
await flushTasks();
receive(app, { type: 'response.created', response: { id: 'new' } });
receive(app, audioChunk('new'));
receive(app, { type: 'output_audio_buffer.cleared', response_id: 'old' });
await flushTasks();
assert.equal(app.player.activeResponseId, 'new');
assert.equal(app.player.bufferedFrames(), 24000);
releaseDecode({ sampleRate: 24000, getChannelData: () => new Float32Array(24000).fill(0.4) });
await flushTasks();
assert.equal(app.player.bufferedFrames(), 24000);
await app.ui.stopSession({ terminal: false });
});
@@ -1,5 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import ast
import asyncio
import base64
from types import SimpleNamespace
@@ -17,7 +18,14 @@ from tests.engine.duplex.test_session_runner import (
tts_output,
)
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig
from vllm_omni.engine.duplex.commands import AckPlayback, CancelResponse, Commit, UpdateSession
from vllm_omni.engine.duplex.commands import (
AckPlayback,
CancelResponse,
ClearOutputAudio,
Commit,
CreateResponse,
UpdateSession,
)
from vllm_omni.engine.duplex.config import DuplexSessionConfig
from vllm_omni.engine.duplex.messages import OpenDuplexSessionMessage
from vllm_omni.engine.duplex.plugin import DuplexRuntimeConfigError
@@ -120,6 +128,72 @@ async def test_two_committed_turns_stream_text_and_audio_and_release_requests():
await h.manager.shutdown()
@pytest.mark.asyncio
@pytest.mark.parametrize("played_ms", [0, 500, 1000])
async def test_clear_completed_qwen_playback_allows_next_manual_turn(played_ms):
h = await open_qwen()
try:
await h.run(UpdateSession(patch={"turn_detection": None}))
assert h.session.config.overlap_policy == "listen_only"
await h.run(append_audio())
await h.run(Commit(final=True, create_response=False))
await h.run(CreateResponse())
old = h.port.submissions[-1].context
response_id = h.session.active_response_id
await h.deliver_and_settle(
tts_output(old.request_id, samples=0, text="first answer", finished=True), stage_id=0
)
await h.deliver_and_settle(tts_output(old.request_id, finished=True), stage_id=2)
if played_ms:
await h.run(AckPlayback(response_id=response_id, played_ms=played_ms, committed_ms=played_ms))
assert h.session.active_response_id is None
assert h.session.playback.sent_ms == 1000
events = await h.run(ClearOutputAudio(response_id=response_id))
cleared = [event for event in events if event.type == "output_audio_buffer.cleared"]
assert len(cleared) == 1
assert cleared[0].response_id == response_id
assert h.session.playback.sent_ms == h.session.playback.committed_ms == 0
# Repeated clear after the queue is empty still identifies the old turn.
repeated = await h.run(ClearOutputAudio(response_id=response_id))
assert [event.response_id for event in repeated if event.type == "output_audio_buffer.cleared"] == [response_id]
await h.run(append_audio(value=-0.25))
await h.run(Commit(final=True, create_response=False))
await h.run(CreateResponse())
assert len(h.port.submissions) == 2, [event.to_realtime() for event in h.events]
latest = h.port.submissions[-1]
assert latest.context.request_id != old.request_id
assert latest.prompt["prompt"] == repr(
[
{"role": "user", "content": [{"type": "audio"}]},
{"role": "assistant", "content": "first answer" if played_ms == 1000 else ""},
{"role": "user", "content": [{"type": "audio"}]},
]
)
newest_audio, sample_rate = latest.prompt["multi_modal_data"]["audio"][-1]
assert sample_rate == 16000
np.testing.assert_array_equal(newest_audio, np.full(16000, -0.25, dtype=np.float32))
new_response_id = h.session.active_response_id
new_epoch = h.session.epoch
await h.deliver_and_settle(tts_output(latest.context.request_id, finished=False), stage_id=2)
new_playback = h.session.playback.as_dict()
late_clear = await h.run(ClearOutputAudio(response_id=response_id))
assert [event.response_id for event in late_clear if event.type == "output_audio_buffer.cleared"] == [
response_id
]
assert h.session.active_response_id == new_response_id
assert h.session.active_request_id == latest.context.request_id
assert h.session.epoch == new_epoch
assert h.session.playback.as_dict() == new_playback
terminals = [event for event in h.events if event.type == "response.done" and event.response_id == response_id]
assert len(terminals) == 1 and terminals[0].status == "completed"
assert not any(event.to_realtime().get("type") == "error" for event in h.events)
finally:
await h.manager.shutdown()
@pytest.mark.asyncio
async def test_cancel_drops_old_output_and_accepts_next_turn():
h = await open_qwen()
@@ -157,6 +231,60 @@ async def test_vad_commits_and_speech_interrupts_active_qwen_response():
await h.manager.shutdown()
@pytest.mark.asyncio
@pytest.mark.parametrize("generation_finished", [False, True])
async def test_vad_interrupts_queued_qwen_audio_and_keeps_the_new_utterance(generation_finished):
h = await open_qwen()
try:
h.runner.control._detector = _speech_then_stop()
await h.run(append_audio(value=0.125))
await h.run(append_audio(is_speech=False, value=0.0))
old = h.port.submissions[-1].context
response_id = h.session.active_response_id
await h.deliver_and_settle(
tts_output(old.request_id, samples=0, text="first answer", finished=True), stage_id=0
)
await h.deliver_and_settle(tts_output(old.request_id, samples=240000, finished=generation_finished), stage_id=2)
await h.run(AckPlayback(response_id=response_id, played_ms=1000, committed_ms=1000))
assert h.session.playback.sent_ms == 10000
assert h.session.playback.committed_ms == 1000
if generation_finished:
assert h.session.active_response_id is None
assert h.session.active_request_id is None
h.runner.control._detector = _speech_then_stop()
events = await h.run(append_audio(value=-0.25))
if generation_finished:
cleared = [event for event in events if event.type == "output_audio_buffer.cleared"]
assert len(cleared) == 1, [event.to_realtime() for event in events]
assert cleared[0].response_id == response_id
else:
assert any(event.type == "response.done" and event.status == "cancelled" for event in events)
assert h.session.epoch > old.fence.epoch
# Generation already completed is still completed: stopping playback
# must not emit a second, cancelled terminal for the same response.
terminals = [event for event in h.events if event.type == "response.done" and event.response_id == response_id]
assert len(terminals) == 1
assert terminals[0].status == ("completed" if generation_finished else "cancelled")
await h.run(append_audio(is_speech=False, value=0.0))
assert len(h.port.submissions) == 2
latest = h.port.submissions[-1]
assert latest.context.request_id != old.request_id
assert latest.prompt["prompt"] == repr(
[
{"role": "user", "content": [{"type": "audio"}]},
{"role": "assistant", "content": ""},
{"role": "user", "content": [{"type": "audio"}]},
]
)
newest_audio, sample_rate = latest.prompt["multi_modal_data"]["audio"][-1]
assert sample_rate == 16000
np.testing.assert_array_equal(newest_audio, np.full(16000, -0.25, dtype=np.float32))
finally:
await h.manager.shutdown()
def test_audio_projector_handles_delta_tensor_lists_and_sample_rate():
import torch
@@ -180,6 +308,49 @@ def test_audio_projector_handles_delta_tensor_lists_and_sample_rate():
assert events[0]["end_of_turn"] is True
def test_format_history_merges_user_parts_but_preserves_empty_assistant_boundary():
text = {"type": "text", "text": "first question"}
image = {"type": "image"}
audio = {"type": "audio"}
source = [
{"role": "system", "content": "instructions"},
{"role": "user", "content": [image]},
{"role": "user", "content": "first question"},
{"role": "user", "content": [audio]},
{"role": "assistant", "content": ""},
{"role": "user", "content": "next question"},
{"role": "user", "content": [image, audio]},
]
before = repr(source)
assert Qwen3OmniDuplexPlugin.format_history(source) == [
source[0],
{"role": "user", "content": [image, text, audio]},
{"role": "assistant", "content": ""},
{"role": "user", "content": [image, audio, {"type": "text", "text": "next question"}]},
]
assert repr(source) == before
def test_audio_byte_budget_evicts_whole_multimodal_turn():
plugin = Qwen3OmniDuplexPlugin(lambda *args: None)
state = plugin.create_session_state()
first = {"role": "user", "content": [{"type": "audio_url"}]}
image = {"role": "user", "content": [{"type": "image_url"}]}
source = [first, image]
plugin.prepare_prompt_config({"conversation": source}, state=state, payload={"audio": "A" * (5 * 1024 * 1024)})
source.extend(
[
{"role": "assistant", "content": "old answer"},
{"role": "user", "content": [{"type": "audio_url"}]},
]
)
current = {"audio": "B" * (4 * 1024 * 1024)}
config = plugin.prepare_prompt_config({"conversation": source}, state=state, payload=current)
assert config["qwen_messages"] == [{"role": "user", "audio_payload": current}]
assert len(state.audio_history) == 1
assert source[1] is image
def test_audio_history_is_bounded_and_does_not_cross_sessions():
plugin = Qwen3OmniDuplexPlugin(lambda *args: None)
state, other = plugin.create_session_state(), plugin.create_session_state()
@@ -198,6 +369,27 @@ def test_audio_history_is_bounded_and_does_not_cross_sessions():
assert other.audio_history == []
@pytest.mark.parametrize("assistant_text", ["", "previous answer"])
def test_dropped_audio_removes_its_image_and_assistant(assistant_text):
plugin = Qwen3OmniDuplexPlugin(lambda *args: None)
state = plugin.create_session_state()
image = {"role": "user", "content": [{"type": "image_url"}]}
history = [image]
for i in range(6):
history.append({"role": "user", "content": [{"type": "audio_url"}]})
config = plugin.prepare_prompt_config({"conversation": history}, state=state, payload={"audio": str(i)})
history.append({"role": "assistant", "content": assistant_text})
assert config["qwen_messages"] == [
{"role": "user", "audio_payload": {"audio": "2"}},
{"role": "assistant", "content": assistant_text},
{"role": "user", "audio_payload": {"audio": "3"}},
{"role": "assistant", "content": assistant_text},
{"role": "user", "audio_payload": {"audio": "4"}},
{"role": "assistant", "content": assistant_text},
{"role": "user", "audio_payload": {"audio": "5"}},
]
@pytest.mark.asyncio
async def test_late_playback_ack_keeps_answer_before_new_user_input():
h = await open_qwen()
@@ -208,10 +400,18 @@ async def test_late_playback_ack_keeps_answer_before_new_user_input():
response_id = h.session.active_response_id
await h.deliver_and_settle(tts_output(request_id, samples=0, text="first answer", finished=True), stage_id=0)
await h.deliver_and_settle(tts_output(request_id, finished=True), stage_id=2)
# Playback has not drained yet. A new utterance commits first.
assert [m["role"] for m in h.session.history] == ["user"]
# The unplayed answer reserves its turn without exposing unheard text.
assert [m["role"] for m in h.session.history] == ["user", "assistant"]
assert h.session.history[1]["content"] == ""
await h.run(append_audio())
await h.run(Commit(final=True, create_response=True))
assert h.port.submissions[-1].prompt["prompt"] == repr(
[
{"role": "user", "content": [{"type": "audio"}]},
{"role": "assistant", "content": ""},
{"role": "user", "content": [{"type": "audio"}]},
]
)
current_response = h.session.active_response_id
events = await h.run(AckPlayback(response_id=response_id, played_ms=1000))
assert not any(e.to_realtime().get("type") == "error" for e in events)
@@ -286,6 +486,45 @@ def test_current_audio_is_not_replaced_by_history_ending_with_assistant():
assert config["qwen_messages"][-1]["audio_payload"] is current
@pytest.mark.parametrize("current_in_history", [False, True])
def test_empty_assistant_keeps_turn_boundary_in_audio_prompt(current_in_history):
plugin = Qwen3OmniDuplexPlugin(lambda *args: None)
plugin.processor = SimpleNamespace(apply_chat_template=lambda messages, **kwargs: repr(messages))
state = plugin.create_session_state()
first = {"audio": base64.b64encode(np.full(160, 0.1, dtype="<f4").tobytes()).decode()}
current = {"audio": base64.b64encode(np.full(320, 0.2, dtype="<f4").tobytes()).decode()}
history = [{"role": "user", "content": [{"type": "audio_url"}]}]
plugin.prepare_prompt_config({"conversation": history}, state=state, payload=first)
# Playback was interrupted before any unaligned Thinker text was committed.
# The assistant turn still separates the old request from the new utterance.
history.append({"role": "assistant", "content": ""})
if current_in_history:
history.append({"role": "user", "content": [{"type": "audio_url"}]})
config = plugin.prepare_prompt_config({"conversation": history}, state=state, payload=current)
plan = plugin.plan_append(
request_id="after-interruption",
fence=None,
session_config=config,
runtime_config={},
seq=1,
turn_seq=1,
payload=current,
final=True,
sampling_params=None,
)
assert plan.prompt["prompt"] == repr(
[
{"role": "user", "content": [{"type": "audio"}]},
{"role": "assistant", "content": ""},
{"role": "user", "content": [{"type": "audio"}]},
]
)
audios = plan.prompt["multi_modal_data"]["audio"]
assert len(audios) == 2
np.testing.assert_allclose(audios[0][0], np.full(160, 0.1, dtype="<f4"))
np.testing.assert_allclose(audios[1][0], np.full(320, 0.2, dtype="<f4"))
@pytest.mark.asyncio
async def test_partial_playback_does_not_commit_unaligned_thinker_text():
h = await open_qwen()
@@ -300,7 +539,7 @@ async def test_partial_playback_does_not_commit_unaligned_thinker_text():
assert not any(m["role"] == "assistant" for m in h.session.history)
await h.deliver_and_settle(tts_output(request_id, samples=216000, finished=True), stage_id=2)
await h.run(AckPlayback(response_id=response_id, played_ms=1000))
assert not any(m["role"] == "assistant" for m in h.session.history)
assert [m for m in h.session.history if m["role"] == "assistant"] == [{"role": "assistant", "content": ""}]
await h.run(AckPlayback(response_id=response_id, played_ms=10000))
assert h.session.history[-1]["content"] == "entire answer"
finally:
@@ -416,6 +655,90 @@ async def test_image_context_reaches_audio_turn_and_survives_commit():
await h.manager.shutdown()
@pytest.mark.asyncio
async def test_multiple_commits_before_response_keep_one_combined_audio_in_history():
from vllm_omni.engine.duplex.commands import CreateItem
h = await open_qwen()
try:
await h.run(append_audio(value=0.1))
await h.run(Commit(final=True, create_response=False))
await h.run(CreateItem(item=image_item()))
await h.run(append_audio(value=0.2))
await h.run(Commit(final=True, create_response=False))
await h.run(CreateResponse())
assert len(h.port.submissions) == 1, [e.to_realtime() for e in h.events]
first = h.port.submissions[-1]
audio = first.prompt["multi_modal_data"]["audio"]
assert len(audio) == 1
np.testing.assert_allclose(audio[0][0], np.repeat(np.array([0.1, 0.2], dtype=np.float32), 16000))
response_id = h.session.active_response_id
await h.deliver_and_settle(
tts_output(first.context.request_id, samples=0, text="combined answer", finished=True), stage_id=0
)
await h.deliver_and_settle(tts_output(first.context.request_id, finished=True), stage_id=2)
await h.run(AckPlayback(response_id=response_id, played_ms=1000, committed_ms=1000))
await h.run(append_audio(value=0.3))
await h.run(Commit(final=True, create_response=True))
next_audio = h.port.submissions[-1].prompt["multi_modal_data"]["audio"]
assert len(next_audio) == 2
np.testing.assert_array_equal(next_audio[0][0], audio[0][0])
np.testing.assert_allclose(next_audio[1][0], 0.3)
finally:
await h.manager.shutdown()
@pytest.mark.asyncio
@pytest.mark.parametrize("image_after_commit", [False, True])
async def test_multimodal_turn_is_merged_retained_and_evicted_with_its_answer(image_after_commit):
from vllm_omni.engine.duplex.commands import CreateItem
h = await open_qwen()
try:
for turn in range(6):
if turn == 0 and not image_after_commit:
await h.run(CreateItem(item=image_item()))
await h.run(append_audio(value=(turn + 1) / 10))
await h.run(Commit(final=True, create_response=False))
assert len(h.port.submissions) == turn
if turn == 0 and image_after_commit:
await h.run(CreateItem(item=image_item()))
await h.run(CreateResponse())
submission = h.port.submissions[-1]
prompt = submission.prompt
messages = ast.literal_eval(prompt["prompt"])
first_retained = max(0, turn - 3)
assert [m["role"] for m in messages] == ["user", "assistant"] * (turn - first_retained) + ["user"]
assert [m["content"] for m in messages if m["role"] == "assistant"] == [
f"answer-{i}" for i in range(first_retained, turn)
]
audios = prompt["multi_modal_data"]["audio"]
assert len(audios) == turn - first_retained + 1
for index, (audio, rate) in enumerate(audios, start=first_retained):
assert rate == 16000
np.testing.assert_allclose(audio, (index + 1) / 10)
assert len(prompt["multi_modal_data"].get("image", [])) == int(first_retained == 0)
if first_retained == 0:
parts = messages[0]["content"]
assert sum(p["type"] == "image" for p in parts) == 1
assert sum(p["type"] == "audio" for p in parts) == 1
assert {"type": "text", "text": "What color is this?"} in parts
response_id = h.session.active_response_id
request_id = submission.context.request_id
await h.deliver_and_settle(
tts_output(request_id, samples=0, text=f"answer-{turn}", finished=True), stage_id=0
)
await h.deliver_and_settle(tts_output(request_id, finished=True), stage_id=2)
await h.run(AckPlayback(response_id=response_id, played_ms=1000, committed_ms=1000))
assert h.session.history[-1]["content"] == f"answer-{turn}"
# Prompt eviction leaves source items individually addressable by the client.
assert "camera" in h.session.history_item_ids
assert not any(e.to_realtime().get("type") == "error" for e in h.events)
finally:
await h.manager.shutdown()
@pytest.mark.asyncio
async def test_image_only_item_reaches_the_prompt_the_way_the_camera_sends_it():
"""The camera sends each frame as its own item, with no text part.