[Cleanup][Full Duplex] Remove generic server VAD
Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
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):
|
||||
|
||||
Reference in new issue
Block a user