[Cleanup][Full Duplex] Remove generic server VAD

Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
Sy03 committed 2026-08-25 03:14:31 +08:00
1 parent 2a7407fe85
commit d3780fdcb7
13 files changed
+17 -854

No files matched your search

@@ -59,63 +59,6 @@ def _make_scheduler(policy: SchedulingPolicy, *, waiting=(), skipped_waiting=(),
return scheduler
@pytest.mark.parametrize("policy", _POLICIES)
def test_sweep_drops_aborted_request_from_skipped_waiting(policy: SchedulingPolicy) -> None:
"""The sweep must cover ``skipped_waiting``.
Upstream ``schedule()`` admits from ``skipped_waiting`` as readily as from
``waiting``, so an aborted request left there is re-selected and raises.
"""
aborted = _StubRequest("req-aborted", RequestStatus.FINISHED_ABORTED)
scheduler = _make_scheduler(policy, skipped_waiting=[aborted])
scheduler._drop_aborted_queued_requests()
assert list(scheduler.skipped_waiting) == []
@pytest.mark.parametrize("policy", _POLICIES)
def test_sweep_drops_aborted_requests_from_waiting_and_running(policy: SchedulingPolicy) -> None:
"""``waiting`` and ``running`` stay covered alongside the new queue."""
aborted_waiting = _StubRequest("req-waiting", RequestStatus.FINISHED_ABORTED)
aborted_running = _StubRequest("req-running", RequestStatus.FINISHED_ABORTED)
scheduler = _make_scheduler(
policy,
waiting=[aborted_waiting],
running=[aborted_running],
)
scheduler._drop_aborted_queued_requests()
assert list(scheduler.waiting) == []
assert scheduler.running == []
@pytest.mark.parametrize("policy", _POLICIES)
def test_sweep_leaves_live_requests_intact(policy: SchedulingPolicy) -> None:
"""The sweep must not over-sweep.
A request parked in ``skipped_waiting`` on a blocked waiting status is one
upstream still intends to schedule; only the aborted entry may go.
"""
blocked = _StubRequest("req-blocked", RequestStatus.WAITING_FOR_STREAMING_REQ, arrival_time=0.0)
aborted = _StubRequest("req-aborted", RequestStatus.FINISHED_ABORTED, arrival_time=1.0)
live_waiting = _StubRequest("req-live-waiting", RequestStatus.WAITING)
live_running = _StubRequest("req-live-running", RequestStatus.RUNNING)
scheduler = _make_scheduler(
policy,
waiting=[live_waiting],
skipped_waiting=[blocked, aborted],
running=[live_running],
)
scheduler._drop_aborted_queued_requests()
assert list(scheduler.skipped_waiting) == [blocked]
assert list(scheduler.waiting) == [live_waiting]
assert scheduler.running == [live_running]
@pytest.mark.parametrize("policy", _POLICIES)
def test_schedule_sweeps_skipped_waiting_before_upstream_selection(
monkeypatch: pytest.MonkeyPatch,
@@ -60,12 +60,6 @@ def _make_scheduler(scheduler_cls, *, requests, running, waiting, counter, skipp
return scheduler
def _upstream_num_unfinished(scheduler) -> int:
"""Mirror of ``Scheduler.get_num_unfinished_requests`` arithmetic."""
num_waiting = len(scheduler.waiting) + len(scheduler.skipped_waiting) - scheduler.num_waiting_for_streaming_input
return num_waiting + len(scheduler.running)
@pytest.mark.parametrize(("scheduler_cls", "scheduler_mod"), _SCHEDULER_PARAMS)
def test_finish_requests_clears_counter_left_by_a_stomped_status(
monkeypatch: pytest.MonkeyPatch,
@@ -99,73 +93,6 @@ def test_finish_requests_clears_counter_left_by_a_stomped_status(
assert scheduler.num_waiting_for_streaming_input == 0
@pytest.mark.parametrize(("scheduler_cls", "scheduler_mod"), _SCHEDULER_PARAMS)
def test_next_request_is_visible_as_work_after_the_abort(
monkeypatch: pytest.MonkeyPatch,
scheduler_cls,
scheduler_mod,
) -> None:
"""The consequence, stated as the engine sees it.
A fresh request queued behind the leak must read as unfinished work.
Without the resync this is ``(1 + 0 - 1) + 0 == 0`` and the engine parks
in ``input_queue.get()`` with a live request in ``waiting``.
"""
aborted = _StubRequest("req-aborted", RequestStatus.RUNNING)
incoming = _StubRequest("req-next-session", RequestStatus.WAITING)
scheduler = _make_scheduler(
scheduler_cls,
requests={incoming.request_id: incoming},
running=[],
waiting=[incoming],
counter=1,
)
monkeypatch.setattr(
scheduler_mod.VLLMScheduler,
"finish_requests",
lambda self, request_ids, finished_status: [aborted],
)
scheduler_cls.finish_requests(scheduler, [aborted.request_id], RequestStatus.FINISHED_ABORTED)
assert _upstream_num_unfinished(scheduler) == 1
@pytest.mark.parametrize(("scheduler_cls", "scheduler_mod"), _SCHEDULER_PARAMS)
def test_finish_requests_keeps_counting_a_genuinely_parked_request(
monkeypatch: pytest.MonkeyPatch,
scheduler_cls,
scheduler_mod,
) -> None:
"""The resync must not over-correct.
A request still parked as ``WAITING_FOR_STREAMING_REQ`` in ``waiting`` is
exactly what the counter is for -- it must stay counted, so the engine
does not spin on a request that has no input to process.
"""
parked = _StubRequest("req-parked", RequestStatus.WAITING_FOR_STREAMING_REQ)
leaving = _StubRequest("req-leaving", RequestStatus.FINISHED_STOPPED)
scheduler = _make_scheduler(
scheduler_cls,
requests={parked.request_id: parked},
running=[],
waiting=[parked],
counter=1,
)
monkeypatch.setattr(
scheduler_mod.VLLMScheduler,
"finish_requests",
lambda self, request_ids, finished_status: [leaving],
)
scheduler_cls.finish_requests(scheduler, [leaving.request_id], RequestStatus.FINISHED_STOPPED)
assert scheduler.num_waiting_for_streaming_input == 1
assert _upstream_num_unfinished(scheduler) == 0
def test_resync_counts_parked_requests_in_skipped_waiting() -> None:
"""``get_num_unfinished_requests`` sums ``waiting`` and ``skipped_waiting``,
so the resync has to look at both or it under-counts and reintroduces the
@@ -185,36 +112,6 @@ def test_resync_counts_parked_requests_in_skipped_waiting() -> None:
assert scheduler.num_waiting_for_streaming_input == 1
def test_resync_is_a_no_op_when_upstream_accounting_is_correct() -> None:
"""In the healthy case the derived value equals upstream's incremental one,
so the helper must leave it alone rather than fight it."""
parked = _StubRequest("req-parked", RequestStatus.WAITING_FOR_STREAMING_REQ)
running = _StubRequest("req-running", RequestStatus.RUNNING)
scheduler = _make_scheduler(
OmniARScheduler,
requests={r.request_id: r for r in (parked, running)},
running=[running],
waiting=[parked],
counter=1,
)
scheduler._resync_streaming_input_counter()
assert scheduler.num_waiting_for_streaming_input == 1
def test_resync_tolerates_a_scheduler_without_the_counter() -> None:
"""Guard for vLLM versions that predate ``num_waiting_for_streaming_input``:
the helper must no-op rather than invent the attribute."""
scheduler = OmniARScheduler.__new__(OmniARScheduler)
scheduler.waiting = []
scheduler.skipped_waiting = []
scheduler._resync_streaming_input_counter()
assert not hasattr(scheduler, "num_waiting_for_streaming_input")
def test_ar_schedule_resyncs_before_delegating_upstream(monkeypatch: pytest.MonkeyPatch) -> None:
"""Pins placement in ``schedule``: the chunk-transfer adapter rewrites
status during ``process_pending_chunks``, so the resync has to run after
@@ -1,107 +0,0 @@
"""MiniCPM-o server-VAD hard-interruption acceptance driver."""
from __future__ import annotations
import base64
from pathlib import Path
from vllm_omni.experimental.fullduplex.client import (
PCM16_BYTES_PER_SAMPLE,
PCM16_SAMPLE_RATE,
RealtimeDuplexClient,
RealtimeEventCollector,
build_realtime_url,
read_pcm16_wav,
wait_for,
)
def _events(events: list[dict[str, object]], response_id: str, kind: str) -> list[dict[str, object]]:
return [
event
for event in events
if event.get("type") == kind and RealtimeEventCollector.response_id(event) == response_id
]
async def run_server_vad_interrupt(args) -> dict[str, object]:
initial = read_pcm16_wav(Path(args.input_wav))
interrupt = read_pcm16_wav(Path(args.interrupt_wav))
interrupt = interrupt[: PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE * 4700 // 1000]
session_id = f"server-vad-hard-interrupt-{id(args)}"
client = RealtimeDuplexClient(build_realtime_url(args.url, args.model, autostart=False, session_id=session_id))
try:
await client.__aenter__()
await client.configure(
args.model,
ref_audio="data:audio/wav;base64," + base64.b64encode(Path(args.ref_audio).read_bytes()).decode(),
session_id=session_id,
turn_detection={
"type": "server_vad",
"interrupt_response": True,
},
timeout_s=args.timeout_s,
)
await client.stream_pcm16(initial + bytes(16_000 * 2 * 1600 // 1000), chunk_ms=200, realtime=True)
await client.commit()
await wait_for(
lambda: any(e.get("type") == "response.created" for e in client.events.events),
timeout_s=args.timeout_s,
label="response.created",
)
target = next(e for e in client.events.events if e.get("type") == "response.created")
target_id = RealtimeEventCollector.response_id(target)
assert target_id
await wait_for(
lambda: _events(client.events.events, target_id, "response.audio.delta"),
timeout_s=args.timeout_s,
label="response.audio.delta",
)
cursor = len(client.events.events)
await client.stream_pcm16(interrupt + bytes(16_000 * 2 * 800 // 1000), chunk_ms=args.chunk_ms, realtime=True)
await wait_for(
lambda: _events(client.events.events, target_id, "response.done"),
timeout_s=args.timeout_s,
label="cancelled response.done",
)
await client.commit()
await wait_for(
lambda: any(
e.get("type") == "response.done" and RealtimeEventCollector.response_id(e) != target_id
for e in client.events.events[cursor:]
),
timeout_s=args.timeout_s,
label="follow-up response.done",
)
events = client.events.events
finally:
try:
await client.close_session(timeout_s=args.timeout_s)
finally:
await client.__aexit__(None, None, None)
done = _events(events, target_id, "response.done")
done_index = events.index(done[0]) if len(done) == 1 else len(events)
stale = (
any(
e.get("type") == "response.audio.delta" and RealtimeEventCollector.response_id(e) == target_id
for e in events[done_index + 1 :]
)
if len(done) == 1
else False
)
followup_audio = any(
e.get("type") == "response.audio.delta" and RealtimeEventCollector.response_id(e) != target_id
for e in events[cursor:]
)
ok = (
len(done) == 1
and isinstance(done[0].get("response"), dict)
and done[0]["response"].get("status") == "cancelled"
and done[0]["response"].get("status_details", {}).get("reason") == "turn_detected"
and not stale
and any(e.get("type") == "input_audio_buffer.committed" for e in events[cursor:])
and followup_audio
and not any(e.get("type") == "error" for e in events)
)
return {"ok": ok}
@@ -21,7 +21,6 @@ from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import (
from tests.e2e.online_serving.run_minicpmo_realtime_duplex_multi_session import (
run_lifecycle_probes,
)
from tests.e2e.online_serving.run_minicpmo_realtime_duplex_server_vad import run_server_vad_interrupt
from tests.e2e.online_serving.run_minicpmo_realtime_duplex_soft_interrupt import (
run_soft_interrupt,
)
@@ -89,22 +88,3 @@ def test_duplex_soft_interrupt(omni_server, model_prefix: str, tmp_path: Path) -
assert result["response_lifecycle_ok"] is True
assert result["response_audio_contract_ok"] is True
assert result["followup_response_transcript_ok"] is True
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True)
def test_duplex_server_vad_hard_interrupt(omni_server, model_prefix: str) -> None:
result = asyncio.run(
run_server_vad_interrupt(
SimpleNamespace(
url=realtime_url(omni_server),
model=omni_server.model,
input_wav=str(validated_input_wav()),
interrupt_wav=str(validated_soft_interrupt_wav()),
ref_audio=str(resolve_ref_audio(model_prefix)),
chunk_ms=200,
timeout_s=180.0,
)
)
)
assert result["ok"] is True, json.dumps(result, ensure_ascii=False, indent=2)
@@ -652,7 +652,7 @@ async def test_native_realtime_protocol_preserves_input_turn_policy_hints():
@pytest.mark.asyncio
async def test_native_realtime_protocol_rejects_server_vad_without_interrupt():
async def test_native_realtime_protocol_rejects_unimplemented_server_vad():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
@@ -675,10 +675,7 @@ async def test_native_realtime_protocol_rejects_server_vad_without_interrupt():
error = ws.sent[-1]
assert error["type"] == "error"
assert error["error"]["code"] == "unsupported_turn_detection"
assert "interrupt_response=false" in error["error"]["message"]
assert "greater than 0.15" in str(
protocol._validate_realtime_turn_detection({"turn_detection": {"type": "server_vad", "threshold": 0.15}})
)
assert "server_vad" in error["error"]["message"]
@pytest.mark.asyncio
@@ -702,7 +699,7 @@ async def test_native_realtime_protocol_accepts_disabled_turn_detection():
@pytest.mark.asyncio
async def test_native_realtime_protocol_prefers_nested_vad_over_top_level():
async def test_native_realtime_protocol_rejects_nested_vad_even_when_top_level_is_disabled():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
@@ -722,18 +719,8 @@ async def test_native_realtime_protocol_prefers_nested_vad_over_top_level():
}
)
assert translated["session"]["extra_body"]["realtime_session_payload"]["overlap_policy"] == "barge_in_on_speech"
@pytest.mark.asyncio
async def test_realtime_vad_candidate_waits_for_session_ack():
protocol = NativeRealtimeSessionProtocol(TimedWebSocket()) # type: ignore[arg-type]
event = {"type": "session.update", "model": "test-model", "turn_detection": {"type": "server_vad"}}
for outbound, enabled in (({"type": "error"}, False), ({"type": "session.updated", "session": {}}, True)):
await protocol._to_duplex_event(event)
protocol.encode_outbound_event(outbound)
assert (protocol._server_vad is not None) is enabled
assert translated is None
assert ws.sent[-1]["error"]["code"] == "unsupported_turn_detection"
def test_native_duplex_handler_has_no_fixed_session_admission_cap():
@@ -5439,33 +5426,6 @@ async def test_minicpmo_native_duplex_open_unsupported_fails_session_create():
assert ws.sent[0]["runtime_control"]["unsupported_count"] == 1
@pytest.mark.asyncio
async def test_native_runtime_rejects_server_vad_without_barge_in(monkeypatch: pytest.MonkeyPatch):
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
capabilities = DuplexCapabilities.minicpmo45_native()
capabilities.supports_barge_in = False
monkeypatch.setattr(
handler._serving_runtime_adapter,
"capabilities",
lambda *, max_sessions: capabilities,
)
event = _native_session_create("sid-native-no-barge-in")
event["session"]["overlap_policy"] = "barge_in_on_speech"
ws = TimedWebSocket()
ws.put(event)
await handler.handle_session(ws)
assert ws.sent_types() == ["error"]
assert ws.sent[0]["code"] == "unsupported_turn_detection"
assert engine.opened == []
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_rejects_engine_without_fence_contract():
class LegacyEngineClient(FakeEngineClient):