Signed-off-by: chickeyton <ngton2014@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
138 lines
5.2 KiB
Python
138 lines
5.2 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
"""Live E2E coverage for the public duplex client (``vllm_omni.clients.duplex``).
|
|
|
|
Drives a real MiniCPM-o 4.5 duplex session end to end through
|
|
:class:`DuplexClient`: session handshake with reference audio, paced PCM
|
|
streaming, a speak response consumed via :class:`ResponseHandle` with
|
|
incremental playback acks, a forced transport drop recovered by automatic
|
|
``session.resume``, and a clean close.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import (
|
|
CORE_SERVER_PARAMS,
|
|
realtime_url,
|
|
resolve_ref_audio,
|
|
validated_input_wav,
|
|
)
|
|
from tests.helpers.mark import hardware_test
|
|
from vllm_omni.clients.duplex import (
|
|
DuplexClient,
|
|
EventCollector,
|
|
ReconnectPolicy,
|
|
audio_data_url,
|
|
read_pcm16_wav,
|
|
)
|
|
from vllm_omni.clients.minicpmo_4_5 import create_duplex_session_config
|
|
|
|
pytestmark = pytest.mark.omni
|
|
|
|
_SESSION_TIMEOUT_S = 900.0
|
|
_MAX_LISTEN_ROUNDS = 30
|
|
|
|
|
|
async def _run_live_session(*, url: str, model: str, ref_audio: Path, input_wav: Path) -> dict[str, object]:
|
|
# temperature 0.0 keeps the response-required reply short and
|
|
# deterministic (same as the validated demo driver).
|
|
config = create_duplex_session_config(ref_audio=audio_data_url(ref_audio), temperature=0.0)
|
|
client = DuplexClient(
|
|
url,
|
|
model=model,
|
|
config=config,
|
|
reconnect=ReconnectPolicy(max_attempts=5, backoff_s=(0.25, 1.0)),
|
|
# Short interval so the periodic heartbeat path runs during the
|
|
# session — including across the forced transport drop below.
|
|
heartbeat_interval_s=5.0,
|
|
)
|
|
collector = EventCollector()
|
|
pcm = read_pcm16_wav(input_wav)
|
|
summary: dict[str, object] = {}
|
|
async with client:
|
|
collector_task = asyncio.create_task(collector.consume(client))
|
|
summary["session_id"] = client.session_id
|
|
summary["resume_token_issued"] = client.resume_token is not None
|
|
|
|
# Phase 1: stream the validated utterance and consume one speak response.
|
|
await client.stream_pcm(pcm, chunk_ms=200)
|
|
await client.commit()
|
|
listens = 0
|
|
async for response in client.responses():
|
|
if response.decision == "listen":
|
|
listens += 1
|
|
if listens > _MAX_LISTEN_ROUNDS:
|
|
raise AssertionError(f"no speak response after {listens} listen decisions")
|
|
continue
|
|
chunk_count = 0
|
|
audio_byte_count = 0
|
|
async for chunk in response.audio():
|
|
chunk_count += 1
|
|
audio_byte_count += len(chunk)
|
|
await client.ack_playback(response.played_ms)
|
|
summary.update(
|
|
{
|
|
"decision": response.decision,
|
|
"listen_rounds": listens,
|
|
"audio_chunks": chunk_count,
|
|
"audio_bytes": audio_byte_count,
|
|
"played_ms": response.played_ms,
|
|
"transcript": response.transcript,
|
|
}
|
|
)
|
|
break
|
|
|
|
# Phase 2: force-drop the transport underneath the client and require
|
|
# the automatic session.resume path to bring the session back.
|
|
resumed_waiter = asyncio.create_task(client.wait_for("session.resumed", timeout_s=90.0))
|
|
await asyncio.sleep(0.1) # let the waiter subscribe before the drop
|
|
assert client._ws is not None
|
|
await client._ws.close()
|
|
resumed = await resumed_waiter
|
|
summary["resumed_session_id"] = resumed.session_id or client.session_id
|
|
|
|
# The resumed transport must be live: a heartbeat gets acknowledged.
|
|
ack_waiter = asyncio.create_task(client.wait_for("session.heartbeat_ack", timeout_s=30.0))
|
|
await asyncio.sleep(0.1)
|
|
await client.send({"type": "session.heartbeat"})
|
|
await ack_waiter
|
|
summary["post_resume_heartbeat_ok"] = True
|
|
|
|
await client.close()
|
|
await asyncio.wait_for(collector_task, timeout=10.0)
|
|
|
|
summary["error_events"] = collector.errors()
|
|
summary["closed_cleanly"] = collector.count("session.closed") > 0
|
|
summary["timing"] = collector.timing_summary(after_s=0.0)
|
|
return summary
|
|
|
|
|
|
@pytest.mark.core_model
|
|
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
|
@pytest.mark.parametrize("omni_server", CORE_SERVER_PARAMS, indirect=True)
|
|
def test_duplex_client_live_session(omni_server) -> None:
|
|
summary = asyncio.run(
|
|
asyncio.wait_for(
|
|
_run_live_session(
|
|
url=realtime_url(omni_server),
|
|
model=omni_server.model,
|
|
ref_audio=resolve_ref_audio(),
|
|
input_wav=validated_input_wav(),
|
|
),
|
|
timeout=_SESSION_TIMEOUT_S,
|
|
)
|
|
)
|
|
assert summary["decision"] == "speak", summary
|
|
assert summary["audio_chunks"] > 0, summary
|
|
assert summary["played_ms"] > 0, summary
|
|
assert summary["transcript"], summary
|
|
assert summary["resume_token_issued"] is True, summary
|
|
assert summary["post_resume_heartbeat_ok"] is True, summary
|
|
assert summary["closed_cleanly"] is True, summary
|
|
assert summary["error_events"] == [], summary
|