[WIP][Bugfix][MiniCPM-o] Fix Full duplex bugs for minicpm-challenge (#5496)

Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
Sy03 authored and GitHub committed 2026-07-28 15:59:26 +08:00
1 parent fb89ab43ea
commit ffa9a6af69
60 files changed
+3696 -634

No files matched your search

Binary file not shown.
Binary file not shown.
@@ -242,9 +242,52 @@ def test_explicit_streaming_payload_replaces_placeholder_prompt() -> None:
"tts_token_ids": [10, 20],
"meta": {"replace_streaming_prompt": True},
}
update.model_intermediate_buffer = {
"ids": {"tts": [41, 42, 99]},
"meta": {"turn_eos_token_id": 99},
}
sched._update_request_as_session(session, update)
assert session.prompt_token_ids == [10, 20]
assert session.additional_information == update.additional_information
assert session.model_intermediate_buffer == {
"ids": {"tts": [41, 42, 99]},
"meta": {"turn_eos_token_id": 99},
}
assert session.status == RequestStatus.WAITING
def test_model_intermediate_streaming_payload_replaces_computed_prompt() -> None:
sched = _make_scheduler(stage_id=1)
sched.chunk_transfer_adapter = SimpleNamespace(
receives_chunks=False,
segment_finished_requests=set(),
)
session = _make_request()
session.status = RequestStatus.WAITING_FOR_STREAMING_REQ
session.prompt_token_ids = [0] * 59
session._all_token_ids.clear()
session._all_token_ids.extend(session.prompt_token_ids)
session.num_prompt_tokens = 59
session.num_computed_tokens = 59
update = _make_update([0] * 10)
update.additional_information = None
update.model_intermediate_buffer = {
"ids": {"tts": list(range(8))},
"hidden_states": {"tts": [[0.0]] * 8},
"meta": {
"replace_streaming_prompt": True,
"next_stage_prompt_len": 10,
},
}
sched._update_request_as_session(session, update)
assert session.prompt_token_ids == [0] * 10
assert list(session._all_token_ids) == [0] * 10
assert session.num_prompt_tokens == 10
assert session.num_computed_tokens == 0
assert session.additional_information is None
assert session.model_intermediate_buffer == update.model_intermediate_buffer
assert session.status == RequestStatus.WAITING
@@ -54,6 +54,18 @@ class _SchedulerStub(OmniGenerationScheduler):
raise AssertionError("unexpected enqueue for skipped_waiting miss")
class _AsyncChunkStopSchedulerStub(OmniGenerationScheduler):
def __init__(self) -> None:
self.num_waiting_for_streaming_input = 0
self.chunk_transfer_adapter = SimpleNamespace(receives_chunks=True)
self.enqueued: list[Request] = []
self.enqueued_statuses: list[RequestStatus] = []
def _enqueue_waiting_request(self, session: Request) -> None:
self.enqueued.append(session)
self.enqueued_statuses.append(session.status)
def _make_request(**kwargs) -> Request:
sp = SamplingParams(max_tokens=8)
defaults = dict(
@@ -133,6 +145,21 @@ def test_resumable_generation_stop_marks_segment_boundary() -> None:
assert output.is_segment_finished is True
def test_async_chunk_resumable_stop_rearms_connector_polling() -> None:
sched = _AsyncChunkStopSchedulerStub()
session = _make_request(request_id="req-async-chunk-next-segment")
session.status = RequestStatus.FINISHED_STOPPED
session.resumable = True
finished = sched._handle_stopped_request(session)
assert finished is False
assert sched.enqueued == [session]
assert sched.enqueued_statuses == [RequestStatus.WAITING]
assert session.status == RequestStatus.WAITING
assert sched.num_waiting_for_streaming_input == 0
class TestReplaceSessionWithStreamingUpdate:
def test_resets_tokens_and_prompt_from_update(self) -> None:
sched = _SchedulerStub()
@@ -22,10 +22,7 @@ _MINICPMO_SERVER = [
model="openbmb/MiniCPM-o-4_5",
stage_config_path=_MINICPMO_DEPLOY,
use_stage_cli=True,
server_args=[
"--trust-remote-code",
"--no-async-chunk",
],
server_args=["--trust-remote-code"],
),
id="minicpmo_4_5",
)
@@ -71,6 +71,30 @@ def test_streaming_payload_can_replace_placeholder_prompt(mocker: MockerFixture)
request.update_block_hashes.assert_called_once_with()
def test_streaming_payload_can_append_exact_prompt_length(mocker: MockerFixture) -> None:
request = SimpleNamespace(
_all_token_ids=[0, 0, 7, 8],
_output_token_ids=[7, 8],
prompt_token_ids=[0, 0],
num_computed_tokens=4,
num_prompt_tokens=2,
update_block_hashes=mocker.Mock(),
)
payload = {
"ids": {"prompt": [1, 2, 3]},
"meta": {"next_stage_prompt_len": 3},
}
construct_next_stage_streaming_input_prompt(payload, request)
assert request.prompt_token_ids == [0, 0, 7, 8, 0, 0, 0]
assert request._all_token_ids == [0, 0, 7, 8, 0, 0, 0]
assert request._output_token_ids == []
assert request.num_computed_tokens == 4
assert request.num_prompt_tokens == 7
request.update_block_hashes.assert_called_once_with()
@pytest.fixture
def build_adapter(monkeypatch, mocker: MockerFixture):
def _build(
@@ -252,6 +276,37 @@ def test_save_async_uses_confirmed_tokens_for_async_scheduler_watermark(build_ad
assert len(adapter._pending_save_reqs) == 1
def test_send_single_request_terminal_chunk_still_flushes_processor(build_adapter, monkeypatch):
"""A terminal stop is not a segment boundary (#5383), but the producer-side
processor must still receive the flush signal on the terminal chunk.
Passing only ``is_segment_finished`` starved processors of their final
accumulated payload once terminal stops stopped setting it (#5413: the
downstream stage got ``meta.finished`` with the tail data missing).
"""
adapter, connector = build_adapter(stage_id=0)
request = _req("req-terminal", RequestStatus.FINISHED_STOPPED, external_req_id="ext-terminal")
seen_flush_flags = []
def recording_processor(**kwargs):
seen_flush_flags.append(kwargs["is_finished"])
return OmniPayloadStruct(
codes=CodesStruct(audio=torch.tensor([1, 2, 3], dtype=torch.long)),
)
adapter.custom_process_next_stage_input_func = recording_processor
monkeypatch.setattr(adapter, "cleanup", lambda *a, **kw: None)
adapter._send_single_request(
{"multimodal_output": None, "request": request, "is_finished": True, "is_segment_finished": False}
)
assert seen_flush_flags == [True]
sent_payload = connector.put.call_args.kwargs["data"]
assert bool(sent_payload.meta.finished.item()) is True
assert bool(sent_payload.meta.is_segment_finished.item()) is False
def test_send_single_request_struct_without_meta_does_not_crash(build_adapter, monkeypatch):
"""Producer may return a struct with ``meta=None`` (e.g. payload that
carries only ``embed`` or ``codes``). The sender's ``meta is not None``
@@ -314,6 +369,23 @@ def test_send_single_request_struct_preserves_segment_finished(build_adapter, mo
assert sent_payload.meta.is_segment_finished.item() is True
def test_send_single_request_respects_processor_receiver_boundary(build_adapter, monkeypatch):
adapter, connector = build_adapter(stage_id=1)
request = _req("req-stream", RequestStatus.WAITING, external_req_id="ext-stream")
adapter.custom_process_next_stage_input_func = lambda **kwargs: OmniPayloadStruct(
meta=MetaStruct(is_segment_finished=torch.tensor(False, dtype=torch.bool))
)
monkeypatch.setattr(adapter, "cleanup", lambda *a, **kw: None)
adapter._send_single_request(
{"multimodal_output": None, "request": request, "is_finished": False, "is_segment_finished": True}
)
sent_payload = connector.put.call_args.kwargs["data"]
assert sent_payload.meta.is_segment_finished.item() is False
def test_save_async_skips_stale_resumable_chunk_until_dedup_is_reset(build_adapter):
adapter, _ = build_adapter(stage_id=1)
request = _req("req-stream", RequestStatus.WAITING, external_req_id="ext-stream")
@@ -463,7 +535,7 @@ def test_fifo_promotion(build_adapter):
adapter.process_pending_chunks(waiting_queue, running_queue)
assert list(adapter._active_streams) == ["req-1", "req-2"]
assert waiting_queue == reqs[2:]
assert waiting_queue == []
assert reqs[0].status == RequestStatus.WAITING_FOR_CHUNK
assert reqs[1].status == RequestStatus.WAITING_FOR_CHUNK
assert reqs[2].status == RequestStatus.WAITING
@@ -473,6 +545,7 @@ def test_fifo_promotion(build_adapter):
# (commit c4d95fd9 — otherwise the terminal chunk deadlocks at c=8 K=2).
# Simulate it here so promotion can pick up the freed slot.
adapter._evict_finished_active_streams({"req-1"})
adapter.restore_queues(waiting_queue, running_queue)
adapter.process_pending_chunks(waiting_queue, running_queue)
assert list(adapter._active_streams) == ["req-2", "req-3"]
@@ -481,6 +554,22 @@ def test_fifo_promotion(build_adapter):
assert reqs[2].status == RequestStatus.WAITING_FOR_CHUNK
def test_non_active_waiting_request_is_held_off_scheduler(build_adapter):
adapter, _ = build_adapter(stage_id=2, max_num_seqs=2, active_stream_window=1)
active = _req("req-active", RequestStatus.WAITING)
non_active = _req("req-non-active", RequestStatus.WAITING)
waiting_queue = DummyWaitingQueue([active, non_active])
running_queue = []
adapter.process_pending_chunks(waiting_queue, running_queue)
assert waiting_queue == []
assert active.status == RequestStatus.WAITING_FOR_CHUNK
assert non_active.status == RequestStatus.WAITING
assert list(adapter._pending_load_reqs) == [active]
assert list(adapter.waiting_for_chunk_waiting_requests) == [active, non_active]
def test_legacy_k0(build_adapter):
adapter, _ = build_adapter(stage_id=1, max_num_seqs=1, active_stream_window=0)
waiting_req = _req("waiting", RequestStatus.WAITING)
@@ -509,12 +598,13 @@ def test_finished_releases_slot(build_adapter):
adapter.process_pending_chunks(waiting_queue, running_queue)
assert list(adapter._active_streams) == ["req-1"]
assert waiting_queue == [req_2]
assert waiting_queue == []
adapter.finished_requests.add("req-1")
# Eviction is deferred to postprocess_scheduler_output in the runtime path
# (commit c4d95fd9). Simulate it so promotion can pick up the freed slot.
adapter._evict_finished_active_streams({"req-1"})
adapter.restore_queues(waiting_queue, running_queue)
adapter.process_pending_chunks(waiting_queue, running_queue)
assert list(adapter._active_streams) == ["req-2"]
@@ -83,7 +83,7 @@ def test_video_to_text(omni_runner, omni_runner_handler) -> None:
@hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=2)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_text_to_audio(omni_runner, omni_runner_handler) -> None:
"""Test processing text, generating audio output through the talker token2wav path."""
"""Test processing text and generating audio through Talker and Code2Wav."""
request_config = {"prompts": get_question("text"), "modalities": ["audio"]}
omni_runner_handler.send_omni_request(request_config)
@@ -1174,10 +1174,13 @@ async def _send_listen_only_overlap_pair(
before_second_listen = state.model_listen_count
overlap_started_while_active = not state.response_done(first_response_id)
model_unit_ready_while_active = False
second_unit_event_index: int | None = None
def record_model_unit_ready() -> None:
nonlocal model_unit_ready_while_active
nonlocal model_unit_ready_while_active, second_unit_event_index
model_unit_ready_while_active = not state.response_done(first_response_id)
if model_unit_ready_while_active:
second_unit_event_index = len(state.events)
await _send_pcm16(
ws,
@@ -1239,14 +1242,13 @@ async def _send_listen_only_overlap_pair(
label=transcripts[1],
)
second_speech_index = _nth_event_index(state, "input_audio_buffer.speech_started", 2)
first_done_index = _event_index_for_response(state, "response.done", first_response_id)
overlap_ok = (
overlap_started_while_active
and model_unit_ready_while_active
and second_speech_index is not None
and second_unit_event_index is not None
and first_done_index is not None
and second_speech_index < first_done_index
and second_unit_event_index < first_done_index
)
return [first_response_id, second_response_id], ["speak", second_outcome], overlap_ok
@@ -1380,6 +1382,17 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
await ws.send(json.dumps({"type": "session.close"}))
await _wait_for(state, lambda: state.count("session.closed") > 0, timeout_s=20, label="session.closed")
finally:
if state.count("session.created") > 0 and state.count("session.closed") == 0:
try:
await ws.send(json.dumps({"type": "session.close"}))
await _wait_for(
state,
lambda: state.count("session.closed") > 0,
timeout_s=min(args.timeout_s, 5),
label="session.closed during cleanup",
)
except (ConnectionClosed, TimeoutError):
pass
stop.set()
reader.cancel()
try:
@@ -19,7 +19,11 @@ SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from minicpmo_realtime_duplex_scenarios import _url_with_model, run_demo # noqa: E402
from minicpmo_realtime_duplex_scenarios import ( # noqa: E402
_ref_audio_data_url,
_url_with_model,
run_demo,
)
def _with_resume_mode(url: str) -> str:
@@ -114,7 +118,12 @@ async def _open_admission_session(
args: argparse.Namespace,
session_id: str,
) -> tuple[object, dict[str, object]]:
url = _url_with_model(args.url, args.model, session_id=session_id)
url = _url_with_model(
args.url,
args.model,
autostart=False if getattr(args, "ref_audio", None) else None,
session_id=session_id,
)
ws = await websockets.connect(url, max_size=64 * 1024 * 1024)
await ws.send(
json.dumps(
@@ -125,6 +134,7 @@ async def _open_admission_session(
"model": args.model,
"modalities": ["audio", "text"],
"extra_body": {"minicpmo45_native_duplex": True},
**({"ref_audio": _ref_audio_data_url(args.ref_audio)} if getattr(args, "ref_audio", None) else {}),
},
}
)
@@ -151,7 +161,12 @@ async def _admission_probe(args: argparse.Namespace, *, limit: int) -> dict[str,
accepted.append(await _open_admission_session(args, f"{prefix}-accepted-{index}"))
overflow_id = f"{prefix}-overflow"
overflow_url = _url_with_model(args.url, args.model, session_id=overflow_id)
overflow_url = _url_with_model(
args.url,
args.model,
autostart=False if getattr(args, "ref_audio", None) else None,
session_id=overflow_id,
)
async with websockets.connect(overflow_url, max_size=64 * 1024 * 1024) as overflow:
await overflow.send(
json.dumps(
@@ -162,6 +177,11 @@ async def _admission_probe(args: argparse.Namespace, *, limit: int) -> dict[str,
"model": args.model,
"modalities": ["audio", "text"],
"extra_body": {"minicpmo45_native_duplex": True},
**(
{"ref_audio": _ref_audio_data_url(args.ref_audio)}
if getattr(args, "ref_audio", None)
else {}
),
},
}
)
@@ -207,16 +227,27 @@ async def _resume_probe(
session_id: str,
expect_expired: bool = False,
) -> dict[str, object]:
url = _url_with_model(args.url, args.model, session_id=session_id)
url = _url_with_model(
args.url,
args.model,
autostart=False if getattr(args, "ref_audio", None) else None,
session_id=session_id,
)
async with websockets.connect(url, max_size=64 * 1024 * 1024) as first:
await first.send(
json.dumps(
{
"type": "session.update",
"session": {
"session_id": session_id,
"model": args.model,
"modalities": ["audio", "text"],
"extra_body": {"minicpmo45_native_duplex": True},
**(
{"ref_audio": _ref_audio_data_url(args.ref_audio)}
if getattr(args, "ref_audio", None)
else {}
),
},
}
)
@@ -306,7 +337,12 @@ async def _takeover_probe(
*,
session_id: str,
) -> dict[str, object]:
url = _url_with_model(args.url, args.model, session_id=session_id)
url = _url_with_model(
args.url,
args.model,
autostart=False if getattr(args, "ref_audio", None) else None,
session_id=session_id,
)
first = await websockets.connect(url, max_size=64 * 1024 * 1024)
second = None
try:
@@ -315,9 +351,15 @@ async def _takeover_probe(
{
"type": "session.update",
"session": {
"session_id": session_id,
"model": args.model,
"modalities": ["audio", "text"],
"extra_body": {"minicpmo45_native_duplex": True},
**(
{"ref_audio": _ref_audio_data_url(args.ref_audio)}
if getattr(args, "ref_audio", None)
else {}
),
},
}
)
@@ -451,20 +493,7 @@ async def run_multi_session(args: argparse.Namespace) -> dict[str, object]:
args,
session_id=f"takeover-{args.takeover_session_index}-{uuid.uuid4().hex}",
)
expiry_result = None
if args.expire_session_index is not None:
if not 0 <= args.expire_session_index < args.sessions:
raise ValueError("--expire-session-index is outside the session range")
expiry_result = await _resume_probe(
args,
session_id=f"expire-{args.expire_session_index}-{uuid.uuid4().hex}",
expect_expired=True,
)
admission_result = (
await _admission_probe(args, limit=args.verify_admission_limit)
if args.verify_admission_limit is not None
else None
)
lifecycle_result = await run_lifecycle_probes(args)
session_results = await asyncio.gather(
*(run_demo(_demo_args(args, index)) for index in range(args.sessions)),
@@ -487,16 +516,15 @@ async def run_multi_session(args: argparse.Namespace) -> dict[str, object]:
and semantic_isolation_ok
and (resume_result is None or resume_result.get("ok") is True)
and (takeover_result is None or takeover_result.get("ok") is True)
and (expiry_result is None or expiry_result.get("ok") is True)
and (admission_result is None or admission_result.get("ok") is True)
and lifecycle_result["ok"] is True
),
"session_count": args.sessions,
"identity_isolation_ok": identity_isolation_ok,
"semantic_isolation_ok": semantic_isolation_ok,
"resume": resume_result,
"takeover": takeover_result,
"expiry": expiry_result,
"admission": admission_result,
"expiry": lifecycle_result["expiry"],
"admission": lifecycle_result["admission"],
"failures": failures,
"sessions": completed,
}
@@ -507,6 +535,34 @@ async def run_multi_session(args: argparse.Namespace) -> dict[str, object]:
return result
async def run_lifecycle_probes(args: argparse.Namespace) -> dict[str, object]:
"""Run expiry and admission probes without requiring model output."""
if args.sessions < 1:
raise ValueError("--sessions must be positive")
expiry_result = None
if args.expire_session_index is not None:
if not 0 <= args.expire_session_index < args.sessions:
raise ValueError("--expire-session-index is outside the session range")
expiry_result = await _resume_probe(
args,
session_id=f"expire-{args.expire_session_index}-{uuid.uuid4().hex}",
expect_expired=True,
)
admission_result = (
await _admission_probe(args, limit=args.verify_admission_limit)
if args.verify_admission_limit is not None
else None
)
return {
"ok": (
(expiry_result is None or expiry_result.get("ok") is True)
and (admission_result is None or admission_result.get("ok") is True)
),
"expiry": expiry_result,
"admission": admission_result,
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--url", default="ws://127.0.0.1:8113/v1/realtime?duplex=1")
@@ -2,8 +2,8 @@
This E2E driver runs the public ``realtime_duplex_demo.py`` against a live
duplex backend. Arbitrary audio defaults to the model-policy lifecycle contract.
The stronger response-required mode binds the two-response contract to a known
input checksum and expected second response.
The stronger response-required mode binds the multi-response contract to a
known input checksum and expected follow-up response.
"""
from __future__ import annotations
@@ -39,6 +39,17 @@ def _validate_input_sha256(path: Path, expected: str) -> str:
return actual
def _input_duration_s(path: Path) -> float:
with wave.open(str(path), "rb") as wav_file:
return wav_file.getnframes() / wav_file.getframerate()
def _client_process_timeout_s(input_wav: Path, protocol_timeout_s: float) -> float:
# The child first streams the WAV in real time, then can independently
# exhaust its post-commit and session-close protocol waits.
return _input_duration_s(input_wav) + 2 * protocol_timeout_s + 30.0
def _read_json(path: Path) -> dict[str, object]:
if not path.is_file():
return {}
@@ -211,7 +222,7 @@ def summarize_artifacts(
validation_mode: str,
min_responses: int,
min_audio_deltas_per_response: int,
expect_second_response_substring: str | None,
expect_followup_response_substring: str | None,
) -> dict[str, object]:
if validation_mode not in {"model-policy", "response-required"}:
raise ValueError(f"unsupported validation mode: {validation_mode}")
@@ -274,12 +285,20 @@ def summarize_artifacts(
)
final_listen_after_commit = commit_index is not None and any(index > commit_index for index in listen_indices)
transcript = "".join(str(summary.get("transcript") or "") for summary in response_summaries)
second_response_transcript = (
str(response_summaries[1].get("transcript") or "") if len(response_summaries) >= 2 else ""
followup_response_transcripts = [
str(summary.get("transcript") or "")
for summary in response_summaries[1:]
if commit_index is not None
and isinstance(summary.get("created_index"), int)
and summary["created_index"] < commit_index
]
followup_response_transcript_ok = any(
bool(_normalize_text(transcript)) for transcript in followup_response_transcripts
)
followup_response_transcript_expectation_ok = not expect_followup_response_substring or any(
_normalize_text(expect_followup_response_substring) in _normalize_text(transcript)
for transcript in followup_response_transcripts
)
second_response_transcript_expectation_ok = not expect_second_response_substring or _normalize_text(
expect_second_response_substring
) in _normalize_text(second_response_transcript)
error_events = [
event
for event in events
@@ -310,9 +329,8 @@ def summarize_artifacts(
mode_contract_ok = validation_mode == "model-policy" or (
second_response_before_final_commit
and listen_before_first_response
and listen_between_responses
and listen_after_last_done
and second_response_transcript_expectation_ok
and followup_response_transcript_ok
)
ok = common_contract_ok and mode_contract_ok
return {
@@ -337,9 +355,10 @@ def summarize_artifacts(
"listen_after_response_before_commit": listen_after_response_before_commit,
"final_listen_after_commit": final_listen_after_commit,
"transcript": transcript,
"second_response_transcript": second_response_transcript,
"expect_second_response_substring": expect_second_response_substring,
"second_response_transcript_expectation_ok": second_response_transcript_expectation_ok,
"followup_response_transcripts": followup_response_transcripts,
"followup_response_transcript_ok": followup_response_transcript_ok,
"expect_followup_response_substring": expect_followup_response_substring,
"followup_response_transcript_expectation_ok": followup_response_transcript_expectation_ok,
"error_count": len(error_events),
"cancelled_count": cancelled_count,
"compact_sequence": _compact_sequence(events),
@@ -388,7 +407,7 @@ async def run_soft_interrupt(args: argparse.Namespace) -> dict[str, object]:
try:
stdout_bytes, stderr_bytes = await asyncio.wait_for(
process.communicate(),
timeout=args.timeout_s + 30.0,
timeout=_client_process_timeout_s(input_wav, args.timeout_s),
)
except TimeoutError:
process.kill()
@@ -406,7 +425,7 @@ async def run_soft_interrupt(args: argparse.Namespace) -> dict[str, object]:
validation_mode=args.validation_mode,
min_responses=args.min_responses,
min_audio_deltas_per_response=args.min_audio_deltas_per_response,
expect_second_response_substring=args.expect_second_response_substring,
expect_followup_response_substring=args.expect_followup_response_substring,
)
summary.update(
{
@@ -444,14 +463,12 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--min-responses", type=int, default=2)
parser.add_argument("--min-audio-deltas-per-response", type=int, default=2)
parser.add_argument("--input-sha256")
parser.add_argument("--expect-second-response-substring")
parser.add_argument("--expect-followup-response-substring")
args = parser.parse_args()
if args.validation_mode == "response-required" and args.min_responses < 2:
parser.error("--min-responses must be at least 2")
if args.validation_mode == "response-required" and not args.input_sha256:
parser.error("--input-sha256 is required in response-required mode")
if args.validation_mode == "response-required" and not args.expect_second_response_substring:
parser.error("--expect-second-response-substring is required in response-required mode")
if args.min_audio_deltas_per_response < 1:
parser.error("--min-audio-deltas-per-response must be positive")
return args
+7 -11
View File
@@ -1,9 +1,7 @@
"""
E2E Online tests for MiniCPM-o 4.5 model with multimodal input and audio / text output.
"""E2E online tests for MiniCPM-o 4.5 multimodal input and audio/text output.
MiniCPM-o 4.5 has ``async_chunk: false``, ``max_num_seqs: 1`` on both stages,
and the vocoder runs in-process inside the talker stage rather than as a separate
Code2Wav stage.
The batching deployment uses async chunk transfer across separate Thinker,
Talker, and Code2Wav stages.
"""
import os
@@ -26,10 +24,7 @@ test_params = [
model=_MODEL,
stage_config_path=_CI_DEPLOY,
use_stage_cli=True,
server_args=[
"--trust-remote-code",
"--no-async-chunk",
],
server_args=["--trust-remote-code"],
),
id="default",
)
@@ -99,7 +94,7 @@ def test_text_to_text_001(omni_server, openai_client) -> None:
def test_text_to_audio_001(omni_server, openai_client) -> None:
"""
Test text-only input generating text + audio output via OpenAI API.
This exercises the talker TTS region detection and in-process token2wav vocoder.
This exercises Talker TTS region detection and the Code2Wav stage.
Deploy Setting: default 2GPU
Input Modal: text
Output Modal: text + audio
@@ -133,13 +128,14 @@ def test_audio_to_text_audio_001(omni_server, openai_client) -> None:
messages = dummy_messages_from_mix_data(
system_prompt=get_system_prompt(),
audio_data_url=audio_data_url,
content_text=get_prompt("mix"),
content_text=get_prompt(),
)
request_config = {
"model": omni_server.model,
"messages": messages,
"stream": True,
"key_words": {"text": ["Beijing"]},
}
openai_client.send_omni_request(request_config, request_num=get_max_batch_size())
@@ -0,0 +1,147 @@
"""CI coverage for the MiniCPM-o 4.5 native-duplex Realtime API."""
from __future__ import annotations
import asyncio
import json
import uuid
from pathlib import Path
import pytest
import websockets
from tests.e2e.online_serving.minicpmo_realtime_duplex_scenarios import (
_ref_audio_data_url,
run_demo,
)
from tests.e2e.online_serving.run_minicpmo_realtime_duplex_multi_session import (
run_multi_session,
)
from tests.helpers.mark import hardware_test
from tests.helpers.minicpmo_4_5_duplex import (
CORE_SERVER_PARAMS,
SERVER_PARAMS,
demo_args,
multi_session_args,
realtime_url,
resolve_ref_audio,
validated_input_wav,
)
from vllm_omni.experimental.fullduplex.client import build_realtime_url
pytestmark = pytest.mark.omni
async def _receive_protocol_events(ws, required_types: set[str], *, timeout_s: float) -> list[dict[str, object]]:
async def receive() -> list[dict[str, object]]:
events: list[dict[str, object]] = []
seen: set[str] = set()
while not required_types.issubset(seen):
raw = await ws.recv()
if not isinstance(raw, str):
continue
event = json.loads(raw)
if not isinstance(event, dict):
continue
events.append(event)
event_type = event.get("type")
if event_type == "error":
raise AssertionError(f"WebSocket protocol smoke received an error: {event}")
if isinstance(event_type, str):
seen.add(event_type)
return events
return await asyncio.wait_for(receive(), timeout=timeout_s)
async def _run_protocol_smoke(*, url: str, model: str, ref_audio: Path) -> list[dict[str, object]]:
session_id = f"duplex-ci-protocol-{uuid.uuid4().hex}"
websocket_url = build_realtime_url(url, model, autostart=False, session_id=session_id)
async with websockets.connect(websocket_url, max_size=64 * 1024 * 1024) as ws:
await ws.send(
json.dumps(
{
"type": "session.update",
"session": {
"session_id": session_id,
"model": model,
"modalities": ["audio", "text"],
"ref_audio": _ref_audio_data_url(str(ref_audio)),
"extra_body": {"minicpmo45_native_duplex": True},
},
}
)
)
events = await _receive_protocol_events(
ws,
{"session.created", "session.updated"},
timeout_s=60,
)
await ws.send(json.dumps({"type": "session.close"}))
events.extend(await _receive_protocol_events(ws, {"session.closed"}, timeout_s=60))
return events
@pytest.mark.core_model
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_server", CORE_SERVER_PARAMS, indirect=True)
def test_duplex_websocket_protocol_smoke(omni_server, model_prefix: str) -> None:
ref_audio = resolve_ref_audio(model_prefix)
events = asyncio.run(
_run_protocol_smoke(
url=realtime_url(omni_server),
model=omni_server.model,
ref_audio=ref_audio,
)
)
event_types = [event.get("type") for event in events]
assert "session.created" in event_types
assert "session.updated" in event_types
assert event_types[-1] == "session.closed"
@pytest.mark.advanced_model
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
def test_duplex_single_session_response_required(omni_server, model_prefix: str, tmp_path: Path) -> None:
result = asyncio.run(
run_demo(
demo_args(
omni_server=omni_server,
input_wav=validated_input_wav(),
ref_audio=resolve_ref_audio(model_prefix),
output_dir=tmp_path / "single_session",
)
)
)
assert result["ok"] is True
assert result["audio_delta_count"] > 0
assert result["done_count"] == 1
assert result["error_count"] == 0
assert result["all_audio_responses_have_transcript"] is True
assert result["transcript_delta_done_ok"] is True
@pytest.mark.advanced_model
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
def test_duplex_two_sessions_resume_and_takeover(omni_server, model_prefix: str, tmp_path: Path) -> None:
result = asyncio.run(
run_multi_session(
multi_session_args(
omni_server=omni_server,
input_wav=validated_input_wav(),
ref_audio=resolve_ref_audio(model_prefix),
output_dir=tmp_path / "multi_session",
response_required=True,
)
)
)
assert result["ok"] is True
assert result["session_count"] == 2
assert result["resume"]["ok"] is True
assert result["takeover"]["ok"] is True
assert not result["failures"]
assert all(session["audio_delta_count"] > 0 for session in result["sessions"])
assert all(session["done_count"] == 1 for session in result["sessions"])
assert all(session["error_count"] == 0 for session in result["sessions"])
@@ -0,0 +1,86 @@
"""Nightly lifecycle coverage for the MiniCPM-o 4.5 native-duplex API."""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from tests.e2e.online_serving.run_minicpmo_realtime_duplex_multi_session import (
run_lifecycle_probes,
)
from tests.e2e.online_serving.run_minicpmo_realtime_duplex_soft_interrupt import (
run_soft_interrupt,
)
from tests.helpers.mark import hardware_test
from tests.helpers.minicpmo_4_5_duplex import (
SERVER_PARAMS,
SOFT_INTERRUPT_SHA256,
multi_session_args,
realtime_url,
resolve_ref_audio,
validated_input_wav,
validated_soft_interrupt_wav,
)
pytestmark = [pytest.mark.full_model, pytest.mark.omni]
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
def test_duplex_admission_and_expiry_reaper(omni_server, model_prefix: str, tmp_path: Path) -> None:
args = multi_session_args(
omni_server=omni_server,
input_wav=validated_input_wav(),
ref_audio=resolve_ref_audio(model_prefix),
output_dir=tmp_path / "admission_expiry",
response_required=False,
)
args.sessions = 1
args.disconnect_session_index = None
args.takeover_session_index = None
args.expire_session_index = 0
args.verify_admission_limit = 2
result = asyncio.run(run_lifecycle_probes(args))
assert result["ok"] is True, json.dumps(result, ensure_ascii=False, indent=2)
assert result["expiry"]["ok"] is True
assert result["expiry"]["error_code"] == "session_resume_expired"
assert result["admission"]["ok"] is True
assert result["admission"]["overflow_error_code"] == "resource_exhausted"
@hardware_test(res={"cuda": "H100"}, num_cards=2)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
def test_duplex_soft_interrupt(omni_server, model_prefix: str, tmp_path: Path) -> None:
input_wav = validated_soft_interrupt_wav()
result = asyncio.run(
run_soft_interrupt(
SimpleNamespace(
url=realtime_url(omni_server),
model=omni_server.model,
input_wav=str(input_wav),
ref_audio=str(resolve_ref_audio(model_prefix)),
output_dir=str(tmp_path / "soft_interrupt"),
summary_output=None,
chunk_ms=200,
timeout_s=180.0,
require_audio=True,
no_realtime_pacing=False,
validation_mode="response-required",
min_responses=2,
min_audio_deltas_per_response=2,
input_sha256=SOFT_INTERRUPT_SHA256,
expect_followup_response_substring=None,
)
)
)
assert result["ok"] is True, json.dumps(result, ensure_ascii=False, indent=2)
assert result["error_count"] == 0
assert result["response_lifecycle_ok"] is True
assert result["response_audio_contract_ok"] is True
assert result["followup_response_transcript_ok"] is True
@@ -1,7 +1,7 @@
"""
E2E Online expansion tests for MiniCPM-o 4.5 covering modality combinations,
in-process token2wav vocoder behavior, max_num_seqs=1 serial execution,
and edge-case validation.
"""E2E online expansion tests for MiniCPM-o 4.5.
These cover modality combinations, separate Code2Wav behavior, sequential
request isolation, longer audio output, and Chinese speech.
"""
import os
@@ -94,10 +94,8 @@ def test_text_video_to_text_001(omni_server, openai_client) -> None:
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_sequential_requests_independent(omni_server, openai_client) -> None:
"""
Verify that sequential requests produce independent results and state
does not leak between requests. MiniCPM-o 4.5 has max_num_seqs=1 so
requests are processed one-at-a-time; the second request must not
receive the first request's audio or token state.
Verify that sequential requests produce independent results and that the
second request does not receive the first request's audio or token state.
Deploy Setting: default 2GPU
Input Modal: text (two different prompts)
Output Modal: text + audio (both)
@@ -138,7 +136,7 @@ def test_sequential_requests_independent(omni_server, openai_client) -> None:
def test_text_to_audio_long_output_001(omni_server, openai_client) -> None:
"""
Test text input generating a longer audio output to exercise the
token2wav decoder across multiple frames.
Code2Wav stage across multiple frames.
Deploy Setting: default 2GPU
Input Modal: text (longer prompt)
Output Modal: text + audio
@@ -146,14 +144,14 @@ def test_text_to_audio_long_output_001(omni_server, openai_client) -> None:
"""
messages = dummy_messages_from_mix_data(
system_prompt=get_system_prompt(),
content_text="Tell me a short story about a cat in about 50 words.",
content_text="What is the capital of China? Answer in 40 words.",
)
request_config = {
"model": omni_server.model,
"messages": messages,
"stream": True,
"key_words": {"audio": ["cat"]},
"key_words": {"audio": ["Beijing"]},
}
openai_client.send_omni_request(request_config, request_num=get_max_batch_size())
@@ -12,14 +12,11 @@ import pytest
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
DEMO_PATH = Path(__file__).resolve().parents[1] / "e2e/online_serving/minicpmo_realtime_duplex_scenarios.py"
MULTI_DEMO_PATH = (
Path(__file__).resolve().parents[1] / "e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py"
)
PAIR_DEMO_PATH = Path(__file__).resolve().parents[1] / "e2e/online_serving/run_minicpmo_realtime_duplex_demo_pair.py"
SOFT_INTERRUPT_DEMO_PATH = (
Path(__file__).resolve().parents[1] / "e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py"
)
DRIVER_DIR = Path(__file__).resolve().parent
DEMO_PATH = DRIVER_DIR / "minicpmo_realtime_duplex_scenarios.py"
MULTI_DEMO_PATH = DRIVER_DIR / "run_minicpmo_realtime_duplex_multi_session.py"
PAIR_DEMO_PATH = DRIVER_DIR / "run_minicpmo_realtime_duplex_demo_pair.py"
SOFT_INTERRUPT_DEMO_PATH = DRIVER_DIR / "run_minicpmo_realtime_duplex_soft_interrupt.py"
def _load_demo_module():
@@ -338,14 +335,7 @@ def test_realtime_duplex_soft_interrupt_accepts_explicit_ref_audio(monkeypatch):
assert args.validation_mode == "model-policy"
@pytest.mark.parametrize(
"extra_args",
[
["--expect-second-response-substring", "一加一等于二"],
["--input-sha256", "a" * 64],
],
)
def test_realtime_duplex_soft_interrupt_response_required_needs_bound_fixture(monkeypatch, extra_args):
def test_realtime_duplex_soft_interrupt_response_required_needs_bound_fixture(monkeypatch):
demo = _load_soft_interrupt_demo_module()
monkeypatch.setattr(
demo.sys,
@@ -360,7 +350,6 @@ def test_realtime_duplex_soft_interrupt_response_required_needs_bound_fixture(mo
"ref.wav",
"--validation-mode",
"response-required",
*extra_args,
],
)
@@ -412,7 +401,6 @@ def test_realtime_duplex_soft_interrupt_accepts_multi_delta_handoff_sequence(tmp
"_client_received_at_s": 2.6,
},
{"type": "response.done", "response_id": first_response_id, "_client_received_at_s": 3.0},
{"type": "response.listen", "_client_received_at_s": 3.2},
{"type": "response.created", "response": {"id": second_response_id}, "_client_received_at_s": 4.0},
{"type": "response.speak", "response_id": second_response_id, "_client_received_at_s": 4.0},
{
@@ -458,10 +446,11 @@ def test_realtime_duplex_soft_interrupt_accepts_multi_delta_handoff_sequence(tmp
validation_mode="response-required",
min_responses=2,
min_audio_deltas_per_response=2,
expect_second_response_substring="一加一等于二",
expect_followup_response_substring="一加一等于二",
)
assert summary["ok"] is True
assert summary["listen_between_responses"] is False
assert summary["second_response_before_final_commit"] is True
assert summary["final_listen_after_commit"] is True
assert summary["response_audio_contract_ok"] is True
@@ -498,7 +487,7 @@ def test_realtime_duplex_soft_interrupt_model_policy_accepts_single_response(tmp
validation_mode="model-policy",
min_responses=2,
min_audio_deltas_per_response=2,
expect_second_response_substring=None,
expect_followup_response_substring=None,
)
assert summary["ok"] is True
@@ -537,14 +526,14 @@ def test_realtime_duplex_soft_interrupt_response_required_rejects_single_respons
validation_mode="response-required",
min_responses=2,
min_audio_deltas_per_response=2,
expect_second_response_substring="一加一等于二",
expect_followup_response_substring="一加一等于二",
)
assert summary["ok"] is False
assert summary["enough_responses"] is False
def test_realtime_duplex_soft_interrupt_checks_expected_text_in_second_response(tmp_path):
def test_realtime_duplex_soft_interrupt_reports_text_expectation_without_gating(tmp_path):
demo = _load_soft_interrupt_demo_module()
output = tmp_path / "second_response_text"
output.mkdir()
@@ -611,11 +600,32 @@ def test_realtime_duplex_soft_interrupt_checks_expected_text_in_second_response(
validation_mode="response-required",
min_responses=2,
min_audio_deltas_per_response=2,
expect_second_response_substring="一加一等于二",
expect_followup_response_substring="一加一等于二",
)
assert summary["ok"] is False
assert summary["second_response_transcript_expectation_ok"] is False
assert summary["ok"] is True
assert summary["followup_response_transcript_ok"] is True
assert summary["followup_response_transcript_expectation_ok"] is False
events = [
event
for event in events
if event.get("response_id") != second_response_id or event["type"] != "response.audio_transcript.delta"
]
(output / "events.jsonl").write_text(
"".join(demo.json.dumps(event) + "\n" for event in events),
encoding="utf-8",
)
missing_transcript_summary = demo.summarize_artifacts(
output_dir=output,
validation_mode="response-required",
min_responses=2,
min_audio_deltas_per_response=2,
expect_followup_response_substring="一加一等于二",
)
assert missing_transcript_summary["ok"] is False
assert missing_transcript_summary["followup_response_transcript_ok"] is False
def test_realtime_duplex_multi_session_resume_url_disables_autostart():
@@ -1523,7 +1533,7 @@ def test_realtime_duplex_demo_waits_at_each_model_unit_and_stops_after_speak():
assert len(ws.messages) == 10
def test_realtime_duplex_demo_listen_only_overlap_sends_next_turn_before_first_done(monkeypatch):
def test_realtime_duplex_demo_listen_only_overlap_accepts_silence_unit_before_first_done(monkeypatch):
demo = _load_demo_module()
state = demo.DemoState()
send_calls = 0
@@ -1548,7 +1558,6 @@ def test_realtime_duplex_demo_listen_only_overlap_sends_next_turn_before_first_d
del args
assert not state.response_done("resp-first")
kwargs["on_model_unit_ready"]()
state.add({"type": "input_audio_buffer.speech_started", "turn": 2})
_add_response_transcript(state, "resp-first", transcript="第一轮完成", audio=False)
state.add({"type": "response.created", "response": {"id": "resp-second"}})
_add_response_transcript(state, "resp-second", transcript="第二轮完成", audio=True)
@@ -1568,7 +1577,7 @@ def test_realtime_duplex_demo_listen_only_overlap_sends_next_turn_before_first_d
ws,
state,
b"\x00\x00",
b"\x01\x00",
b"\x00\x00",
transcripts=("first", "second"),
durations_ms=(None, None),
chunk_ms=200,
@@ -18,6 +18,7 @@ def test_chunk_accumulation_policy_replaces_snapshots_and_drains_delta_state():
accumulated = MultimodalPayload.from_dict(
{
"audio": torch.tensor([1.0]),
"meta.segment_end": torch.tensor([0]),
"meta.tts_is_last_chunk": torch.tensor([0]),
"meta.turn_end": torch.tensor([0]),
"meta.stable_request_value": "keep",
@@ -26,6 +27,7 @@ def test_chunk_accumulation_policy_replaces_snapshots_and_drains_delta_state():
incoming = MultimodalPayload.from_dict(
{
"audio": torch.tensor([2.0]),
"meta.segment_end": torch.tensor([1]),
"meta.tts_is_last_chunk": torch.tensor([1]),
"meta.turn_end": torch.tensor([1]),
}
@@ -41,6 +43,7 @@ def test_chunk_accumulation_policy_replaces_snapshots_and_drains_delta_state():
drain_delta_payload(merged)
assert "audio" not in merged
assert "meta.segment_end" not in merged
assert "meta.tts_is_last_chunk" not in merged
assert "meta.turn_end" not in merged
assert merged.metadata["meta.stable_request_value"] == "keep"
+50
View File
@@ -1813,6 +1813,56 @@ async def test_stage_pool_submit_update_refreshes_output_processor_state() -> No
@pytest.mark.asyncio
async def test_handle_streaming_update_unknown_request_is_dropped() -> None:
"""A streaming update for an untracked request must not resurrect it.
Updates always follow the first-chunk add_request through the same
ordered submission queue, so an unknown id can only mean the request
already finished or was aborted (e.g. the client disconnected).
Falling back to add_request created headless sessions that kept
cycling through the stages (issue #4271).
"""
class RecordingPool:
def __init__(self) -> None:
self.calls: list[tuple[str, Any]] = []
async def submit_update(self, request_id, req_state, request, *, prompt_text=None) -> int:
self.calls.append((request_id, prompt_text))
return 0
pool = RecordingPool()
orchestrator = object.__new__(Orchestrator)
orchestrator.async_chunk = False
orchestrator.request_states = {}
orchestrator.stage_pools = [pool]
add_request_calls: list[str] = []
async def record_add_request(msg) -> None:
add_request_calls.append(msg.request_id)
orchestrator._handle_add_request = record_add_request
await orchestrator._handle_streaming_update(
StageSubmissionMessage(
type="streaming_update",
request_id="req-unknown",
prompt=SimpleNamespace(request_id="req-unknown", prompt_token_ids=[1], resumable=True),
original_prompt={"prompt": "segment-2"},
output_prompt_text="segment-2",
sampling_params_list=[_sampling_params()],
final_stage_id=0,
preprocess_ms=0.0,
request_timestamp=time.time(),
enqueue_ts=time.perf_counter(),
)
)
assert pool.calls == []
assert add_request_calls == []
assert "req-unknown" not in orchestrator.request_states
async def test_handle_streaming_update_passes_prompt_text_to_stage_pool() -> None:
class RecordingPool:
def __init__(self) -> None:
@@ -7,15 +7,24 @@ import asyncio
import queue
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock
from unittest.mock import ANY, AsyncMock, MagicMock
import janus
import pytest
from vllm.outputs import CompletionOutput, RequestOutput
from vllm.sampling_params import SamplingParams
from vllm_omni.engine.orchestrator import Orchestrator, OrchestratorRequestState
from vllm_omni.engine.orchestrator import (
Orchestrator,
OrchestratorRequestState,
_OrchestratorDuplexStagePort,
)
from vllm_omni.engine.stage_pool import StagePool
from vllm_omni.experimental.fullduplex.engine.contracts import (
DuplexStageRequestContext,
DuplexStageSubmission,
)
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -107,11 +116,50 @@ class FakePrewarmPool:
async def submit_initial(self, _request_id, _req_state, request, prompt_text=None):
self.submitted.append(request)
return 0
def get_bound_replica_id(self, _request_id):
return 0
def _duplex_stage_port_submission():
stage_pools = []
for stage_id in range(3):
pool = SimpleNamespace(
stage_client=SimpleNamespace(default_sampling_params=SamplingParams(max_tokens=1)),
stage_vllm_config=SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)),
submit_initial=AsyncMock(return_value=stage_id + 10),
submit_update=AsyncMock(return_value=stage_id + 20),
)
stage_pools.append(pool)
request_states: dict[str, OrchestratorRequestState] = {}
prewarm = AsyncMock()
port = _OrchestratorDuplexStagePort(
stage_pools=stage_pools,
request_states=request_states,
running_counter=None,
cleanup_request_ids=AsyncMock(),
async_chunk=True,
prewarm_async_chunk_stages=prewarm,
)
context = DuplexStageRequestContext(
request_id="req-duplex",
session_id="session-duplex",
fence=DuplexFence("session-duplex"),
stage_id=0,
final_stage_id=2,
config_generation=0,
sampling_params=tuple(SamplingParams(max_tokens=1) for _ in range(3)),
)
port.ensure_request(context)
submission = DuplexStageSubmission(
context=context,
prompt={"prompt_token_ids": [1, 2]},
already_submitted=False,
)
return port, stage_pools, request_states, prewarm, submission
def _request_output(request_id: str) -> RequestOutput:
completion = CompletionOutput(
index=0,
@@ -199,11 +247,13 @@ async def test_async_prewarm_skips_outgoing_only_stage() -> None:
stage2 = FakePrewarmPool("receiver")
orchestrator.stage_pools = [stage0, stage1, stage2]
orchestrator._emit_tx_edge = lambda **_kwargs: None
orchestrator._record_duplex_stage_submission = MagicMock()
req_state = OrchestratorRequestState(
request_id="req-prewarm",
prompt={"prompt_token_ids": [1, 2]},
sampling_params_list=[SamplingParams(max_tokens=1) for _ in range(3)],
final_stage_id=2,
duplex_identity=SimpleNamespace(),
)
await orchestrator._prewarm_async_chunk_stages(
@@ -216,6 +266,23 @@ async def test_async_prewarm_skips_outgoing_only_stage() -> None:
assert len(stage2.submitted) == 1
assert 1 not in req_state.stage_submit_ts
assert 2 in req_state.stage_submit_ts
orchestrator._record_duplex_stage_submission.assert_called_once_with(
2,
"req-prewarm",
0,
req_state,
)
@pytest.mark.asyncio
async def test_duplex_prewarm_runs_after_first_stage0_submission() -> None:
port, stage_pools, request_states, prewarm, submission = _duplex_stage_port_submission()
result = await port.submit(submission)
assert result.stage_id == 0
stage_pools[0].submit_initial.assert_awaited_once()
prewarm.assert_awaited_once_with("req-duplex", ANY, request_states["req-duplex"])
@pytest.mark.asyncio
+122 -1
View File
@@ -14,7 +14,7 @@ from vllm.v1.engine import FinishReason
from vllm.v1.engine.output_processor import OutputProcessor as VLLMOutputProcessor
from vllm_omni.outputs import output_processor
from vllm_omni.outputs.output_modality import OutputModalityNames
from vllm_omni.outputs.output_modality import OutputModality, OutputModalityNames
from vllm_omni.outputs.output_processor import MultimodalOutputProcessor, OmniRequestState
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -452,6 +452,127 @@ def test_no_detokenizer_make_request_output():
assert AUDIO in result.outputs[0].multimodal_output
def test_no_detokenizer_process_outputs_returns_nonterminal_audio_chunk(monkeypatch):
"""Generation-stage MM-only chunks must reach the orchestrator.
StagePool registers generation requests without a per-request collector,
so the processor return value is the only path from Stage2 to
Orchestrator._handle_processed_outputs().
"""
state = _make_no_detok_state(RequestOutputKind.DELTA)
processor = object.__new__(MultimodalOutputProcessor)
processor.output_modality = OutputModality.AUDIO
processor.request_states = {"r": state}
upstream_result = SimpleNamespace(request_outputs=[], reqs_to_abort=[])
monkeypatch.setattr(
VLLMOutputProcessor,
"process_outputs",
lambda *_args, **_kwargs: upstream_result,
)
engine_output = SimpleNamespace(
request_id="r",
multimodal_output={
"model_outputs": torch.arange(8, dtype=torch.float32),
"sr": torch.tensor(24000, dtype=torch.int32),
},
output_type="audio",
pooling_output=None,
new_token_ids=[],
finish_reason=None,
stop_reason=None,
kv_transfer_params=None,
routed_experts=None,
num_cached_tokens=0,
)
result = processor.process_outputs([engine_output])
assert len(result.request_outputs) == 1
assert result.request_outputs[0].request_id == "r"
assert AUDIO in result.request_outputs[0].outputs[0].multimodal_output
def _make_mm_only_output_processor(monkeypatch):
processor = object.__new__(MultimodalOutputProcessor)
processor.output_modality = OutputModality.AUDIO
processor.request_states = {"r": _make_no_detok_state(RequestOutputKind.DELTA)}
monkeypatch.setattr(
VLLMOutputProcessor,
"process_outputs",
lambda *_args, **_kwargs: SimpleNamespace(
request_outputs=[],
reqs_to_abort=[],
),
)
def finish_request(req_state):
processor.request_states.pop(req_state.request_id, None)
processor._finish_request = finish_request
return processor
def _audio_engine_output(*, is_segment_finished: bool, is_last_chunk: bool):
return SimpleNamespace(
request_id="r",
multimodal_output={
"model_outputs": torch.arange(8, dtype=torch.float32),
"meta.tts_is_last_chunk": torch.tensor([int(is_last_chunk)]),
},
output_type="audio",
pooling_output=None,
new_token_ids=[],
finish_reason=FinishReason.STOP,
stop_reason=None,
kv_transfer_params=None,
routed_experts=None,
num_cached_tokens=0,
is_segment_finished=is_segment_finished,
)
def test_mm_only_segment_finish_retains_request_state_for_next_audio_chunk(monkeypatch):
processor = _make_mm_only_output_processor(monkeypatch)
first = processor.process_outputs([_audio_engine_output(is_segment_finished=True, is_last_chunk=False)])
assert "r" in processor.request_states
assert len(first.request_outputs) == 1
assert first.request_outputs[0].finished is False
second = processor.process_outputs([_audio_engine_output(is_segment_finished=False, is_last_chunk=True)])
assert len(second.request_outputs) == 1
assert second.request_outputs[0].finished is True
assert "r" not in processor.request_states
def test_mm_only_nonfinal_audio_retains_state_without_segment_marker(monkeypatch):
processor = _make_mm_only_output_processor(monkeypatch)
first = processor.process_outputs([_audio_engine_output(is_segment_finished=False, is_last_chunk=False)])
assert "r" in processor.request_states
assert len(first.request_outputs) == 1
assert first.request_outputs[0].finished is False
second = processor.process_outputs([_audio_engine_output(is_segment_finished=False, is_last_chunk=True)])
assert len(second.request_outputs) == 1
assert second.request_outputs[0].finished is True
assert "r" not in processor.request_states
def test_mm_only_terminal_finish_removes_request_state(monkeypatch):
processor = _make_mm_only_output_processor(monkeypatch)
result = processor.process_outputs([_audio_engine_output(is_segment_finished=False, is_last_chunk=True)])
assert len(result.request_outputs) == 1
assert result.request_outputs[0].finished is True
assert "r" not in processor.request_states
def test_no_detokenizer_make_request_output_with_routed_experts():
"""make_request_output accepts the routed_experts arg that the multimodal
output channel (_process_mm_only_outputs) passes positionally, and attaches
+49
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import os
import socket
from contextlib import contextmanager
from types import SimpleNamespace
from typing import Any
@@ -11,6 +12,7 @@ import pytest
from pytest_mock import MockerFixture
from vllm.v1.engine.utils import EngineZmqAddresses
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
from vllm_omni.engine.stage_engine_core_client import StageEngineCoreClientBase
from vllm_omni.engine.stage_engine_startup import (
@@ -189,6 +191,34 @@ class TestOmniMasterServerAllocation:
output_port = int(alloc.output_bind_address.split(":")[-1])
assert len({handshake_port, input_port, output_port}) == 3
def test_route_ports_remain_os_reserved_until_master_releases_them(self):
server = OmniMasterServer(master_address="127.0.0.1", master_port=15005, stage_ids=[0])
alloc = server.get_allocation(0)
ports = [
int(alloc.handshake_bind_address.rsplit(":", 1)[-1]),
int(alloc.input_bind_address.rsplit(":", 1)[-1]),
int(alloc.output_bind_address.rsplit(":", 1)[-1]),
]
for port in ports:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
with pytest.raises(OSError):
probe.bind(("127.0.0.1", port))
server.release_route_port_reservations(0, handshake=True, data=False)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.bind(("127.0.0.1", ports[0]))
for port in ports[1:]:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
with pytest.raises(OSError):
probe.bind(("127.0.0.1", port))
server.stop()
for port in ports[1:]:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.bind(("127.0.0.1", port))
def test_get_zmq_addresses_returns_bind_addresses(self):
server = OmniMasterServer(master_address="127.0.0.1", master_port=15002, stage_ids=[0])
alloc = server.get_allocation(0)
@@ -397,6 +427,25 @@ class TestSingleStageModeDetection:
assert engine.single_stage_mode is False
assert engine._single_stage_id_filter is None
def test_stage_configs_path_loads_duplex_runtime_config(self, mocker: MockerFixture):
duplex_session = DuplexSessionRuntimeConfig(max_sessions=2)
mocker.patch(
"vllm_omni.engine.async_omni_engine.StageConfigFactory.get_pipeline_config",
return_value=None,
)
load_deploy_config = mocker.patch(
"vllm_omni.engine.async_omni_engine.load_deploy_config",
return_value=SimpleNamespace(duplex_session=duplex_session),
)
engine = self._make_engine_no_thread(
mocker,
stage_configs_path="/fake/duplex.yaml",
)
load_deploy_config.assert_called_once_with("/fake/duplex.yaml")
assert engine.duplex_session_config is duplex_session
def test_single_stage_mode_without_stage_id_has_no_filter(self, mocker: MockerFixture):
engine = self._make_engine_no_thread(
mocker,
@@ -2520,6 +2520,52 @@ def test_duplex_auto_response_tts_scheduler_eos_fallback():
assert results[0]["abort_data_plane_request"] is True
def test_duplex_auto_response_rearms_tts_boundary_for_next_segment_in_same_turn():
data_plane = _test_data_plane()
request_id = "duplex-sid-next-segment-same-turn-e0-stage0"
session = DuplexSession(
session_id="sid-next-segment-same-turn",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.bind_response_turn(0)
data_plane.begin_request(request_id)
first_segment = _duplex_tts_output(
request_id=request_id,
samples=24000,
finished=False,
tts_is_last_chunk=True,
turn_id=0,
)
first_results = _project_data_plane(
data_plane,
{"data_plane_outputs": [first_segment]},
session=session,
)
assert len(first_results) == 1
assert first_results[0]["abort_data_plane_request"] is True
data_plane.begin_request(request_id)
second_segment = _duplex_tts_output(
request_id=request_id,
samples=48000,
finished=False,
text="hello again",
tts_is_last_chunk=True,
turn_id=0,
)
second_results = _project_data_plane(
data_plane,
{"data_plane_outputs": [second_segment]},
session=session,
)
assert len(second_results) == 1
assert second_results[0]["audio_data"] == "wav-24000"
assert second_results[0]["abort_data_plane_request"] is True
def test_duplex_turn_end_is_not_swallowed_by_finished_segment_fallback():
data_plane = _test_data_plane()
request_id = "duplex-sid-turn-end-after-segment-e0-stage0"
@@ -6202,19 +6248,27 @@ async def test_minicpmo_native_auto_response_keeps_request_bound_for_segment_con
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None:
if data.get("type") != "session.created":
return
session = handler._registry.get("sid-native-auto-continuation")
assert session is not None
session.capabilities.chunk_period_ms = 50
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(800),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
ws.put({"type": "input_audio_buffer.commit", "final": True})
ws = TimedWebSocket(on_send=on_send)
event = _native_session_create("sid-native-auto-continuation")
event["session"]["extra_body"]["auto_response"] = True
ws.put(event)
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(16000),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
ws.put({"type": "input_audio_buffer.commit", "final": True})
await handler.handle_session(ws)
@@ -6225,6 +6279,112 @@ async def test_minicpmo_native_auto_response_keeps_request_bound_for_segment_con
assert final is False
@pytest.mark.asyncio
async def test_minicpmo_native_auto_response_real_input_preempts_unsent_silence():
request_id = "duplex-sid-native-real-before-silence-e0-stage0"
class SequencedAppendEngine(FakeEngineClient):
def __init__(self) -> None:
control_result = {
"operation": "append",
"session_id": "sid-native-real-before-silence",
"ok": True,
"unsupported_count": 0,
"error_count": 0,
"stage_results": [
{
"result": {
"data_plane_append": True,
"request_id": request_id,
"response_stage_id": 1,
}
}
],
}
terminal_segment = _duplex_tts_output(
request_id=request_id,
samples=0,
finished=True,
tts_is_last_chunk=True,
token_ids=[151645],
)
super().__init__(
control_result=control_result,
collect_outputs=[[terminal_segment], [], []],
collect_delay_s=0.01,
)
self.append_sequence: list[str] = []
self.first_real_started = asyncio.Event()
self.second_real_started = asyncio.Event()
self.real_count = 0
async def append_duplex_input_async(self, session_id: str, **kwargs):
kwargs.pop("expected_epoch", None)
result = await super().append_duplex_input_async(session_id, **kwargs)
payload = kwargs.get("payload")
is_silence = (
isinstance(payload, dict)
and payload.get("audio") == OmniDuplexSessionHandler._NATIVE_SILENCE_UNIT_PAYLOAD_AUDIO
)
if is_silence:
self.append_sequence.append("silence")
else:
self.real_count += 1
self.append_sequence.append(f"real-{self.real_count}")
if self.real_count == 1:
self.first_real_started.set()
elif self.real_count == 2:
self.second_real_started.set()
return result
session_created = asyncio.Event()
engine = SequencedAppendEngine()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None:
if data.get("type") != "session.created":
return
session = handler._registry.get("sid-native-real-before-silence")
assert session is not None
session.capabilities.chunk_period_ms = 50
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(800),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
session_created.set()
ws = TimedWebSocket(on_send=on_send, receive_timeout_s=2.0)
event = _native_session_create("sid-native-real-before-silence")
event["session"]["extra_body"]["auto_response"] = True
ws.put(event)
handler_task = asyncio.create_task(handler.handle_session(ws))
await asyncio.wait_for(session_created.wait(), timeout=1)
await asyncio.wait_for(engine.first_real_started.wait(), timeout=1)
await asyncio.sleep(0.02)
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(800, value=0.07),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
await asyncio.wait_for(engine.second_real_started.wait(), timeout=1)
ws.put({"type": "session.close"})
await asyncio.wait_for(handler_task, timeout=2)
assert engine.append_sequence[:2] == ["real-1", "real-2"]
@pytest.mark.asyncio
async def test_minicpmo_native_auto_response_real_input_waits_for_submitted_silence_tail():
request_id = "duplex-sid-native-silence-tail-e0-stage0"
@@ -6294,18 +6454,26 @@ async def test_minicpmo_native_auto_response_real_input_waits_for_submitted_sile
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket(receive_timeout_s=2.0)
def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None:
if data.get("type") != "session.created":
return
session = handler._registry.get("sid-native-silence-tail")
assert session is not None
session.capabilities.chunk_period_ms = 50
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(800, value=0.05),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
ws = TimedWebSocket(on_send=on_send, receive_timeout_s=2.0)
event = _native_session_create("sid-native-silence-tail")
event["session"]["extra_body"]["auto_response"] = True
ws.put(event)
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(16000, value=0.05),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
handler_task = asyncio.create_task(handler.handle_session(ws))
await asyncio.wait_for(engine.silence_append_started.wait(), timeout=1)
@@ -6330,6 +6498,135 @@ async def test_minicpmo_native_auto_response_real_input_waits_for_submitted_sile
assert engine.append_sequence == ["real-1", "silence-started", "silence-returned", "real-2"]
@pytest.mark.asyncio
async def test_minicpmo_native_auto_response_preserves_silence_continuations_across_append_tails():
request_id = "duplex-sid-native-continuation-tails-e0-stage0"
class SequencedAppendEngine(FakeEngineClient):
def __init__(self) -> None:
control_result = {
"operation": "append",
"session_id": "sid-native-continuation-tails",
"ok": True,
"unsupported_count": 0,
"error_count": 0,
"stage_results": [
{
"result": {
"data_plane_append": True,
"request_id": request_id,
"response_stage_id": 1,
}
}
],
}
super().__init__(control_result=control_result)
self.real_append_started = asyncio.Event()
self.release_real_append = asyncio.Event()
self.first_silence_started = asyncio.Event()
self.release_first_silence = asyncio.Event()
self.second_silence_started = asyncio.Event()
self.release_collect = asyncio.Event()
self.silence_count = 0
async def append_duplex_input_async(self, session_id: str, **kwargs):
kwargs.pop("expected_epoch", None)
payload = kwargs.get("payload")
assert isinstance(payload, dict)
result = await super().append_duplex_input_async(session_id, **kwargs)
if payload.get("audio") != OmniDuplexSessionHandler._NATIVE_SILENCE_UNIT_PAYLOAD_AUDIO:
self.real_append_started.set()
await self.release_real_append.wait()
return result
self.silence_count += 1
if self.silence_count == 1:
self.first_silence_started.set()
await self.release_first_silence.wait()
else:
self.second_silence_started.set()
return result
async def collect_duplex_data_plane_outputs_async(
self,
request_id: str,
*,
response_stage_id: int | None = None,
timeout: float | None = None,
) -> list[object]:
del timeout
self.collected.append((request_id, response_stage_id))
await self.release_collect.wait()
return []
session_created = asyncio.Event()
def on_send(_ws: TimedWebSocket, data: dict[str, Any]) -> None:
if data.get("type") == "session.created":
session_created.set()
engine = SequencedAppendEngine()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket(on_send=on_send, receive_timeout_s=2.0)
event = _native_session_create("sid-native-continuation-tails")
event["session"]["extra_body"]["auto_response"] = True
ws.put(event)
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(16000, value=0.05),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
handler_task = asyncio.create_task(handler.handle_session(ws))
await asyncio.wait_for(session_created.wait(), timeout=1)
await asyncio.wait_for(engine.real_append_started.wait(), timeout=1)
session = handler._registry.get("sid-native-continuation-tails")
assert session is not None
session.capabilities.chunk_period_ms = 50
session.bind_request(request_id)
response_id = session.begin_response(turn_id=session.turn_id)
native = handler._minicpmo_session_state(session)
scheduler = native.silence_continuation_scheduler
assert scheduler is not None
payload = handler._native_silence_unit_payload()
payload["duplex_turn_id"] = session.turn_id
kwargs = {
"request_id": request_id,
"owner_id": f"response:{response_id}",
"response_id": response_id,
"response_owned": True,
"expected_epoch": session.epoch,
"expected_incarnation": session.incarnation,
"expected_model_turn_id": None,
"send_json": ws.send_json,
}
assert await scheduler(payload, **kwargs) is True
next_schedule = asyncio.create_task(scheduler(payload, **kwargs))
await asyncio.sleep(0.05)
assert not next_schedule.done()
engine.release_real_append.set()
await asyncio.wait_for(engine.first_silence_started.wait(), timeout=1)
assert not next_schedule.done()
engine.release_first_silence.set()
assert await asyncio.wait_for(next_schedule, timeout=1) is True
await asyncio.wait_for(engine.second_silence_started.wait(), timeout=1)
ws.put({"type": "session.close"})
await asyncio.wait_for(handler_task, timeout=2)
assert engine.silence_count == 2
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_uses_segment_text_metadata_for_transcript_cursor():
request_id = "duplex-sid-native-repeat-text-e0-stage0"
+6 -1
View File
@@ -222,6 +222,7 @@ def test_run_headless_llm_registers_with_auto_assigned_replica_id(mocker: Mocker
from vllm_omni.engine.stage_engine_startup import StageRegistrationResponse
stage_cfg = _make_stage_cfg(0, stage_type="llm")
stage_cfg.engine_args["async_chunk"] = True
parallel_config = SimpleNamespace(
data_parallel_size_local=1,
data_parallel_rank=0,
@@ -241,7 +242,10 @@ def test_run_headless_llm_registers_with_auto_assigned_replica_id(mocker: Mocker
"vllm_omni.distributed.omni_connectors.utils.initialization.resolve_omni_kv_config_for_stage",
return_value=(None, None, None),
)
mocker.patch("vllm_omni.engine.stage_init_utils.get_stage_connector_spec", return_value={})
mock_connector_spec = mocker.patch(
"vllm_omni.engine.stage_init_utils.get_stage_connector_spec",
return_value={},
)
mocker.patch("vllm_omni.engine.stage_init_utils.build_engine_args_dict", return_value={})
mocker.patch(
"vllm_omni.engine.stage_init_utils.build_vllm_config",
@@ -277,6 +281,7 @@ def test_run_headless_llm_registers_with_auto_assigned_replica_id(mocker: Mocker
assert kwargs["omni_stage_config"] is stage_cfg
assert kwargs["replica_id"] is None
assert "socket_ownership" not in kwargs
assert mock_connector_spec.call_args.kwargs["async_chunk"] is True
assert mock_manager_cls.call_count == 1
mgr_kwargs = mock_manager_cls.call_args.kwargs
@@ -207,3 +207,68 @@ def test_playback_worklet_waits_before_playing_and_rebuffers_after_underrun():
capture_output=True,
text=True,
)
def test_playback_worklet_fades_terminal_drain_to_zero():
node = shutil.which("node")
if node is None:
pytest.skip("node is required for the AudioWorklet regression test")
script = textwrap.dedent(
"""
const fs = require('fs');
const vm = require('vm');
global.sampleRate = 1000;
const messages = [];
global.AudioWorkletProcessor = class {
constructor() {
this.port = {
onmessage: null,
postMessage: (message) => messages.push(message),
};
}
};
let Processor = null;
global.registerProcessor = (_name, processor) => { Processor = processor; };
vm.runInThisContext(fs.readFileSync(process.argv[1], 'utf8'));
const processor = new Processor();
const pcm = new Int16Array(100);
pcm.fill(16384);
processor.handleMessage({
type: 'audio',
pcm,
responseId: 'response-terminal',
initialBufferMs: 400,
});
processor.handleMessage({
type: 'drain',
responseId: 'response-terminal',
});
const output = new Float32Array(100);
processor.process([], [[output]]);
const assert = (condition, message) => {
if (!condition) throw new Error(message);
};
assert(output.some((sample) => sample !== 0), 'terminal audio must still play');
assert(output[output.length - 1] === 0, 'terminal drain must fade the final sample to zero');
const tail = Array.from(output.slice(-5), Math.abs);
assert(
tail.every((sample, index) => index === 0 || sample <= tail[index - 1]),
'terminal drain fade must decrease monotonically',
);
assert(
messages.filter((message) => message.type === 'playback-drained').length === 1,
'terminal drain must be reported exactly once after playback',
);
"""
)
subprocess.run(
[node, "-e", script, str(STATIC_ROOT / "playback_worklet.js")],
check=True,
capture_output=True,
text=True,
)
+48
View File
@@ -43,6 +43,54 @@ def test_append_emits_one_model_unit_when_multiple_units_are_buffered():
assert buffer.pending_byte_count == 16_000 * 4
def test_speech_marker_does_not_leak_across_irregular_chunk_boundaries():
buffer = MiniCPMO45PcmAppendBuffer()
assert buffer.append(pcm_payload(15_000), chunk_period_ms=1_000) is None
speech_unit = buffer.append(pcm_payload(2_000, speech=False), chunk_period_ms=1_000)
silence_unit = buffer.append(pcm_payload(15_000, speech=False), chunk_period_ms=1_000)
assert speech_unit is not None
assert speech_unit["is_speech"] is True
assert silence_unit is not None
assert silence_unit["is_speech"] is False
def test_force_listen_marker_does_not_leak_across_irregular_chunk_boundaries():
buffer = MiniCPMO45PcmAppendBuffer()
forced = pcm_payload(15_000, speech=False)
forced["force_listen"] = True
assert buffer.append(forced, chunk_period_ms=1_000) is None
forced_unit = buffer.append(pcm_payload(2_000, speech=False), chunk_period_ms=1_000)
unforced_unit = buffer.append(pcm_payload(15_000, speech=False), chunk_period_ms=1_000)
assert forced_unit is not None
assert forced_unit["force_listen"] is True
assert unforced_unit is not None
assert unforced_unit["force_listen"] is False
def test_pcm_append_rollback_restores_per_span_speech_markers():
buffer = MiniCPMO45PcmAppendBuffer()
assert buffer.append(pcm_payload(15_000), chunk_period_ms=1_000) is None
reservation = buffer.prepare_append(
pcm_payload(2_000, speech=False),
operation_id="mixed-unit",
chunk_period_ms=1_000,
)
assert reservation is not None
reservation.rollback()
first = buffer.append(pcm_payload(15_000, speech=False), chunk_period_ms=1_000)
second = buffer.flush(chunk_period_ms=1_000)
assert first is not None
assert first["is_speech"] is True
assert second is not None
assert second["is_speech"] is False
def test_commit_without_speech_does_not_synthesize_terminal_audio():
buffer = MiniCPMO45PcmAppendBuffer()
buffer.append(pcm_payload(8_000, speech=False), chunk_period_ms=1_000)
+179
View File
@@ -0,0 +1,179 @@
"""Shared fixtures and argument builders for MiniCPM-o 4.5 duplex E2E tests."""
from __future__ import annotations
import hashlib
import uuid
import wave
from pathlib import Path
from types import SimpleNamespace
import pytest
from huggingface_hub import snapshot_download
from tests.helpers.runtime import OmniServerParams
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
MODEL = "openbmb/MiniCPM-o-4_5"
DEPLOY_CONFIG = modify_stage_config(
get_deploy_config_path("minicpmo_4_5_duplex.yaml"),
updates={
"base_config": get_deploy_config_path("minicpmo_4_5.yaml"),
"stages": {
0: {"kv_cache_memory_bytes": 6 * 1024 * 1024 * 1024},
1: {"kv_cache_memory_bytes": 512 * 1024 * 1024},
2: {"kv_cache_memory_bytes": 256 * 1024 * 1024},
},
},
)
CORE_DEPLOY_CONFIG = modify_stage_config(
DEPLOY_CONFIG,
updates={
"stages": {
0: {"enforce_eager": True},
1: {"enforce_eager": True},
}
},
)
ASSET_DIR = Path(__file__).resolve().parents[1] / "assets" / "minicpmo_4_5"
RESPONSE_REQUIRED_WAV = ASSET_DIR / "response_required_16k.wav"
RESPONSE_REQUIRED_SHA256 = "2e5fd4eb3ee434ce107ee3a0591fa624a33f7683c7462f45fe651c443c9af941"
SOFT_INTERRUPT_WAV = ASSET_DIR / "soft_interrupt_16k.wav"
SOFT_INTERRUPT_SHA256 = "cadae6d0ddc510310f16f8775d6379f30a5195369ccb54ff10a8e3f9a1f4a2ea"
REF_AUDIO_RELATIVE_PATH = Path("assets") / "HT_ref_audio.wav"
SERVER_PARAMS = [
pytest.param(
OmniServerParams(
model=MODEL,
stage_config_path=DEPLOY_CONFIG,
use_stage_cli=True,
server_args=["--trust-remote-code"],
),
id="three-stage-single-gpu",
)
]
CORE_SERVER_PARAMS = [
pytest.param(
OmniServerParams(
model=MODEL,
stage_config_path=CORE_DEPLOY_CONFIG,
use_stage_cli=True,
server_args=["--trust-remote-code"],
),
id="three-stage-single-gpu",
)
]
def validated_wav(path: Path, expected_sha256: str) -> Path:
actual_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if actual_sha256 != expected_sha256:
raise AssertionError(
f"MiniCPM-o duplex fixture SHA256 mismatch: expected {expected_sha256}, got {actual_sha256}"
)
with wave.open(str(path), "rb") as wav_file:
actual_format = (
wav_file.getframerate(),
wav_file.getnchannels(),
wav_file.getsampwidth(),
wav_file.getcomptype(),
)
expected_format = (16_000, 1, 2, "NONE")
if actual_format != expected_format:
raise AssertionError(f"MiniCPM-o duplex fixture must be 16 kHz mono PCM16, got {actual_format}")
return path
def validated_input_wav() -> Path:
return validated_wav(RESPONSE_REQUIRED_WAV, RESPONSE_REQUIRED_SHA256)
def validated_soft_interrupt_wav() -> Path:
return validated_wav(SOFT_INTERRUPT_WAV, SOFT_INTERRUPT_SHA256)
def resolve_ref_audio(model_prefix: str) -> Path:
if model_prefix:
model_root = Path(model_prefix) / MODEL
else:
model_root = Path(snapshot_download(MODEL, local_files_only=True))
ref_audio = model_root / REF_AUDIO_RELATIVE_PATH
if not ref_audio.is_file():
raise FileNotFoundError(f"MiniCPM-o checkpoint ref audio is missing: {ref_audio}")
return ref_audio
def realtime_url(omni_server) -> str:
return f"ws://{omni_server.host}:{omni_server.port}/v1/realtime?duplex=1"
def demo_args(
*,
omni_server,
input_wav: Path,
ref_audio: Path,
output_dir: Path,
) -> SimpleNamespace:
return SimpleNamespace(
url=realtime_url(omni_server),
model=omni_server.model,
session_id=f"duplex-ci-single-{uuid.uuid4().hex}",
input_wav=str(input_wav),
ref_audio=str(ref_audio),
turn_input_wav=[],
output_dir=str(output_dir),
output_audio_format="pcm16",
chunk_ms=200,
realtime_input=True,
first_turn_ms=1400,
turn_duration_ms=[],
first_turn_transcript="duplex CI speech",
omit_transcript_hints=False,
validation_mode="response-required",
temperature=0.0,
scenario="sequential",
require_audio=True,
require_distinct_inputs=False,
expect_empty_turn=[],
short_ack_ms=350,
turns=1,
timeout_s=120.0,
model_policy_settle_ms=2000,
)
def multi_session_args(
*,
omni_server,
input_wav: Path,
ref_audio: Path,
output_dir: Path,
response_required: bool,
) -> SimpleNamespace:
return SimpleNamespace(
url=realtime_url(omni_server),
model=omni_server.model,
sessions=2,
input_wav=str(input_wav),
ref_audio=str(ref_audio),
session_input_wav=[],
session_expected_token=[],
turn_input_wav=[],
output_dir=str(output_dir),
realtime_input=True,
chunk_ms=200,
turns=1,
first_turn_ms=1400,
turn_duration_ms=[],
response_required=response_required,
temperature=0.0 if response_required else None,
disconnect_session_index=0,
takeover_session_index=1,
resume_after_ms=100,
expire_session_index=None,
expire_after_s=40.0,
verify_admission_limit=None,
model_policy_settle_ms=2000,
timeout_s=180.0,
)
@@ -19,10 +19,12 @@ class _FakeEncoder(nn.Module):
def __init__(self):
super().__init__()
self.calls: list[int] = []
self.last_chunk_calls: list[bool] = []
def forward_chunk(self, xs, last_chunk=False, cnn_cache=None, att_cache=None):
batch, length, _ = xs.shape
self.calls.append(batch)
self.last_chunk_calls.append(last_chunk)
old_length = 0 if att_cache is None else att_cache.shape[3]
output = xs[:, : max(1, length - 1)]
cnn = xs[:, :1, :].transpose(1, 2).contiguous()
@@ -156,6 +158,35 @@ def _model():
return model, token2wav
def test_code2wav_resolves_hf_model_id_for_assets(mocker, tmp_path):
resolved_root = tmp_path / "snapshot"
resolved_root.mkdir()
config = _config()
config.model_config.model = "openbmb/MiniCPM-o-4_5"
config.model_config.revision = "test-revision"
config.model_config.stage_connector_config["extra"].pop("prompt_wav")
config.load_config = SimpleNamespace(download_dir="/model-cache")
model = MiniCPMO45Code2Wav(vllm_config=config)
mock_download = mocker.patch(
"vllm_omni.model_executor.model_loader.weight_utils.download_weights_from_hf_specific",
return_value=str(resolved_root),
)
assert model._resolve_model_root() == resolved_root
assert model.model_path == str(resolved_root)
assert model._default_prompt_wav == str(resolved_root / "assets" / "HT_ref_audio.wav")
mock_download.assert_called_once_with(
"openbmb/MiniCPM-o-4_5",
"/model-cache",
allow_patterns=[
"assets/HT_ref_audio.wav",
"assets/token2wav/*",
],
revision="test-revision",
require_all=True,
)
def _info(
request_id: str,
chunk_seq: int,
@@ -212,6 +243,18 @@ def test_adapter_runs_true_batch_cfg_and_splits_request_caches():
assert cache1[0, 0, 0, 0, 0].item() == 20
def test_fade_in_out_limits_overlap_to_available_previous_audio():
speech = torch.arange(6, dtype=torch.float32).reshape(1, -1)
previous = torch.full((1, 3), 2.0)
window = torch.hamming_window(8, periodic=False)
actual = BatchedToken2Wav._fade_in_out(speech, previous, window)
expected = speech.clone()
expected[..., :3] = speech[..., :3] * window[:3] + previous * window[-3:]
torch.testing.assert_close(actual, expected)
def test_estimator_cache_stack_split_round_trip_preserves_cfg_rows():
token2wav = _FakeToken2Wav()
adapter = BatchedToken2Wav(token2wav)
@@ -259,6 +302,166 @@ def test_model_preserves_output_slots_and_prefers_runtime_codes():
assert token2wav.flow.encoder.calls[-1] == 2
def test_code2wav_projects_duplex_metadata_to_final_audio_output():
model, token2wav = _model()
segment = _info("duplex", 0, [10, 11])
segment_text_utf8 = torch.tensor(list(b"hello"), dtype=torch.uint8)
segment["meta"].update(
{
"duplex_epoch": 3,
"duplex_turn_id": 7,
"llm_output_text_utf8": segment_text_utf8,
"tts_is_last_chunk": True,
"turn_end": False,
}
)
segment_output = _forward(model, [segment])
assert segment_output.multimodal_outputs["meta.turn_end"][0].item() is False
# A Talker unit boundary only drains pending codec tokens. The official
# streaming path keeps Token2wav open until the assistant turn ends.
assert token2wav.flow.encoder.last_chunk_calls[-1] is False
assert "duplex" in model._states
final = _info("duplex", 1, [12, 13], last_chunk=True)
final["meta"].update(segment["meta"])
final["meta"]["chunk_seq"] = 1
final["meta"]["last_chunk"] = True
final["meta"]["turn_end"] = True
output = _forward(model, [final])
payload = output.multimodal_outputs
assert "meta" not in payload
assert payload["meta.duplex_epoch"][0].item() == 3
assert payload["meta.duplex_turn_id"][0].item() == 7
torch.testing.assert_close(
payload["meta.llm_output_text_utf8"][0],
segment_text_utf8,
)
assert payload["meta.tts_is_last_chunk"][0].item() is True
assert payload["meta.turn_end"][0].item() is True
assert token2wav.flow.encoder.last_chunk_calls[-1] is True
assert "duplex" not in model._states
def test_initial_empty_segment_marker_initializes_stream_without_audio():
model, token2wav = _model()
boundary = _info("duplex", 0, [])
boundary["meta"].update(
{
"code_flat_numel": 0,
"tts_is_last_chunk": True,
"turn_end": False,
}
)
output = _forward(model, [boundary])
assert output.multimodal_outputs["model_outputs"][0].numel() == 0
assert "duplex" in model._states
assert token2wav.hift.calls == []
resumed = _info(
"duplex",
1,
[4218, 4218, 4218, 10, 11, 12, 13, 14],
)
output = _forward(model, [resumed])
assert output.multimodal_outputs["model_outputs"][0].numel() > 0
assert "duplex" in model._states
def test_shared_runtime_prompt_recreates_missing_file_before_second_owner(tmp_path, monkeypatch):
monkeypatch.setattr("tempfile.gettempdir", lambda: str(tmp_path))
model, _ = _model()
reference = torch.tensor([0.0, 0.25, -0.25, 0.0])
first = _info("voice-a", 0, [10, 11])
first["codes"]["ref"] = reference
first["meta"]["ref_audio_sr"] = 16000
first["meta"].pop("prompt_cache_id")
_forward(model, [first], request_ids=["internal-a"])
prompt_key = model._request_prompt_keys["internal-a"]
prompt_path = Path(model._runtime_prompts[prompt_key].path)
prompt_path.unlink()
second = _info("voice-b", 0, [12, 13])
second["codes"]["ref"] = reference
second["meta"]["ref_audio_sr"] = 16000
second["meta"].pop("prompt_cache_id")
_forward(model, [second], request_ids=["internal-b"])
assert prompt_path.is_file()
assert model._runtime_prompts[prompt_key].owners == {"internal-a", "internal-b"}
model.on_requests_finished(["internal-a"])
assert prompt_path.is_file()
assert model._runtime_prompts[prompt_key].owners == {"internal-b"}
model.on_requests_finished(["internal-b"])
assert not prompt_path.exists()
assert prompt_key not in model._runtime_prompts
def test_runtime_prompt_write_failure_does_not_publish_partial_file(tmp_path, monkeypatch):
monkeypatch.setattr("tempfile.gettempdir", lambda: str(tmp_path))
model, _ = _model()
reference = torch.tensor([0.0, 0.25, -0.25, 0.0])
def fail_after_partial_write(path, *_args, **_kwargs):
Path(path).write_bytes(b"partial")
raise OSError("simulated write failure")
monkeypatch.setattr(
"vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_code2wav.sf.write",
fail_after_partial_write,
)
with pytest.raises(OSError, match="simulated write failure"):
model._materialize_runtime_prompt(reference, 16000)
assert len(model._runtime_prompts) == 1
entry = next(iter(model._runtime_prompts.values()))
assert not Path(entry.path).exists()
assert list(Path(entry.path).parent.iterdir()) == []
def test_runtime_prompt_files_are_isolated_between_model_instances(tmp_path, monkeypatch):
monkeypatch.setattr("tempfile.gettempdir", lambda: str(tmp_path))
first_model, _ = _model()
second_model, _ = _model()
reference = torch.tensor([0.0, 0.25, -0.25, 0.0])
def runtime_ref_info(request_id: str):
info = _info(request_id, 0, [10, 11])
info["codes"]["ref"] = reference
info["meta"]["ref_audio_sr"] = 16000
info["meta"].pop("prompt_cache_id")
return info
_forward(first_model, [runtime_ref_info("voice-a")], request_ids=["internal-a"])
_forward(second_model, [runtime_ref_info("voice-b")], request_ids=["internal-b"])
first_key = first_model._request_prompt_keys["internal-a"]
second_key = second_model._request_prompt_keys["internal-b"]
first_path = Path(first_model._runtime_prompts[first_key].path)
second_path = Path(second_model._runtime_prompts[second_key].path)
assert first_key == second_key
assert first_path != second_path
assert first_path.is_file()
assert second_path.is_file()
first_model.on_requests_finished(["internal-a"])
assert not first_path.exists()
assert second_path.is_file()
second_model.on_requests_finished(["internal-b"])
assert not second_path.exists()
def test_mixed_final_exact_buckets_keep_order_and_release_only_final_states():
model, _ = _model()
_forward(
@@ -485,27 +688,37 @@ def test_reference_voice_and_duplex_metadata_follow_request_lifecycle():
model, _ = _model()
first = _info("voice-a", 0, [1, 2])
first["codes"]["ref"] = torch.linspace(-0.1, 0.1, 160)
segment_text_utf8 = torch.tensor(list(b"hello"), dtype=torch.uint8)
first["meta"].update(
ref_audio_sr=16000,
native_duplex_segment_text="hello",
llm_output_text_utf8=segment_text_utf8,
duplex_turn_id=7,
duplex_epoch=3,
)
first["meta"].pop("prompt_cache_id")
output = _forward(model, [first])
prompt_cache_id, prompt_wav = model._owned_prompt_wavs["voice-a"]
assert prompt_cache_id.startswith("voice-a:")
prompt_key = model._request_prompt_keys["voice-a"]
prompt = model._runtime_prompts[prompt_key]
prompt_cache_id, prompt_wav = prompt.cache_id, prompt.path
assert prompt_cache_id.startswith("runtime-ref-")
assert Path(prompt_wav).is_file()
assert bytes(output.multimodal_outputs["meta.llm_output_text_utf8"][0].tolist()).decode() == "hello"
torch.testing.assert_close(
output.multimodal_outputs["meta.llm_output_text_utf8"][0],
segment_text_utf8,
)
assert output.multimodal_outputs["meta.duplex_turn_id"][0].item() == 7
assert output.multimodal_outputs["meta.duplex_epoch"][0].item() == 3
final = _info("voice-a", 1, [3, 4], last_chunk=True)
final["meta"].pop("prompt_cache_id")
final["meta"]["tts_is_last_chunk"] = True
output = _forward(model, [final])
assert output.multimodal_outputs["meta.tts_is_last_chunk"][0].item() is True
assert "voice-a" not in model._owned_prompt_wavs
assert model._request_prompt_keys["voice-a"] == prompt_key
model.on_requests_finished(["voice-a"])
assert "voice-a" not in model._request_prompt_keys
assert prompt_key not in model._runtime_prompts
assert not Path(prompt_wav).exists()
assert (prompt_cache_id, prompt_wav) not in model.backend._prompt_features
@@ -14,6 +14,7 @@ from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni import (
from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni_tts import (
MiniCPMO45OmniTTSForConditionalGeneration,
_max_audio_tokens,
_restore_weight_norm_weight,
)
from vllm_omni.utils.mm_outputs import to_payload_element
@@ -56,6 +57,8 @@ def _make_talker() -> MiniCPMO45OmniTTSForConditionalGeneration:
talker._request_generators = {}
talker._request_audio_states = {}
talker._deferred_cleanup_ids = set()
talker._codec_min_tokens = 50
talker._codec_seed = 42
return talker
@@ -81,6 +84,23 @@ def test_audio_token_limit_scales_with_condition_length(
assert _max_audio_tokens(condition_tokens) == expected
def test_weight_norm_restore_matches_checkpoint_parametrization_in_bfloat16() -> None:
generator = torch.Generator().manual_seed(42)
weight_v = torch.randn(8, 16, generator=generator, dtype=torch.bfloat16)
weight_g = torch.rand(8, 1, generator=generator, dtype=torch.bfloat16)
linear = nn.utils.parametrizations.weight_norm(
nn.Linear(16, 8, bias=False, dtype=torch.bfloat16),
dim=0,
)
with torch.no_grad():
linear.parametrizations.weight.original0.copy_(weight_g)
linear.parametrizations.weight.original1.copy_(weight_v)
restored = _restore_weight_norm_weight(weight_g, weight_v)
assert torch.equal(restored, linear.weight)
def test_talker_emits_request_aligned_codec_deltas_after_compaction(mocker) -> None:
talker = _make_talker()
seen: list[tuple[str, list[float], list[int]]] = []
@@ -114,9 +134,73 @@ def test_talker_emits_request_aligned_codec_deltas_after_compaction(mocker) -> N
assert _routed(output, 0)["codes"]["audio"].tolist() == [[2]]
assert _routed(output, 1)["codes"]["audio"].tolist() == [[3]]
assert _routed(output, 0)["meta"]["finished"].item() is False
assert set(output.multimodal_outputs["meta"]) == {"finished"}
assert talker.compute_logits(output.text_hidden_states).argmax(dim=-1).tolist() == [0, 0]
def test_talker_projects_request_aligned_duplex_metadata(mocker) -> None:
talker = _make_talker()
mocker.patch.object(talker, "_sample_audio_code", return_value=torch.tensor(2))
infos = [
{
"request_id": "req-a",
"native_duplex": True,
"duplex": {"epoch": 3, "turn_id": 7},
"ids": {"tts": [41]},
"meta": {
"native_duplex_segment_text": "first",
"turn_eos_token_id": 99,
},
"audio_codes": {"accumulated": torch.empty(0, dtype=torch.long)},
},
{
"request_id": "req-b",
"native_duplex": True,
"duplex": {"epoch": 4, "turn_id": 8},
"ids": {"tts": [42, 99]},
"meta": {
"native_duplex_segment_text": "second",
"turn_eos_token_id": 99,
},
"audio_codes": {"accumulated": torch.empty(0, dtype=torch.long)},
},
]
output = talker.make_omni_output(
torch.ones(2, 2),
model_intermediate_buffer=infos,
request_token_spans=[(0, 1), (1, 2)],
)
meta = output.multimodal_outputs["meta"]
assert [value.item() for value in meta["native_duplex"]] == [True, True]
assert [value.item() for value in meta["duplex_epoch"]] == [3, 4]
assert [value.item() for value in meta["duplex_turn_id"]] == [7, 8]
assert "native_duplex_segment_text" not in meta
assert [bytes(value.tolist()).decode("utf-8") for value in meta["llm_output_text_utf8"]] == [
"first",
"second",
]
assert [value.item() for value in meta["turn_end"]] == [False, True]
def test_talker_rejects_native_duplex_without_fence_identity(mocker) -> None:
talker = _make_talker()
mocker.patch.object(talker, "_sample_audio_code", return_value=torch.tensor(2))
info = {
"request_id": "req-missing-fence",
"native_duplex": True,
"audio_codes": {"accumulated": torch.empty(0, dtype=torch.long)},
}
with pytest.raises(RuntimeError, match="requires non-negative integer epoch and turn_id"):
talker.make_omni_output(
torch.ones(1, 2),
model_intermediate_buffer=[info],
request_token_spans=[(0, 1)],
)
def test_incomplete_prefill_emits_no_code_and_does_not_advance_state(mocker) -> None:
talker = _make_talker()
sample = mocker.patch.object(talker, "_sample_audio_code", return_value=torch.tensor(2))
@@ -179,7 +263,7 @@ def test_eos_is_terminal_once_and_never_enters_codec_history(mocker) -> None:
assert talker.compute_logits(second.text_hidden_states).argmax(dim=-1).tolist() == [1]
def test_max_token_terminal_includes_only_new_codec_delta(mocker) -> None:
def test_max_token_terminal_drops_unconsumed_codec_delta(mocker) -> None:
talker = _make_talker()
mocker.patch.object(talker, "_sample_audio_code", return_value=torch.tensor(3))
info = {
@@ -194,8 +278,10 @@ def test_max_token_terminal_includes_only_new_codec_delta(mocker) -> None:
request_token_spans=[(0, 1)],
)
assert info["audio_codes"]["accumulated"].tolist() == [4, 5, 3]
assert output.multimodal_outputs["codes"]["audio"][0].tolist() == [[3]]
# MiniCPMTTS.generate_chunk samples once at the max-token boundary to
# advance RNG state, but the sampled code is not fed into KV or returned.
assert info["audio_codes"]["accumulated"].tolist() == [4, 5]
assert output.multimodal_outputs["codes"]["audio"][0].shape == (0, 1)
assert output.multimodal_outputs["meta"]["finished"][0].item() is True
assert talker.compute_logits(output.text_hidden_states).argmax(dim=-1).tolist() == [1]
@@ -268,6 +354,94 @@ def test_empty_speech_segment_finishes_without_sampling_codes() -> None:
assert decode_embeds.shape == (1, 4)
def test_chunked_prefill_tail_aligns_condition_with_prompt_length(mocker) -> None:
talker = _make_talker()
talker.emb_text = nn.Embedding(1, 2)
condition = torch.arange(18, dtype=torch.float32).reshape(9, 2)
mocker.patch.object(talker, "_build_condition_embeddings", return_value=condition)
_, embeds, _ = talker.preprocess(
torch.zeros(9, dtype=torch.long),
None,
_omni_is_prefill=True,
_omni_num_computed_tokens=59,
_omni_prompt_len=68,
request_id="req-chunked-prefill",
tts_token_ids=torch.tensor([1]),
tts_hidden_states=torch.ones(1, 2),
)
assert torch.equal(embeds, condition)
state = talker._request_audio_states["req-chunked-prefill"]
assert state["min_tokens"] == 50
assert state["max_tokens"] == 64
@pytest.mark.parametrize(
("meta", "expected_min_tokens"),
[
({"turn_start": True}, 0),
({}, 26),
({"turn_end": True}, 0),
],
)
def test_native_duplex_prefill_uses_official_chunk_limits(
mocker,
meta,
expected_min_tokens,
) -> None:
talker = _make_talker()
talker.emb_text = nn.Embedding(1, 2)
mocker.patch.object(
talker,
"_build_condition_embeddings",
return_value=torch.ones(3, 2),
)
talker.preprocess(
torch.zeros(3, dtype=torch.long),
None,
_omni_is_prefill=True,
request_id="req-duplex-chunk",
native_duplex=True,
meta=meta,
tts_token_ids=torch.tensor([1]),
tts_hidden_states=torch.ones(1, 2),
)
state = talker._request_audio_states["req-duplex-chunk"]
assert state["min_tokens"] == expected_min_tokens
assert state["max_tokens"] == 26
def test_native_duplex_condition_matches_official_text_plus_audio_bos() -> None:
talker = _make_talker()
talker.emb_text = nn.Embedding(16, 2)
talker.projector_semantic = nn.Identity()
talker._normalize = False
talker._text_eos_id = 14
talker._tts_bos_id = 15
with torch.no_grad():
talker.emb_text.weight.copy_(torch.arange(32, dtype=torch.float32).reshape(16, 2))
token_ids = torch.tensor([2, 3])
hidden_states = torch.tensor([[0.5, 1.0], [1.5, 2.0]])
condition = talker._build_condition_embeddings(
token_ids,
hidden_states,
native_duplex=True,
)
expected_text = talker.emb_text(token_ids) + hidden_states
expected = torch.cat(
[expected_text, talker.emb_text(torch.tensor([talker._tts_bos_id]))],
dim=0,
)
assert torch.equal(condition, expected)
assert condition.shape[0] == token_ids.shape[0] + 1
def test_request_cleanup_evicts_ar_rng_and_decode_state() -> None:
talker = _make_talker()
talker._request_generators["req-done"] = torch.Generator()
@@ -43,6 +43,27 @@ def _delta(*codes: int):
}
def _duplex_delta(
*codes: int,
epoch: int = 3,
turn_id: int = 7,
text: str = "segment",
turn_end: bool = False,
):
text_utf8 = torch.tensor(list(text.encode("utf-8")), dtype=torch.uint8)
return {
"codes": {"audio": torch.tensor(codes, dtype=torch.long).reshape(-1, 1)},
"meta": {
"finished": torch.tensor(False),
"native_duplex": torch.tensor(True),
"duplex_epoch": torch.tensor(epoch),
"duplex_turn_id": torch.tensor(turn_id),
"llm_output_text_utf8": text_utf8,
"turn_end": torch.tensor(turn_end),
},
}
def _codes(payload) -> list[int]:
assert payload.codes is not None
assert isinstance(payload.codes.audio, torch.Tensor)
@@ -108,29 +129,57 @@ def test_short_final_flushes_silence_prefix_and_tail() -> None:
assert final.meta.finished.item() is True
def test_duplex_turn_end_waits_for_terminal_codec_flush() -> None:
manager = _manager()
request = _request("req-duplex")
body = tts2code2wav_async_chunk(
manager,
_duplex_delta(*range(25), turn_end=True),
request,
False,
)
final = tts2code2wav_async_chunk(
manager,
_duplex_delta(turn_end=True),
request,
True,
)
assert body is not None
assert body.meta.last_chunk is False
assert body.meta.turn_end is False
assert final is not None
assert _codes(final) == [22, 23, 24]
assert final.meta.last_chunk is True
assert final.meta.turn_end is True
def test_first_chunk_forwards_reference_voice_and_duplex_identity() -> None:
manager = _manager()
request = _request("req")
request.additional_information = {
"codes": {"ref": [0.1, -0.1]},
"meta": {
"ref_audio_sr": 16000,
"native_duplex_segment_text": "hello",
"segment_end": True,
"turn_end": True,
},
"duplex": {"epoch": 3, "model_turn_id": 7},
"meta": {"ref_audio_sr": 16000},
}
payload = tts2code2wav_async_chunk(manager, _delta(*range(7)), request, True)
payload = tts2code2wav_async_chunk(
manager,
_duplex_delta(*range(7), text="hello", turn_end=True),
request,
True,
)
assert payload is not None
assert payload.codes.ref.tolist() == pytest.approx([0.1, -0.1])
assert payload.meta.ref_audio_sr == 16000
assert payload.meta.native_duplex_segment_text == "hello"
torch.testing.assert_close(
payload.meta.llm_output_text_utf8,
torch.tensor(list(b"hello"), dtype=torch.uint8),
)
assert payload.meta.duplex_epoch == 3
assert payload.meta.duplex_turn_id == 7
assert payload.meta.segment_end is True
assert payload.meta.tts_is_last_chunk is True
assert payload.meta.turn_end is True
@@ -211,6 +260,186 @@ def test_empty_final_releases_wait_gate_once() -> None:
assert duplicate is None
def test_empty_duplex_boundary_uses_zero_length_transport_placeholder() -> None:
manager = _manager()
boundary = tts2code2wav_async_chunk(
manager,
_duplex_delta(text="boundary"),
_request("req-duplex"),
True,
)
assert boundary is not None
assert _codes(boundary) == [0]
assert boundary.meta.code_flat_numel == 0
assert boundary.meta.last_chunk is False
assert boundary.meta.is_segment_finished.item() is False
torch.testing.assert_close(
boundary.meta.llm_output_text_utf8,
torch.tensor(list(b"boundary"), dtype=torch.uint8),
)
def test_duplex_segments_preserve_stream_state_without_closing_turn() -> None:
manager = _manager()
request = _request("req-duplex")
request.additional_information = {
"codes": {"ref": [0.1, 0.2, 0.3]},
"meta": {"ref_audio_sr": 16000},
}
first = tts2code2wav_async_chunk(
manager,
_duplex_delta(10, 11, text="first"),
request,
True,
)
second = tts2code2wav_async_chunk(
manager,
_duplex_delta(12, 13, text="second"),
request,
True,
)
assert first is not None
assert second is not None
assert first.meta.last_chunk is False
assert second.meta.last_chunk is False
assert first.codes is not None
assert torch.allclose(first.codes.ref, torch.tensor([0.1, 0.2, 0.3]))
assert first.meta.ref_audio_sr == 16000
assert second.codes is not None
assert second.codes.ref is None
assert second.meta.cache_epoch == first.meta.cache_epoch
assert second.meta.chunk_seq == first.meta.chunk_seq + 1
assert second.meta.duplex_epoch == 3
assert second.meta.duplex_turn_id == 7
torch.testing.assert_close(
second.meta.llm_output_text_utf8,
torch.tensor(list(b"second"), dtype=torch.uint8),
)
assert second.meta.tts_is_last_chunk is True
assert second.meta.turn_end is False
assert first.meta.is_segment_finished.item() is False
assert second.meta.is_segment_finished.item() is False
def test_duplex_short_units_wait_for_minimum_stream_body() -> None:
manager = _manager()
request = _request("req-duplex")
first = tts2code2wav_async_chunk(
manager,
_duplex_delta(10, 11, 12, text="first"),
request,
True,
)
second = tts2code2wav_async_chunk(
manager,
_duplex_delta(13, 14, text="first"),
request,
True,
)
assert first is not None
assert _codes(first) == [0]
assert first.meta.code_flat_numel == 0
assert first.meta.last_chunk is False
assert first.meta.tts_is_last_chunk is True
assert second is not None
assert _codes(second) == [4218, 4218, 4218, 10, 11, 12, 13, 14]
assert second.meta.code_flat_numel == 8
torch.testing.assert_close(
second.meta.llm_output_text_utf8,
torch.tensor(list(b"firstfirst"), dtype=torch.uint8),
)
def test_duplex_empty_finish_callback_does_not_replay_previous_text() -> None:
manager = _manager()
request = _request("req-duplex")
first = tts2code2wav_async_chunk(
manager,
_duplex_delta(*range(25), text="first"),
request,
False,
)
boundary = tts2code2wav_async_chunk(
manager,
_duplex_delta(text="first"),
request,
True,
)
second = tts2code2wav_async_chunk(
manager,
_duplex_delta(*range(25, 50), text="second"),
request,
False,
)
assert first is not None
assert boundary is not None
assert boundary.meta.code_flat_numel == 0
assert second is not None
torch.testing.assert_close(
second.meta.llm_output_text_utf8,
torch.tensor(list(b"second"), dtype=torch.uint8),
)
def test_duplex_short_tail_does_not_replay_previous_segment_text() -> None:
manager = _manager()
request = _request("req-duplex")
def chunk(codes, text: str, finished: bool):
return tts2code2wav_async_chunk(
manager,
_duplex_delta(*codes, text=text),
request,
finished,
)
first = chunk(range(25), "和上海之间", False)
first_tail = chunk([25, 26], "和上海之间", True)
assert first is not None
assert first.meta.llm_output_text_utf8.tolist() == list("和上海之间".encode())
assert first_tail is not None
assert first_tail.meta.code_flat_numel == 0
assert chunk([27, 28, 29], "的距离大约是", False) is None
next_flush = chunk([], "的距离大约是", True)
assert next_flush is not None
assert next_flush.meta.llm_output_text_utf8.tolist() == list("的距离大约是".encode())
def test_duplex_turn_end_closes_epoch_and_next_turn_restarts_sequence() -> None:
manager = _manager()
request = _request("req-duplex")
turn_end = tts2code2wav_async_chunk(
manager,
_duplex_delta(14, turn_id=7, turn_end=True),
request,
True,
)
next_turn = tts2code2wav_async_chunk(
manager,
_duplex_delta(20, turn_id=8),
request,
True,
)
assert turn_end is not None
assert next_turn is not None
assert turn_end.meta.last_chunk is True
assert turn_end.meta.turn_end is True
assert turn_end.meta.is_segment_finished.item() is True
assert next_turn.meta.cache_epoch == turn_end.meta.cache_epoch + 1
assert next_turn.meta.chunk_seq == 0
assert next_turn.meta.last_chunk is False
def test_staggered_requests_keep_accumulators_isolated() -> None:
manager = _manager()
req_a = _request("a")
@@ -128,13 +128,125 @@ def test_native_duplex_speak_segment_reaches_split_talker() -> None:
info = converted["model_intermediate_buffer"]
assert info["native_duplex"] is True
assert info["ids"]["tts"] == [21, 22]
assert converted["prompt_token_ids"] == [0, 0, 0, 0]
assert converted["prompt_token_ids"] == [0, 0, 0]
assert info["meta"]["replace_streaming_prompt"] is True
assert info["meta"]["next_stage_prompt_len"] == 3
assert info["meta"]["turn_start"] is True
assert info["meta"]["segment_end"] is True
assert info["duplex"]["epoch"] == 3
assert info["duplex"]["turn_id"] == 7
def test_native_duplex_continuation_appends_only_new_talker_condition() -> None:
prompt_ids = [101, 102]
token_ids = {
"tts_bos_token_id": 9301,
"tts_eos_token_id": 9302,
"listen_token_id": 9303,
"speak_token_id": 9304,
"chunk_eos_token_id": 9308,
"chunk_tts_eos_token_id": 9309,
"turn_eos_token_id": 9310,
}
context = SimpleNamespace(
bridge_states={
"duplex": {
"epoch": 3,
"model_turn_id": 7,
}
}
)
first_ids = [9304, 21, 22, 9308]
first = _output(
prompt_ids=prompt_ids,
output_ids=first_ids,
latent=torch.arange(24, dtype=torch.float32).reshape(6, 4),
multimodal_output={
"duplex_prompt_token_ids": prompt_ids,
"meta": token_ids,
},
)
second_ids = [*first_ids, 9304, 23, 24, 9308]
second = _output(
prompt_ids=prompt_ids,
output_ids=second_ids,
latent=torch.arange(40, dtype=torch.float32).reshape(10, 4),
multimodal_output={
"duplex_prompt_token_ids": prompt_ids,
"meta": token_ids,
},
)
third_ids = [*second_ids, 9304, 25, 26, 9308]
third = _output(
prompt_ids=prompt_ids,
output_ids=third_ids,
latent=torch.arange(56, dtype=torch.float32).reshape(14, 4),
multimodal_output={
"duplex_prompt_token_ids": prompt_ids,
"meta": token_ids,
},
)
first_input = llm2tts([first], prompt=[{}], _streaming_context=context)[0]
second_input = llm2tts([second], prompt=[{}], _streaming_context=context)[0]
context.bridge_states["duplex"]["model_turn_id"] = 8
third_input = llm2tts([third], prompt=[{}], _streaming_context=context)[0]
assert first_input["model_intermediate_buffer"]["ids"]["tts"] == [21, 22]
assert second_input["model_intermediate_buffer"]["ids"]["tts"] == [23, 24]
assert third_input["model_intermediate_buffer"]["ids"]["tts"] == [25, 26]
assert first_input["model_intermediate_buffer"]["meta"]["turn_start"] is True
assert second_input["model_intermediate_buffer"]["meta"]["turn_start"] is False
assert third_input["model_intermediate_buffer"]["meta"]["turn_start"] is True
assert first_input["model_intermediate_buffer"]["meta"]["replace_streaming_prompt"] is True
assert "replace_streaming_prompt" not in second_input["model_intermediate_buffer"]["meta"]
assert third_input["model_intermediate_buffer"]["meta"]["replace_streaming_prompt"] is True
assert second_input["model_intermediate_buffer"]["meta"]["next_stage_prompt_len"] == 3
assert second_input["prompt_token_ids"] == [0, 0, 0]
def test_native_duplex_transcript_decodes_the_talker_condition_slice() -> None:
prompt_ids = [101, 102]
metadata = {
"tts_bos_token_id": 9301,
"tts_eos_token_id": 9302,
"listen_token_id": 9303,
"speak_token_id": 9304,
"chunk_eos_token_id": 9308,
"chunk_tts_eos_token_id": 9309,
"turn_eos_token_id": 9310,
}
token_text = {21: "杭州", 22: "和", 23: "州和", 24: "上海之间", 25: "大"}
def output(token_ids: list[int]):
return _output(
prompt_ids=prompt_ids,
output_ids=token_ids,
latent=torch.zeros((len(prompt_ids) + len(token_ids), 1)),
multimodal_output={
"duplex_prompt_token_ids": prompt_ids,
"meta": metadata,
},
)
context = SimpleNamespace(
bridge_states={"duplex": {"epoch": 3, "model_turn_id": 7}},
source_token_decoder=lambda ids, **_: "".join(token_text.get(int(token_id), "") for token_id in ids),
)
first_ids = [9304, 21, 22, 9308]
first_info = llm2tts([output(first_ids)], prompt=[{}], _streaming_context=context)[0]["model_intermediate_buffer"]
second_info = llm2tts(
[output([*first_ids, 23, 24, 25, 9308])],
prompt=[{}],
_streaming_context=context,
)[0]["model_intermediate_buffer"]
assert first_info["meta"]["native_duplex_segment_text"] == "杭州和"
assert second_info["ids"]["tts"] == [24, 25]
assert second_info["meta"]["native_duplex_segment_text"] == "上海之间大"
def test_native_duplex_requires_tokenizer_boundary_metadata() -> None:
latent = torch.zeros((3, 4))
source = _output(
@@ -4,6 +4,7 @@
from __future__ import annotations
from collections import defaultdict
from types import SimpleNamespace
import pytest
@@ -227,6 +228,47 @@ def test_talker2code2wav_full_payload_keeps_all_zero_codec_rows() -> None:
assert "code_predictor_codes" not in payload
def test_talker2code2wav_async_chunk_flushes_cached_tail_on_stop_token() -> None:
request_id = "codec_tail"
stop_token_id = 999
transfer_manager = SimpleNamespace(
code_prompt_token_ids=defaultdict(list),
put_req_chunk=defaultdict(int, {request_id: 2}),
connector=SimpleNamespace(
config={
"extra": {
"initial_codec_chunk_frames": 4,
"codec_chunk_frames": 25,
"codec_left_context_frames": 25,
}
}
),
)
transfer_manager.code_prompt_token_ids[request_id] = [
torch.tensor([[frame, frame + 100]], dtype=torch.long) for frame in range(50)
]
request = SimpleNamespace(
external_req_id=request_id,
sampling_params=SimpleNamespace(
stop_token_ids=[stop_token_id],
stop_token_id=None,
),
)
payload = q3.talker2code2wav_async_chunk(
transfer_manager,
{"codes": {"audio": torch.tensor([[stop_token_id, 0]], dtype=torch.long)}},
request,
is_finished=True,
)
assert payload is not None
assert payload.meta.finished.item() is True
# 4 initial frames and one 25-frame chunk were already emitted. The final
# payload contains 25 frames of left context plus the remaining 21 frames.
assert payload.codes.audio.numel() == (25 + 21) * 2
def test_thinker2talker_full_payload_packs_complete_tensors() -> None:
"""Full-payload path drops the terminal thinker row before talker prefill."""
request = SimpleNamespace(
+2
View File
@@ -1160,8 +1160,10 @@ class TestDeployConfigLoading:
assert [stage.to_omegaconf().session_mode for stage in stages] == ["duplex", "duplex", "duplex"]
assert [stage.yaml_engine_args["async_scheduling"] for stage in stages] == [False, False, False]
assert all("Async" not in (stage.scheduler_cls or "") for stage in stages)
assert [stage.devices for stage in deploy.stages] == ["0", "0", "0"]
assert deploy.stages[1].enforce_eager is False
assert stages[1].yaml_extras["default_sampling_params"]["max_tokens"] == 2048
assert stages[1].yaml_extras["default_sampling_params"]["min_tokens"] == 0
assert stages[1].yaml_extras["default_sampling_params"]["stop_token_ids"] == [1]
@pytest.mark.parametrize(
+270 -15
View File
@@ -276,16 +276,16 @@ def test_minicpmo_model_hook_same_speech_row_second_step_does_not_rearm_pending_
next_logits[0, 7] = 30.0
next_logits[0, 8] = -2.0
next_logits[0, 10] = 20.0
original_next_logits = next_logits.clone()
model.prepare_duplex_sampling(next_logits, SimpleNamespace(), (row,))
assert state.pending_speech_context is False
assert state.current_turn_ended is False
assert torch.isneginf(next_logits[0, 7])
assert next_logits[0, 8].item() == 30.0
assert torch.equal(next_logits, original_next_logits)
def test_minicpmo_model_hook_mid_turn_speech_redirects_listen_to_tts_bos():
def test_minicpmo_model_hook_mid_turn_speech_preserves_logits_for_post_sample_redirect():
state = SimpleNamespace(
current_turn_ended=False,
last_terminator_token=None,
@@ -296,12 +296,11 @@ def test_minicpmo_model_hook_mid_turn_speech_redirects_listen_to_tts_bos():
logits[0, 7] = 30.0
logits[0, 8] = -2.0
logits[0, 10] = 20.0
original_logits = logits.clone()
model.prepare_duplex_sampling(logits, SimpleNamespace(), (row,))
assert torch.isneginf(logits[0, 7])
assert logits[0, 8].item() == 30.0
assert logits[0, 10].item() == 20.0
assert torch.equal(logits, original_logits)
def test_minicpmo_model_hook_ignores_serving_new_user_turn_marker():
@@ -317,12 +316,12 @@ def test_minicpmo_model_hook_ignores_serving_new_user_turn_marker():
logits = torch.zeros((1, 16), dtype=torch.float32)
logits[0, 7] = 5.0
logits[0, 10] = 20.0
original_logits = logits.clone()
model.prepare_duplex_sampling(logits, SimpleNamespace(), (row,))
assert state.current_turn_ended is False
assert state.last_terminator_token is None
assert torch.isneginf(logits[0, 7])
assert logits[0, 8].item() == 5.0
assert torch.equal(logits, original_logits)
def test_generic_ar_runner_builds_typed_duplex_sampling_rows():
@@ -613,6 +612,180 @@ def test_minicpmo_stage0_rejects_unknown_special_token_fallbacks():
runtime._require_special_token_ids()
def _minicpmo45_tokenizer_stub():
token_ids = {
token: index
for index, token in enumerate(
(
"<unit>",
"</unit>",
"<|listen|>",
"<|speak|>",
"<|tts_bos|>",
"<|tts_eos|>",
"<|tts_pad|>",
"<|chunk_eos|>",
"<|chunk_tts_eos|>",
"<|turn_eos|>",
),
start=101,
)
}
return SimpleNamespace(
unk_token_id=0,
convert_tokens_to_ids=lambda token: token_ids.get(token, 0),
encode=lambda token, add_special_tokens=False: (
[token_ids[token]] if not add_special_tokens and token in token_ids else [0]
),
)
def _transformers_processor_stub(auto_processor):
class _AutoImageProcessor:
@staticmethod
def register(*_args, **_kwargs):
return None
return SimpleNamespace(
AutoImageProcessor=_AutoImageProcessor,
AutoProcessor=auto_processor,
)
def test_minicpmo_stage0_loads_processor_from_hf_id(monkeypatch):
import sys
from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import (
MiniCPMO45Stage0DuplexRuntime,
)
model_id = "openbmb/MiniCPM-o-4_5"
processor = SimpleNamespace(tokenizer=_minicpmo45_tokenizer_stub())
load_calls = []
class _AutoImageProcessor:
@staticmethod
def register(config_class, image_processor_class):
del image_processor_class
if isinstance(config_class, str):
raise AttributeError("'str' object has no attribute '__module__'")
class _AutoProcessor:
@classmethod
def from_pretrained(cls, model_path, *, trust_remote_code):
load_calls.append((model_path, trust_remote_code))
_AutoImageProcessor.register("MiniCPMVImageProcessor", object)
return processor
monkeypatch.setitem(
sys.modules,
"transformers",
SimpleNamespace(
AutoImageProcessor=_AutoImageProcessor,
AutoProcessor=_AutoProcessor,
),
)
loaded = MiniCPMO45Stage0DuplexRuntime._load_processor_from_path(model_id)
assert loaded is processor
assert load_calls == [(model_id, True)]
with pytest.raises(AttributeError, match="__module__"):
_AutoImageProcessor.register("MiniCPMVImageProcessor", object)
def test_minicpmo_stage0_processor_load_preserves_original_exception(monkeypatch):
import sys
from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import (
MiniCPMO45Stage0DuplexRuntime,
)
original = ValueError("invalid processor config")
class _AutoProcessor:
@classmethod
def from_pretrained(cls, model_path, *, trust_remote_code):
del cls, model_path, trust_remote_code
raise original
monkeypatch.setitem(
sys.modules,
"transformers",
_transformers_processor_stub(_AutoProcessor),
)
with pytest.raises(RuntimeError, match="Failed to load MiniCPM-o duplex processor") as exc_info:
MiniCPMO45Stage0DuplexRuntime._load_processor_from_path("openbmb/MiniCPM-o-4_5")
assert exc_info.value.__cause__ is original
def test_minicpmo_stage0_processor_requires_tokenizer(monkeypatch):
import sys
from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import (
MiniCPMO45Stage0DuplexRuntime,
)
class _AutoProcessor:
@classmethod
def from_pretrained(cls, model_path, *, trust_remote_code):
del cls, model_path, trust_remote_code
return object()
monkeypatch.setitem(
sys.modules,
"transformers",
_transformers_processor_stub(_AutoProcessor),
)
with pytest.raises(RuntimeError, match="does not expose a tokenizer"):
MiniCPMO45Stage0DuplexRuntime._load_processor_from_path("openbmb/MiniCPM-o-4_5")
def test_minicpmo_stage0_loaded_processor_validates_special_tokens(monkeypatch):
import sys
from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import (
MiniCPMO45Stage0DuplexRuntime,
)
processor = SimpleNamespace(tokenizer=_minicpmo45_tokenizer_stub())
class _AutoProcessor:
@classmethod
def from_pretrained(cls, model_path, *, trust_remote_code):
del cls, model_path, trust_remote_code
return processor
monkeypatch.setitem(
sys.modules,
"transformers",
_transformers_processor_stub(_AutoProcessor),
)
runtime = MiniCPMO45Stage0DuplexRuntime(
SimpleNamespace(),
model_path="openbmb/MiniCPM-o-4_5",
device="cpu",
)
assert runtime.processor is processor
assert set(runtime._special_token_ids()) == {
"unit_token_id",
"unit_end_token_id",
"listen_token_id",
"speak_token_id",
"tts_bos_token_id",
"tts_eos_token_id",
"tts_pad_token_id",
"chunk_eos_token_id",
"chunk_tts_eos_token_id",
"turn_eos_token_id",
}
def test_minicpmo_stage0_data_plane_prefill_matches_official_unit_format():
import torch
@@ -1150,6 +1323,88 @@ def test_minicpmo_stage0_data_plane_final_first_chunk_does_not_add_silence_unit(
)
def test_minicpmo_stage0_final_does_not_promote_first_chunk_alignment_tail_to_unit():
import torch
from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import (
MiniCPMO45Stage0DuplexRuntime,
_MiniCPMO45Stage0SessionState,
)
class _StageModel(torch.nn.Module):
first_chunk_ms = 10
chunk_ms = 8
sample_rate = 1000
def __init__(self):
super().__init__()
self.embed = torch.nn.Embedding(256, 2)
def get_input_embeddings(self):
return self.embed
def get_audio_hidden_states(self, _data):
return [torch.tensor([[0.5, 0.5]], dtype=torch.float32)]
class _MelProcessor:
sample_rate = 1000
is_first = True
def get_config(self):
return {"effective_first_chunk_ms": 9}
class _Processor:
def __init__(self):
self._streaming_mel_processor = _MelProcessor()
def get_streaming_chunk_size(self):
return 9 if self._streaming_mel_processor.is_first else 8
def process_audio_streaming(self, audio, **_kwargs):
self._streaming_mel_processor.is_first = False
return {"audio_features": audio, "audio_feature_lens": [[len(audio)]]}
runtime = MiniCPMO45Stage0DuplexRuntime.__new__(MiniCPMO45Stage0DuplexRuntime)
runtime.stage_model = _StageModel()
runtime.thinker = runtime.stage_model
runtime.tokenizer = SimpleNamespace(
unk_token_id=0,
convert_tokens_to_ids=lambda token: {
"<unit>": 1,
"</unit>": 2,
"<|listen|>": 3,
"<|speak|>": 4,
"<|tts_bos|>": 5,
"<|tts_eos|>": 6,
"<|tts_pad|>": 7,
"<|chunk_eos|>": 8,
"<|chunk_tts_eos|>": 9,
"<|turn_eos|>": 10,
"<|audio|>": 11,
}.get(token, 0),
encode=lambda text, add_special_tokens=False: [],
)
runtime.processor = _Processor()
runtime.device = "cpu"
runtime._init_token_ids()
state = _MiniCPMO45Stage0SessionState(session_id="sid-first-chunk-alignment-tail")
first = runtime._stage_prefill_embeddings_only(state, np.zeros(8, dtype=np.float32), seq=1)
second = runtime._stage_prefill_embeddings_only(state, np.zeros(8, dtype=np.float32), seq=2)
final = runtime._stage_prefill_embeddings_only(
state,
np.zeros(8, dtype=np.float32),
seq=3,
final=True,
)
assert first["input_token_ids"].count(1) == 1
assert second["input_token_ids"].count(1) == 1
assert final["input_token_ids"].count(1) == 1
assert state.audio_chunk_idx == 3
assert len(state.audio_buffer) == 1
def test_minicpmo_stage0_runtime_uses_loaded_vllm_embed_tokens_when_get_input_embeddings_is_broken():
import torch
@@ -1319,7 +1574,7 @@ def test_minicpmo_stage0_native_sampler_penalizes_text_from_prior_chunk():
model.thinker = SimpleNamespace(get_tokenizer=lambda: _Tokenizer())
session_key = ("sid-cross-chunk-repetition", 0)
state = _MiniCPMO45Stage0SessionState(session_id=session_key[0])
state.generated_text_tokens = [198] * 8
state.generated_tokens = [198] * 8
model._minicpmo45_duplex_data_plane_helper = SimpleNamespace(sessions={session_key: state})
model._minicpmo45_duplex_row_sessions = {0: session_key}
@@ -1381,7 +1636,7 @@ def test_minicpmo_stage0_native_sampler_matches_official_negative_logit_penalty(
state = _MiniCPMO45Stage0SessionState(session_id=session_key[0])
repeated = 198
alternative = 1234
state.generated_text_tokens = [repeated]
state.generated_tokens = [repeated]
model._minicpmo45_duplex_data_plane_helper = SimpleNamespace(sessions={session_key: state})
model._minicpmo45_duplex_row_sessions = {0: session_key}
@@ -1405,7 +1660,7 @@ def test_minicpmo_stage0_native_sampler_matches_official_negative_logit_penalty(
assert sampled.sampled_token_ids.tolist() == [[repeated]]
def test_minicpmo_stage0_records_only_bounded_text_repetition_history():
def test_minicpmo_stage0_records_bounded_model_policy_history():
from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import (
_MiniCPMO45Stage0SessionState,
)
@@ -1432,15 +1687,15 @@ def test_minicpmo_stage0_records_only_bounded_text_repetition_history():
}
for sampled in range(1000, 1520):
model._record_minicpmo45_duplex_terminator(0, sampled, token_ids)
model._record_minicpmo45_duplex_generation_token(0, sampled)
expected_history = list(range(1008, 1520))
assert state.generated_text_tokens == expected_history
assert state.generated_tokens == expected_history
for sampled in token_ids.values():
model._record_minicpmo45_duplex_terminator(0, sampled, token_ids)
model._record_minicpmo45_duplex_generation_token(0, sampled)
assert state.generated_text_tokens == expected_history
assert state.generated_tokens == [*expected_history[len(token_ids) :], *token_ids.values()]
def test_minicpmo_stage0_native_sampler_does_not_override_model_at_punctuation():