[Bugfix][MiniCPM-o] Isolate duplex handoff failures and barge-in (#6170)

Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
Sy03 authored and GitHub committed 2026-08-28 15:46:41 +08:00
1 parent 04b97a5922
commit 009b992c5b
23 files changed
+817 -98

No files matched your search

+1
View File
@@ -166,6 +166,7 @@ steps:
- tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py
- tests/e2e/online_serving/helpers/minicpmo_realtime_duplex_scenarios.py
- tests/e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py
- tests/e2e/online_serving/run_minicpmo_realtime_duplex_server_vad.py
- tests/e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py
- vllm_omni/deploy/minicpmo_4_5.yaml
- tests/e2e/online_serving/helpers/minicpmo_4_5_duplex.py
+1
View File
@@ -205,6 +205,7 @@ steps:
- tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py
- tests/e2e/online_serving/helpers/minicpmo_realtime_duplex_scenarios.py
- tests/e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py
- tests/e2e/online_serving/run_minicpmo_realtime_duplex_server_vad.py
- tests/e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py
- vllm_omni/deploy/minicpmo_4_5.yaml
- tests/e2e/online_serving/helpers/minicpmo_4_5_duplex.py
@@ -372,7 +372,7 @@ def parse_args() -> argparse.Namespace:
"--temperature",
type=float,
default=None,
help="Stage0 sampling temperature; response-required soft-interrupt uses 0.0.",
help="Stage 0 sampling temperature; omit to preserve the model default.",
)
parser.add_argument("--no-realtime-pacing", action="store_true")
parser.add_argument("--require-audio", action="store_true")
+5
View File
@@ -75,6 +75,7 @@ dev = [
# Provides top-level cosyvoice2; do not co-install stepaudio2-minicpmo,
# whose top-level stepaudio2 package conflicts with this distribution.
"step-audio2==1.0.0", # Step-Audio2 / MiniCPM-o 4.5 Token2Wav (flashcosyvoice / hyperpyyaml)
"silero-vad==6.1", # Realtime server-VAD duplex E2E
]
demo = [
@@ -84,6 +85,10 @@ demo = [
"cn2an>=0.5.22", # Chinese number normalization for TTS (e.g. "1次" -> "一次")
]
server-vad = [
"silero-vad==6.1",
]
forced-aligner = [
"qwen-asr>=0.0.6", # faithful Japanese/Korean word segmentation for the TTS forced aligner
]
@@ -0,0 +1,85 @@
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 [e for e in events if e.get("type") == kind and RealtimeEventCollector.response_id(e) == 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))[: PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE * 47 // 10]
session_id = f"server-vad-hard-interrupt-{id(args)}"
client = RealtimeDuplexClient(build_realtime_url(args.url, args.model, autostart=False, session_id=session_id))
async def until(predicate, label: str) -> None:
await wait_for(predicate, timeout_s=args.timeout_s, label=label)
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 until(lambda: any(e.get("type") == "response.created" for e in client.events.events), "response.created")
target_id = RealtimeEventCollector.response_id(
next(e for e in client.events.events if e.get("type") == "response.created")
)
assert target_id
await until(lambda: _events(client.events.events, target_id, "response.audio.delta"), "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 until(lambda: _events(client.events.events, target_id, "response.done"), "cancelled response.done")
await client.commit()
await until(
lambda: any(
e.get("type") == "response.done" and RealtimeEventCollector.response_id(e) != target_id
for e in client.events.events[cursor:]
),
"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")
terminal = done[0] if len(done) == 1 else None
trailing = events[events.index(terminal) + 1 :] if terminal is not None else events
stale = any(
e.get("type") == "response.audio.delta" and RealtimeEventCollector.response_id(e) == target_id for e in trailing
)
followup_audio = any(
e.get("type") == "response.audio.delta" and RealtimeEventCollector.response_id(e) != target_id
for e in events[cursor:]
)
ok = (
terminal is not None
and isinstance(terminal.get("response"), dict)
and terminal["response"].get("status") == "cancelled"
and terminal["response"].get("status_details", {}).get("reason") == "turn_detected"
and not stale
and sum(e.get("type") == "input_audio_buffer.speech_started" for e in events[cursor:]) == 1
and sum(e.get("type") == "input_audio_buffer.speech_stopped" for e in events[cursor:]) == 1
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}
@@ -25,6 +25,7 @@ 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,
)
@@ -92,3 +93,22 @@ def test_duplex_soft_interrupt(omni_server, tmp_path: Path) -> None:
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) -> 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()),
chunk_ms=200,
timeout_s=180.0,
)
)
)
assert result["ok"] is True, json.dumps(result, ensure_ascii=False, indent=2)
@@ -935,6 +935,36 @@ async def test_diffusion_client_error_output_propagates_status_code(orchestrator
orchestrator_fixture.thread.join(timeout=5)
@pytest.mark.asyncio
async def test_duplex_input_processor_failure_is_request_scoped(orchestrator_factory, monkeypatch) -> None:
class FailingStage(FakeStageClient):
def process_engine_inputs(self, *_args, **_kwargs):
raise ValueError("No latent or hidden_states found in thinker output")
fixture = orchestrator_factory(
[FakeStageClient(stage_type="llm"), FailingStage(stage_type="llm", final_output=True)]
)
state = OrchestratorRequestState(
request_id="bad",
prompt=SimpleNamespace(request_id="bad", prompt_token_ids=[1]),
sampling_params_list=[_sampling_params(), _sampling_params()],
final_stage_id=1,
duplex_identity=SimpleNamespace(),
)
async def no_cleanup(*_args, **_kwargs):
pass
monkeypatch.setattr(fixture.orchestrator, "_cleanup_request_ids", no_cleanup)
try:
await fixture.orchestrator._forward_to_next_stage_unguarded("bad", 0, _build_request_output("raw"), state)
error = await _wait_for_error_message(fixture, request_id="bad")
assert error.fatal is False
finally:
fixture.request_sync_q.put_nowait(ShutdownRequestMessage())
fixture.thread.join(timeout=5)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("status_code", "error_type"),
@@ -452,7 +452,7 @@ def test_minicpmo_native_capabilities_separate_model_state_from_core_kv_lease():
assert caps["supports_core_resumable_request"] is True
assert caps["supports_stage_connector_handoff"] is True
assert caps["supports_audio_truncate"] is True
assert caps["supports_barge_in"] is False
assert caps["supports_barge_in"] is True
assert caps["target_barge_in_latency_ms"] is None
assert caps["supports_multi_session"] is True
assert caps["supports_multi_session_same_replica"] is True
@@ -33,6 +33,7 @@ from vllm_omni.experimental.fullduplex.minicpmo45.runtime import (
from vllm_omni.experimental.fullduplex.minicpmo45.session import (
MiniCPMO45ServingSessionState,
)
from vllm_omni.experimental.fullduplex.openai import vad as realtime_vad
from vllm_omni.experimental.fullduplex.openai.protocol import (
DuplexCapabilities,
DuplexOverlapPolicy,
@@ -706,7 +707,7 @@ async def test_native_realtime_protocol_preserves_input_turn_policy_hints():
@pytest.mark.asyncio
async def test_native_realtime_protocol_rejects_unimplemented_server_vad():
async def test_native_realtime_protocol_rejects_server_vad_without_interrupt():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
@@ -729,7 +730,11 @@ async def test_native_realtime_protocol_rejects_unimplemented_server_vad():
error = ws.sent[-1]
assert error["type"] == "error"
assert error["error"]["code"] == "unsupported_turn_detection"
assert "server_vad" in error["error"]["message"]
assert "interrupt_response=false" in error["error"]["message"]
threshold_error = protocol._validate_realtime_turn_detection(
{"turn_detection": {"type": "server_vad", "threshold": 0.15}}
)
assert threshold_error == "turn_detection.threshold must be greater than 0.15 and at most 1"
@pytest.mark.asyncio
@@ -753,7 +758,7 @@ async def test_native_realtime_protocol_accepts_disabled_turn_detection():
@pytest.mark.asyncio
async def test_native_realtime_protocol_rejects_nested_vad_even_when_top_level_is_disabled():
async def test_native_realtime_protocol_prefers_nested_vad_over_top_level():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
@@ -773,8 +778,55 @@ async def test_native_realtime_protocol_rejects_nested_vad_even_when_top_level_i
}
)
assert translated is None
assert ws.sent[-1]["error"]["code"] == "unsupported_turn_detection"
assert translated["session"]["extra_body"]["realtime_session_payload"]["overlap_policy"] == "barge_in_on_speech"
@pytest.mark.asyncio
async def test_realtime_vad_edge_order_and_tiny_chunk_resampling(monkeypatch):
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
protocol._server_vad = realtime_vad.SileroStreamingVAD(realtime_vad.SileroVADConfig())
result = realtime_vad.StreamingVADResult(True, True, True, True)
monkeypatch.setattr(protocol._server_vad, "process_base64", lambda *args, **kwargs: result)
protocol._input_speech_started = True
protocol._hold_realtime_output_until_session_created = False
await protocol._to_duplex_event(
{"type": "input_audio_buffer.append", "audio": _pcm_f32_b64(1), "format": "pcm_f32le"}
)
assert [event["type"] for event in ws.sent] == [
"input_audio_buffer.speech_stopped",
"input_audio_buffer.speech_started",
]
ws.sent.clear()
protocol._input_speech_started = False
result = realtime_vad.StreamingVADResult(True, False, True, True)
await protocol._to_duplex_event({"type": "input_audio_buffer.append", "audio": "AAAA", "format": "pcm_f32le"})
assert [event["type"] for event in ws.sent] == [
"input_audio_buffer.speech_started",
"input_audio_buffer.speech_stopped",
]
detector = realtime_vad.SileroStreamingVAD(realtime_vad.SileroVADConfig(), frame_scorer=lambda _: 0.0)
for _ in range(1024):
detector.process_base64(_pcm_f32_b64(1), fmt="pcm_f32le", sample_rate_hz=48_000)
assert detector._processed_samples + detector._pending.size == 341
@pytest.mark.asyncio
async def test_realtime_server_vad_runtime_failures_are_terminal(monkeypatch):
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient()))
ws = TimedWebSocket()
ws.put(_server_vad_update("generic-vad", model="test-model", session_id="generic-vad"))
await handler.handle_session(ws, realtime_protocol=NativeRealtimeSessionProtocol({}))
assert ws.sent[-1]["error"]["code"] == "server_vad_requires_native_duplex"
monkeypatch.setattr(realtime_vad.SileroStreamingVAD, "process_base64", lambda *args, **kwargs: 1 / 0)
ws = TimedWebSocket()
create = _native_realtime_session_update("vad-inference-failure")
create["session"]["turn_detection"] = {"type": "server_vad"}
ws.put(create)
ws.put({"type": "input_audio_buffer.append", "audio": _pcm_f32_b64(1), "format": "pcm_f32le"})
await asyncio.wait_for(handler.handle_session(ws, realtime_protocol=NativeRealtimeSessionProtocol({})), timeout=2)
assert any(e.get("error", {}).get("code") == "realtime_input_failed" for e in ws.sent)
def test_native_duplex_handler_has_no_fixed_session_admission_cap():
@@ -1551,6 +1603,14 @@ def _native_realtime_session_update(
}
def _server_vad_update(event_id: str, **session: object) -> dict[str, object]:
return {
"type": "session.update",
"event_id": event_id,
"session": {"turn_detection": {"type": "server_vad"}, **session},
}
def test_native_stage0_request_id_matches_engine_after_same_session_id_reopen():
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient()))
first = handler._registry.create(session_id="sid-reused")
@@ -5311,7 +5371,7 @@ async def test_audio_clear_preserves_later_wire_order_append():
@pytest.mark.asyncio
async def test_native_append_is_cancelled_by_later_wire_order_input_cancel():
async def test_native_append_is_cancelled_by_later_wire_order_input_cancel(mocker):
class SlowAppendEngine(FakeEngineClient):
def __init__(self) -> None:
super().__init__()
@@ -5348,6 +5408,7 @@ async def test_native_append_is_cancelled_by_later_wire_order_input_cancel():
fence=fence,
)
rollback = mocker.spy(MiniCPMO45PcmAppendBuffer, "_rollback_reservation")
engine = SlowAppendEngine()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
@@ -5378,6 +5439,7 @@ async def test_native_append_is_cancelled_by_later_wire_order_input_cancel():
await asyncio.wait_for(handler_task, timeout=1)
assert engine.append_cancelled.is_set()
rollback.assert_called_once()
assert engine.signal_fences[-1] == DuplexFence("sid-cancel-slow-append")
assert engine.signal_next_fences[-1] == DuplexFence("sid-cancel-slow-append", epoch=1)
@@ -5473,33 +5535,59 @@ async def test_native_session_update_commits_config_only_after_runtime_ack():
return await super().signal_duplex_turn_async(session_id, **kwargs)
engine = BlockingUpdateEngine()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine))
ws = TimedWebSocket()
ws.put(_session_create("sid-update-two-phase"))
ws.put(
{
"type": "turn.signal",
"event": "session.update",
"payload": {"instructions": "candidate instructions"},
}
)
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
ws.put(_native_realtime_session_update("sid-update-two-phase"))
ws.put(_server_vad_update("update-two-phase"))
handler_task = asyncio.create_task(handler.handle_session(ws))
handler_task = asyncio.create_task(handler.handle_session(ws, realtime_protocol=protocol))
await asyncio.wait_for(engine.update_started.wait(), timeout=1)
live_session = handler._registry.get("sid-update-two-phase")
assert live_session is not None
assert live_session.config.instructions != "candidate instructions"
assert live_session.config.overlap_policy == DuplexOverlapPolicy.LISTEN_ONLY.value
assert protocol._pending_turn_detection_update is not None
assert protocol._server_vad is None
protocol.encode_outbound_event({"type": "error", "code": "unrelated", "error": "unrelated"})
assert protocol._pending_turn_detection_update is not None
engine.release_update.set()
ws.put({"type": "session.close"})
await asyncio.wait_for(handler_task, timeout=2)
updated = next(message for message in ws.sent if message.get("type") == "session.updated")
assert updated["session"]["instructions"] == "candidate instructions"
updated = next(message for message in reversed(ws.sent) if message.get("type") == "session.updated")
assert updated["session"]["overlap_policy"] == DuplexOverlapPolicy.BARGE_IN_ON_SPEECH.value
assert isinstance(protocol._server_vad, realtime_vad.SileroStreamingVAD)
@pytest.mark.parametrize(
("append_fails", "expected_code"),
[(True, "session_update_aborted"), (False, "instructions_update_unsupported")],
)
@pytest.mark.asyncio
async def test_realtime_vad_update_rejection_does_not_stall(append_fails, expected_code):
append_result = {"operation": "append", "ok": False, "error_count": 1} if append_fails else None
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient(append_result=append_result)))
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
create = _native_realtime_session_update("sid-rejected-vad-update")
create["session"]["extra_body"]["auto_response"] = append_fails
ws.put(create)
if append_fails:
ws.put({"type": "input_audio_buffer.append", "audio": _pcm_f32_b64(16_000), "format": "pcm_f32le"})
update = _server_vad_update("rejected-vad-update")
if not append_fails:
update["session"]["instructions"] = "You are now a pirate."
ws.put(update)
ws.put({"type": "session.close"})
await asyncio.wait_for(handler.handle_session(ws, realtime_protocol=protocol), timeout=2)
errors = [message["error"] for message in ws.sent if isinstance(message.get("error"), dict)]
error = next(error for error in errors if error.get("code") == expected_code)
assert error["event_id"] == "rejected-vad-update"
assert protocol._pending_turn_detection_update is None
assert protocol._server_vad is None
@pytest.mark.asyncio
@@ -5999,7 +6087,7 @@ async def test_minicpmo_native_duplex_rejects_ref_audio_path():
assert engine.opened == []
def test_minicpmo_native_duplex_explicit_barge_in_request_is_deferred_as_listen():
def test_minicpmo_native_duplex_explicit_barge_in_request_interrupts():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
@@ -6026,12 +6114,10 @@ def test_minicpmo_native_duplex_explicit_barge_in_request_is_deferred_as_listen(
)
assert decision == {
"action": "listen",
"reason": "barge_in_unsupported",
"action": "barge_in",
"reason": "client_force_barge_in",
"duration_ms": 1000,
"overlap_speech_ms": 1000,
"buffer_audio": True,
"defer_runtime_append": True,
}
@@ -6043,7 +6129,7 @@ def test_minicpmo_native_duplex_explicit_barge_in_request_is_deferred_as_listen(
{"type": "turn.signal", "event": "barge_in"},
],
)
async def test_minicpmo_native_duplex_rejects_unadvertised_barge_in_control(
async def test_minicpmo_native_duplex_accepts_explicit_barge_in_control(
barge_in_event: dict[str, object],
):
engine = FakeEngineClient()
@@ -6059,9 +6145,8 @@ async def test_minicpmo_native_duplex_rejects_unadvertised_barge_in_control(
await handler.handle_session(ws)
error = next(message for message in ws.sent if message.get("code") == "barge_in_unsupported")
assert error["session_id"] == "sid-native-barge-control-disabled"
assert ("sid-native-barge-control-disabled", "barge_in") not in engine.signals
assert not any(message.get("code") == "barge_in_unsupported" for message in ws.sent)
assert ("sid-native-barge-control-disabled", "barge_in") in engine.signals
@pytest.mark.asyncio
@@ -414,8 +414,10 @@ def test_talker_drains_all_received_positions_per_wake() -> None:
session = talker._sessions["req-0"]
assert session["step"] == 1 and not session["sync_mode"]
# First decode wake: drains ALL prompt-region steps (t=1..P-1) at once.
_, embeds, update = talker.preprocess(ids, None, _omni_is_prefill=False, **info)
# First resumed wake: drains ALL prompt-region steps (t=1..P-1) at once.
# vLLM 0.28 reports each resumed one-token segment as prefill; the model
# session must still advance instead of restarting its EAR-TTS warmup.
_, embeds, update = talker.preprocess(ids, None, _omni_is_prefill=True, **info)
assert session["step"] == prompt_len # t advanced 1 -> P in one wake
assert update["codes"]["audio"].shape == (prompt_len - 1, 31)
assert float(embeds[0, 0]) == 0.0 # offline stream waits for upstream terminal
+17 -2
View File
@@ -2638,13 +2638,28 @@ class Orchestrator:
req_state.prompt,
streaming_context=req_state.streaming,
)
except Exception:
except Exception as exc:
logger.exception(
"[Orchestrator] req=%s process_engine_inputs FAILED for stage-%s",
req_id,
next_logical,
)
raise
if not self._is_duplex_session_request(req_state):
raise
await self.output_async_queue.put(
ErrorMessage(
request_id=req_id,
stage_id=next_logical,
error=f"Stage-{next_logical} input processor failed: {type(exc).__name__}: {exc}",
error_type=type(exc).__name__,
)
)
await self._cleanup_request_ids(
[req_id, *self._cfg_tracker.cleanup_parent(req_id)],
abort=True,
close_duplex_sessions=True,
)
return
finally:
req_state.streaming.source_token_decoder = previous_decoder
+7 -8
View File
@@ -58,13 +58,13 @@ The checkpoint keeps these verified contracts:
- scheduler data-plane append over a resumable request;
- stale epoch/turn/response fencing;
- `/v1/duplex` and OpenAI Realtime projection;
- optional OpenAI `server_vad` turn detection backed by per-session Silero VAD;
- existing JoyVL behavior.
The checkpoint does not claim:
- scheduler-native KV append;
- deterministic VAD-triggered interruption (the browser intentionally does not
run VAD; MiniCPM owns listen/speak decisions at model-unit boundaries);
- an acoustic-onset-to-cancel latency target across arbitrary client chunk sizes;
- production multi-session admission, fairness, capacity, or failure recovery;
- bounded long-session KV;
- video input or audio/video synchronization.
@@ -640,9 +640,8 @@ multi-turn runtime evidence.
During auto-response overlap, `preserve_realtime_input` distinguishes "do not
append this silent chunk to the native buffer" from "clear the open Realtime
item". Silent overlap no longer discards earlier user PCM. This is an input
ownership correction. It does not add a VAD policy: overlap is admitted to
Stage0 and the model decides whether to listen or speak.
item". Silent overlap no longer discards earlier user PCM. A separate explicit
`server_vad` policy is documented in the Realtime contract above.
The first chunk of one overlapping input item also reserves its target model
turn. A later Realtime commit uses that reserved identity even if response EOS
@@ -1024,6 +1023,6 @@ Passing this checkpoint supports the statement:
> Single-session, model-owned MiniCPM-o 4.5 native duplex is reviewable on the
> validated H20 configuration.
It does not support claims for deterministic VAD-triggered interruption,
multi-session production concurrency, bounded long-session KV,
scheduler-native append, or video input.
It does not support claims for an acoustic-onset barge-in latency target across
arbitrary client chunk sizes, multi-session production concurrency, bounded
long-session KV, scheduler-native append, or video input.
+7 -2
View File
@@ -497,6 +497,7 @@ class RealtimeDuplexClient:
auto_response: bool = True,
temperature: float | None = None,
extra_body: dict[str, object] | None = None,
turn_detection: dict[str, object] | None = None,
session_id: str | None = None,
idle_timeout_s: float | None = None,
timeout_s: float = 20.0,
@@ -517,8 +518,12 @@ class RealtimeDuplexClient:
"modalities": ["audio", "text"],
"input_audio_format": "pcm16",
"output_audio_format": output_audio_format,
"turn_detection": None,
"overlap_policy": "listen_only",
"turn_detection": dict(turn_detection) if turn_detection is not None else None,
"overlap_policy": (
"barge_in_on_speech"
if turn_detection is not None and turn_detection.get("interrupt_response", True) is True
else "listen_only"
),
"playback_commit_policy": "ack_only",
"extra_body": session_extra_body,
}
@@ -9,7 +9,6 @@ from uuid import uuid4
class DuplexOverlapPolicy(str, Enum):
AUTO = "auto"
LISTEN_ONLY = "listen_only"
BARGE_IN_ON_SPEECH = "barge_in_on_speech"
@@ -101,7 +100,7 @@ class DuplexCapabilities:
supports_multi_session = max_sessions > 1
return cls(
supports_model_native_turn_policy=True,
supports_barge_in=False,
supports_barge_in=True,
supports_input_append=True,
supports_replace_latest_chunk=False,
supports_reencode_context=False,
@@ -129,6 +128,8 @@ class DuplexCapabilities:
signal_sources=["model_native", "client_event", "server_policy"],
stage_handoff_transport="scheduler_data_plane",
chunk_period_ms=1000,
# Barge-in latency depends on client chunking and lacks hardware E2E
# measurement, so do not advertise an invented target.
target_barge_in_latency_ms=None,
)
@@ -18,6 +18,13 @@ from vllm_omni.experimental.fullduplex.openai.realtime_state import (
REALTIME_INPUT_AUDIO_FORMATS,
REALTIME_OUTPUT_AUDIO_FORMATS,
)
from vllm_omni.experimental.fullduplex.openai.vad import (
SILERO_VAD_MIN_THRESHOLD,
ServerVADUnavailableError,
SileroStreamingVAD,
SileroVADConfig,
StreamingVADResult,
)
class RealtimeInputTranslator:
@@ -92,6 +99,7 @@ class RealtimeInputTranslator:
)
)
return None
self._prepare_realtime_turn_detection_update(session_payload)
self._apply_realtime_session_defaults(session_payload)
session_payload.update(self._realtime_overlap_fields(session_payload))
if not self._opened:
@@ -102,6 +110,7 @@ class RealtimeInputTranslator:
"type": "turn.signal",
"event": "session.update",
"payload": session_payload,
"realtime_event_id": event.get("event_id"),
}
if event_type == "conversation.item.create":
item = event.get("item")
@@ -278,15 +287,56 @@ class RealtimeInputTranslator:
)
)
return None
looks_like_speech = self._input_looks_like_speech(event, audio=audio, fmt=fmt)
detector = self._server_vad
vad_result: StreamingVADResult | None = None
if isinstance(detector, SileroStreamingVAD):
try:
vad_result = detector.process_base64(audio, fmt=fmt, sample_rate_hz=sample_rate_hz)
except (ServerVADUnavailableError, ValueError) as exc:
unavailable = isinstance(exc, ServerVADUnavailableError)
await self._send_realtime_payload(
self._realtime_error_payload(
"server_vad_unavailable" if unavailable else "bad_audio",
str(exc),
event_id=event.get("event_id"),
param="turn_detection" if unavailable else "audio",
)
)
return None
looks_like_speech = (
vad_result.is_speech
if vad_result is not None
else self._input_looks_like_speech(event, audio=audio, fmt=fmt)
)
self._input_audio_buffer_has_audio = self._input_audio_buffer_has_audio or (
looks_like_speech and isinstance(audio, str) and bool(audio)
)
self._input_audio_buffer_had_non_speech = self._input_audio_buffer_had_non_speech or (
not looks_like_speech and isinstance(audio, str) and bool(audio)
)
if looks_like_speech:
stopped_event: dict[str, object] | None = None
if vad_result is not None and vad_result.speech_stopped:
stopped_event = dict(event)
if vad_result.speech_end_ms is not None:
stopped_event["audio_end_ms"] = vad_result.speech_end_ms
if stopped_event is not None and vad_result is not None and vad_result.speech_active:
await self._emit_input_speech_stopped(
stopped_event,
item_id=self._active_input_item_id or f"item_{uuid4().hex}",
)
if vad_result is not None and vad_result.speech_started:
started_event = dict(event)
if vad_result.speech_start_ms is not None:
started_event["audio_start_ms"] = vad_result.speech_start_ms
await self._emit_input_speech_started(started_event)
elif vad_result is None and looks_like_speech:
await self._emit_input_speech_started(event)
if stopped_event is not None and vad_result is not None and not vad_result.speech_active:
await self._emit_input_speech_stopped(
stopped_event,
item_id=self._active_input_item_id or f"item_{uuid4().hex}",
)
if looks_like_speech:
self._remember_input_transcript_hint(event)
payload = {
"type": "input_audio_buffer.append",
@@ -309,14 +359,25 @@ class RealtimeInputTranslator:
return None
payload["video_frames"] = [frame for frame in video_frames if isinstance(frame, str) and frame]
self._copy_realtime_input_hints(event, payload)
if not looks_like_speech:
payload["is_speech"] = False
payload["is_speech"] = looks_like_speech
if vad_result is not None:
payload["vad"] = {
"backend": "silero",
"is_speech": vad_result.is_speech,
"speech_active": vad_result.speech_active,
"speech_started": vad_result.speech_started,
"speech_stopped": vad_result.speech_stopped,
"speech_probability": vad_result.speech_probability,
}
if vad_result.speech_active:
payload["force_listen"] = True
return payload
if event_type == "input_audio_buffer.commit":
if not self._input_audio_buffer_has_audio:
if self._input_audio_buffer_had_non_speech:
self._input_audio_buffer_had_non_speech = False
self._active_input_item_id = None
self._reset_server_vad()
return {
"type": "input_audio_buffer.commit",
"final": event.get("final", True),
@@ -338,6 +399,7 @@ class RealtimeInputTranslator:
self._active_input_item_id = None
self._input_audio_buffer_has_audio = False
self._input_audio_buffer_had_non_speech = False
self._reset_server_vad()
payload = {
"type": "input_audio_buffer.commit",
"final": event.get("final", True),
@@ -353,6 +415,7 @@ class RealtimeInputTranslator:
self._input_audio_buffer_has_audio = False
self._input_audio_buffer_had_non_speech = False
self._input_audio_buffer_transcript_parts.clear()
self._reset_server_vad()
return {"type": "input_audio_buffer.clear", "reason": event_type}
if event_type == "output_audio_buffer.clear":
payload = {"type": "output_audio_buffer.clear", "reason": event_type}
@@ -995,25 +1058,124 @@ class RealtimeInputTranslator:
return None
@staticmethod
def _validate_realtime_turn_detection(session_payload: dict[str, object]) -> str | None:
configured_values: list[tuple[str, object]] = []
if "turn_detection" in session_payload:
configured_values.append(("turn_detection", session_payload["turn_detection"]))
def _configured_realtime_turn_detection(session_payload: dict[str, object]) -> tuple[str | None, object]:
field = "turn_detection" if "turn_detection" in session_payload else None
selected = session_payload.get("turn_detection")
audio_config = session_payload.get("audio")
if isinstance(audio_config, dict):
audio_input = audio_config.get("input")
if isinstance(audio_input, dict) and "turn_detection" in audio_input:
configured_values.append(("audio.input.turn_detection", audio_input["turn_detection"]))
for field_path, turn_detection in configured_values:
if turn_detection is None:
continue
turn_detection_type = turn_detection.get("type") if isinstance(turn_detection, dict) else turn_detection
return (
f"{field_path}={turn_detection_type!r} is not implemented by the duplex Realtime adapter; "
"set turn_detection to null and commit input explicitly, or use the model-owned duplex policy"
)
field, selected = "audio.input.turn_detection", audio_input["turn_detection"]
return field, selected
@classmethod
def _validate_realtime_turn_detection(cls, session_payload: dict[str, object]) -> str | None:
field, turn_detection = cls._configured_realtime_turn_detection(session_payload)
if field is None:
if session_payload.get("overlap_policy") == "barge_in_on_speech":
return (
"overlap_policy='barge_in_on_speech' requires "
"turn_detection.type='server_vad' on the Realtime endpoint"
)
return None
if turn_detection is not None and not isinstance(turn_detection, dict):
return f"{field} must be null or an object with type='server_vad'"
if isinstance(turn_detection, dict):
if turn_detection.get("type") != "server_vad":
return f"{field}.type must be 'server_vad'"
threshold = turn_detection.get("threshold", 0.5)
if not cls._valid_vad_number(threshold, minimum=SILERO_VAD_MIN_THRESHOLD, strict=True, maximum=1):
return f"{field}.threshold must be greater than {SILERO_VAD_MIN_THRESHOLD} and at most 1"
for name in ("prefix_padding_ms", "silence_duration_ms", "min_speech_duration_ms"):
value = turn_detection.get(name)
if value is not None and not cls._valid_vad_number(value, minimum=0):
return f"{field}.{name} must be a non-negative number"
if turn_detection.get("interrupt_response", True) is not True:
return (
f"{field}.interrupt_response=false is unsupported; "
"use turn_detection=null for model-owned listen/speak"
)
desired_policy = "barge_in_on_speech" if turn_detection is not None else "listen_only"
overlap_policy = session_payload.get("overlap_policy")
if isinstance(overlap_policy, str) and overlap_policy != desired_policy:
return f"overlap_policy={overlap_policy!r} conflicts with turn_detection; expected {desired_policy!r}"
return None
@staticmethod
def _valid_vad_number(value: object, *, minimum: float, maximum: float | None = None, strict: bool = False) -> bool:
return (
isinstance(value, int | float)
and not isinstance(value, bool)
and np.isfinite(float(value))
and (float(value) > minimum if strict else float(value) >= minimum)
and (maximum is None or float(value) <= maximum)
)
def _prepare_realtime_turn_detection_update(self, session_payload: dict[str, object]) -> None:
field, configured = self._configured_realtime_turn_detection(session_payload)
if field is None:
return
if self._pending_turn_detection_update is not None:
raise RuntimeError("A Realtime turn-detection update is already pending")
turn_detection: dict[str, object] | None = None
detector: SileroStreamingVAD | None = None
if configured is None:
session_payload["overlap_policy"] = "listen_only"
else:
assert isinstance(configured, dict)
turn_detection = {
"type": "server_vad",
"interrupt_response": True,
"threshold": 0.5,
"prefix_padding_ms": 300,
"silence_duration_ms": 500,
"min_speech_duration_ms": 96,
**configured,
}
detector = SileroStreamingVAD(
SileroVADConfig(
threshold=float(turn_detection["threshold"]),
prefix_padding_ms=int(turn_detection["prefix_padding_ms"]),
silence_duration_ms=int(turn_detection["silence_duration_ms"]),
min_speech_duration_ms=max(32, int(turn_detection["min_speech_duration_ms"])),
)
)
session_payload["turn_detection"] = dict(turn_detection)
session_payload["overlap_policy"] = "barge_in_on_speech"
self._pending_turn_detection_update = (turn_detection, detector, asyncio.Event())
async def wait_for_realtime_turn_detection_update(self) -> None:
candidate = self._pending_turn_detection_update
if candidate is not None:
await candidate[2].wait()
def commit_realtime_turn_detection_update(self) -> None:
self._resolve_realtime_turn_detection_update(commit=True)
def reject_realtime_turn_detection_update(self) -> None:
self._resolve_realtime_turn_detection_update(commit=False)
def _resolve_realtime_turn_detection_update(self, *, commit: bool) -> None:
candidate = self._pending_turn_detection_update
if candidate is None:
return
turn_detection, detector, resolved = candidate
discarded = self._server_vad if commit else detector
if commit:
self._turn_detection, self._server_vad = turn_detection, detector
self._pending_turn_detection_update = None
resolved.set()
reset = getattr(discarded, "reset", None)
if callable(reset):
reset()
def _reset_server_vad(self) -> None:
reset = getattr(self._server_vad, "reset", None)
if callable(reset):
reset()
@staticmethod
def _input_audio_transcription_config(session_payload: dict[str, object]) -> dict[str, object] | None:
transcription = session_payload.get("input_audio_transcription")
@@ -262,15 +262,8 @@ class RealtimeOutputProjector:
content_index=0,
audio_end_ms=committed_audio_ms,
)
payloads.append(
{
"type": "conversation.item.truncated",
"item_id": item_id,
"content_index": 0,
"audio_end_ms": committed_audio_ms,
"event": event,
}
)
# Align server history with acknowledged playback; only explicit
# client truncation emits ``conversation.item.truncated``.
payloads.extend(self._realtime_audio_done_events(event, response_id))
payloads.extend(
self._realtime_response_terminal_events(
@@ -413,7 +406,7 @@ class RealtimeOutputProjector:
return [event]
message = str(raw_error or event.get("message") or "Duplex runtime error")
code = str(event.get("code") or "duplex_error")
return [self._realtime_error_payload(code, message)]
return [self._realtime_error_payload(code, message, event_id=event.get("realtime_event_id"))]
if event_type == "session.closed":
return [{"type": "session.closed", "event": event}]
return [{"type": f"duplex.{event_type}", "event": event}]
@@ -58,6 +58,7 @@ class NativeRealtimeSessionProtocol(
while True:
if not self._pending_outbound.empty():
return json.dumps(await self._pending_outbound.get())
await self.wait_for_realtime_turn_detection_update()
raw = await websocket.receive_text()
try:
event = json.loads(raw)
@@ -57,6 +57,7 @@ REALTIME_ERROR_TYPES_BY_CODE = {
"native_text_append_unsupported": "invalid_request_error",
"runtime_native_stage_role_required": "server_error",
"runtime_native_runner_kv_required": "server_error",
"server_vad_unavailable": "server_error",
}
@@ -105,6 +106,9 @@ class RealtimeSessionState:
_input_sample_rate_hz: int = 16000
_output_audio_format: str = "pcm16"
_overlap_silence_rms: float = 0.003
_turn_detection: dict[str, object] | None = None
_server_vad: object | None = None
_pending_turn_detection_update: tuple[dict[str, object] | None, object | None, asyncio.Event] | None = None
_send_realtime_json: Any = None
_initial_session_update: bool = False
_input_speech_started: bool = False
@@ -180,6 +184,11 @@ class RealtimeStateOwner:
_input_sample_rate_hz: int = _RealtimeStateField()
_output_audio_format: str = _RealtimeStateField()
_overlap_silence_rms: float = _RealtimeStateField()
_turn_detection: dict[str, object] | None = _RealtimeStateField()
_server_vad: object | None = _RealtimeStateField()
_pending_turn_detection_update: tuple[dict[str, object] | None, object | None, asyncio.Event] | None = (
_RealtimeStateField()
)
_send_realtime_json: Any = _RealtimeStateField()
_initial_session_update: bool = _RealtimeStateField()
_input_speech_started: bool = _RealtimeStateField()
@@ -494,6 +494,22 @@ class OmniDuplexSessionHandler(
# playback only controls history ACKs, not model admission.
if is_speech:
session.accumulate_overlap_speech(duration_ms)
vad_speech_started = self._vad_speech_started(event, payload)
if (
self._uses_native_input_append(session)
and session.capabilities.supports_barge_in
and is_speech
and session.config.overlap_policy == DuplexOverlapPolicy.BARGE_IN_ON_SPEECH.value
and vad_speech_started is not False
):
return {
"action": "barge_in",
"reason": ("server_vad_speech_started" if vad_speech_started is True else "barge_in_on_speech"),
"cancel_reason": "turn_detected" if vad_speech_started is True else "barge_in",
"duration_ms": duration_ms,
"overlap_speech_ms": session.overlap_speech_ms,
"buffer_audio": True,
}
return {
"action": "listen",
"reason": "auto_response_continuous",
@@ -501,7 +517,7 @@ class OmniDuplexSessionHandler(
"overlap_speech_ms": session.overlap_speech_ms,
"buffer_audio": True,
"defer_runtime_append": False,
"force_listen": False,
"force_listen": event.get("force_listen") is True or payload.get("force_listen") is True,
"preserve_realtime_input": True,
}
if bool(event.get("force_listen", False)):
@@ -553,9 +569,22 @@ class OmniDuplexSessionHandler(
session.accumulate_overlap_speech(duration_ms)
if policy == DuplexOverlapPolicy.BARGE_IN_ON_SPEECH.value:
vad_speech_started = self._vad_speech_started(event, payload)
if vad_speech_started is False:
return {
"action": "listen",
"reason": "server_vad_utterance_active",
"duration_ms": duration_ms,
"overlap_speech_ms": session.overlap_speech_ms,
"buffer_audio": True,
"defer_runtime_append": False,
"force_listen": True,
"preserve_realtime_input": True,
}
return {
"action": "barge_in",
"reason": "policy_barge_in_on_speech",
"reason": ("server_vad_speech_started" if vad_speech_started is True else "policy_barge_in_on_speech"),
"cancel_reason": "turn_detected" if vad_speech_started is True else "barge_in",
"duration_ms": duration_ms,
"overlap_speech_ms": session.overlap_speech_ms,
"buffer_audio": True,
@@ -599,6 +628,17 @@ class OmniDuplexSessionHandler(
"defer_runtime_append": True,
}
@staticmethod
def _vad_speech_started(
event: Mapping[str, object],
payload: Mapping[str, object],
) -> bool | None:
for source in (event, payload):
vad = source.get("vad")
if isinstance(vad, Mapping) and isinstance(vad.get("speech_started"), bool):
return bool(vad["speech_started"])
return None
@staticmethod
def _event_requests_barge_in(event: Mapping[str, object]) -> bool:
if event.get("force_barge_in") is True:
@@ -1155,7 +1195,13 @@ class OmniDuplexSessionHandler(
candidate_config: DuplexSessionConfig,
) -> dict[str, object] | None:
if not self._uses_native_input_append(session):
return None
if candidate_config.overlap_policy != DuplexOverlapPolicy.BARGE_IN_ON_SPEECH.value:
return None
return {
"type": "error",
"code": "server_vad_requires_native_duplex",
"error": "Realtime server_vad requires a model-native duplex runtime",
}
if (
candidate_config.instructions != session.config.instructions
and self._runtime_session_state(session).native_context_locked
@@ -18,6 +18,7 @@ from vllm_omni.experimental.fullduplex.openai.commit_policy import (
decide_commit_action,
)
from vllm_omni.experimental.fullduplex.openai.protocol import (
DuplexOverlapPolicy,
DuplexPlaybackCommitPolicy,
DuplexSession,
DuplexSessionState,
@@ -248,6 +249,9 @@ class DuplexSessionRunnerMixin:
await actor.enqueue_event(event)
except WebSocketDisconnect:
await actor.enqueue_event({"type": "__disconnect__"})
except Exception as exc:
await emit_event({"type": "error", "error": str(exc), "code": "realtime_input_failed"})
await actor.enqueue_event({"type": "__disconnect__"})
async def next_actor_event() -> dict[str, object]:
nonlocal pending_turn_reservations, transport_detached
@@ -411,6 +415,8 @@ class DuplexSessionRunnerMixin:
)
return append_ok
except asyncio.CancelledError:
if pcm_reservation is not None:
pcm_reservation.rollback()
raise
except Exception as exc:
if pcm_reservation is not None:
@@ -670,6 +676,9 @@ class DuplexSessionRunnerMixin:
if handshake is None:
return
session = handshake.session
if initial_update_error := self._runtime_session_candidate_update_error(session, session.config):
await emit_event(initial_update_error)
return
if handshake.resumed:
native = self._serving_runtime_adapter.session_states[session.session_id]
actor.tasks = self._session_tasks[session.session_id]
@@ -721,6 +730,8 @@ class DuplexSessionRunnerMixin:
)
if isinstance(open_result, dict):
created_payload["runtime_control"] = self._redact_runtime_control_result(open_result)
if realtime_protocol is not None:
realtime_protocol.commit_realtime_turn_detection_update()
await emit_event(created_payload)
resume_credential_delivered = session.capabilities.supports_session_resume
reader_task = asyncio.create_task(read_event_loop(), name="duplex-session-reader")
@@ -903,7 +914,11 @@ class DuplexSessionRunnerMixin:
if event_type in {"input.cancel", "response.cancel", "barge_in", "output_audio_buffer.clear"}:
cancel_reason = (
"output_audio_buffer_clear" if event_type == "output_audio_buffer.clear" else "barge_in"
"output_audio_buffer_clear"
if event_type == "output_audio_buffer.clear"
else "client_cancelled"
if event_type == "response.cancel"
else "barge_in"
)
cancelled_fence = DuplexFence(
session.session_id,
@@ -1068,9 +1083,20 @@ class DuplexSessionRunnerMixin:
await emit_event(self._barge_in_unsupported_error(session))
continue
if turn_event == "session.update":
realtime_event_id = event.get("realtime_event_id")
async def emit_update_event(update_event: dict[str, object]) -> None:
if isinstance(realtime_event_id, str) and realtime_event_id:
update_event = {**update_event, "realtime_event_id": realtime_event_id}
await emit_event(update_event)
def reject_update() -> None:
if realtime_protocol is not None:
realtime_protocol.reject_realtime_turn_detection_update()
payload = event.get("payload")
if not isinstance(payload, dict):
await emit_event(
await emit_update_event(
{
"type": "error",
"session_id": session.session_id,
@@ -1078,12 +1104,22 @@ class DuplexSessionRunnerMixin:
"error": "session.update requires a session payload",
}
)
reject_update()
continue
if not await wait_for_native_append_tail():
await emit_update_event(
{
"type": "error",
"code": "session_update_aborted",
"error": "session.update was not applied because the preceding append failed",
}
)
reject_update()
continue
runtime_update_error = self._runtime_session_update_error(session, payload)
if runtime_update_error is not None:
await emit_event(runtime_update_error)
await emit_update_event(runtime_update_error)
reject_update()
continue
previous_config = session.config
candidate_config = deepcopy(previous_config)
@@ -1093,14 +1129,16 @@ class DuplexSessionRunnerMixin:
finally:
session.replace_config(previous_config)
if update_error is not None:
await emit_event(update_error)
await emit_update_event(update_error)
reject_update()
continue
runtime_update_error = self._runtime_session_candidate_update_error(
session,
candidate_config,
)
if runtime_update_error is not None:
await emit_event(runtime_update_error)
await emit_update_event(runtime_update_error)
reject_update()
continue
try:
candidate_runtime_config = self._runtime_config_for_session_update(
@@ -1108,7 +1146,7 @@ class DuplexSessionRunnerMixin:
candidate_config,
)
except ServingRuntimeConfigError as exc:
await emit_event(
await emit_update_event(
{
"type": "error",
"session_id": session.session_id,
@@ -1116,18 +1154,22 @@ class DuplexSessionRunnerMixin:
"error": str(exc),
}
)
reject_update()
continue
if not await self._signal_runtime_session(
session,
turn_event,
emit_event,
emit_update_event,
session_config=candidate_config.as_dict(),
runtime_config=candidate_runtime_config,
):
reject_update()
continue
session.replace_config(candidate_config)
session.replace_runtime_config(candidate_runtime_config)
await emit_event(
if realtime_protocol is not None:
realtime_protocol.commit_realtime_turn_detection_update()
await emit_update_event(
{
"type": "session.updated",
"session": session.as_public_dict(),
@@ -1337,7 +1379,16 @@ class DuplexSessionRunnerMixin:
buffer_overlap_audio = True
if self._uses_native_input_append(session):
mark_pending_silence_superseded()
overlap_active = not self._session_auto_responds(session) and native_response_in_progress()
overlap_active = native_response_in_progress() and (
not self._session_auto_responds(session)
or (
session.capabilities.supports_barge_in
and (
session.config.overlap_policy == DuplexOverlapPolicy.BARGE_IN_ON_SPEECH.value
or self._event_requests_barge_in(event)
)
)
)
if overlap_active:
decision = self._overlap_decision(session, event, payload)
await self._emit_overlap_decision(emit_event, session, decision)
@@ -1378,11 +1429,12 @@ class DuplexSessionRunnerMixin:
native.speech_since_commit = False
await actor.cancel_append_tasks()
had_native_stream = native.data_plane_task is not None
cancel_reason = str(decision.get("cancel_reason") or "barge_in")
cancelled = await self._cancel_active_response(
session,
actor.active_response_task,
emit_event,
reason="barge_in",
reason=cancel_reason,
)
had_native_stream = (
await self._cancel_native_data_plane_stream(session) or had_native_stream
@@ -1398,7 +1450,7 @@ class DuplexSessionRunnerMixin:
"type": "audio.cancelled",
"session_id": session.session_id,
"response_id": old_response_id,
"reason": "barge_in",
"reason": cancel_reason,
"cancelled_epoch": old_epoch,
"epoch": new_epoch,
"committed_ms": committed_ms,
@@ -1418,7 +1470,7 @@ class DuplexSessionRunnerMixin:
"type": "audio.cancelled",
"session_id": session.session_id,
"response_id": session.last_response_id,
"reason": "barge_in",
"reason": cancel_reason,
"cancelled_epoch": old_epoch,
"epoch": new_epoch,
"committed_ms": committed_ms,
@@ -1950,6 +2002,8 @@ class DuplexSessionRunnerMixin:
with suppress(Exception):
await emit_event({"type": "error", "error": str(exc), "code": "internal_error"})
finally:
if realtime_protocol is not None:
realtime_protocol.reject_realtime_turn_detection_update()
if session is not None:
if reader_task is not None and not reader_task.done():
reader_task.cancel()
@@ -0,0 +1,178 @@
from __future__ import annotations
import binascii
from collections.abc import Callable
from dataclasses import dataclass
import numpy as np
import pybase64 as base64
SILERO_VAD_MIN_THRESHOLD = 0.15
class ServerVADUnavailableError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class SileroVADConfig:
threshold: float = 0.5
prefix_padding_ms: int = 300
silence_duration_ms: int = 500
min_speech_duration_ms: int = 96
@dataclass(frozen=True, slots=True)
class StreamingVADResult:
is_speech: bool
speech_active: bool
speech_started: bool = False
speech_stopped: bool = False
speech_probability: float = 0.0
speech_start_ms: int | None = None
speech_end_ms: int | None = None
class SileroStreamingVAD:
_SAMPLE_RATE_HZ = 16_000
_WINDOW_SAMPLES = 512
def __init__(
self,
config: SileroVADConfig,
*,
frame_scorer: Callable[[np.ndarray], float] | None = None,
) -> None:
self.config = config
self._frame_scorer = frame_scorer
self._model: object | None = None
self._clear_stream_state()
def _clear_stream_state(self) -> None:
self._pending = np.empty(0, dtype=np.float32)
self._speech_active = False
self._candidate_samples = 0
self._candidate_start_sample: int | None = None
self._silence_samples = 0
self._processed_samples = 0
self._resample_rate_hz: int | None = None
self._resample_remainder = 0
def reset(self) -> None:
self._clear_stream_state()
reset_states = getattr(self._model, "reset_states", None)
if callable(reset_states):
reset_states()
def process_base64(
self,
audio: object,
*,
fmt: object,
sample_rate_hz: object,
) -> StreamingVADResult:
if fmt != "pcm_f32le" or not isinstance(audio, str):
raise ValueError("Silero server VAD requires decoded pcm_f32le audio")
try:
raw = base64.b64decode(audio, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError("Silero server VAD received invalid base64 audio") from exc
if len(raw) % 4:
raise ValueError("Silero server VAD received an incomplete pcm_f32le frame")
samples = np.frombuffer(raw, dtype="<f4").astype(np.float32, copy=False)
rate = int(sample_rate_hz) if isinstance(sample_rate_hz, int | float) else self._SAMPLE_RATE_HZ
if rate <= 0:
raise ValueError("Silero server VAD requires a positive sample rate")
if rate != self._SAMPLE_RATE_HZ:
if self._resample_rate_hz != rate:
self._resample_rate_hz = rate
self._resample_remainder = 0
numerator = samples.size * self._SAMPLE_RATE_HZ + self._resample_remainder
target_size, self._resample_remainder = divmod(numerator, rate)
if target_size == 0:
samples = np.empty(0, dtype=np.float32)
elif samples.size == 1:
samples = np.full(target_size, samples[0], dtype=np.float32)
else:
source_x = np.linspace(0.0, 1.0, num=samples.size, endpoint=True)
target_x = np.linspace(0.0, 1.0, num=target_size, endpoint=True)
samples = np.interp(target_x, source_x, samples).astype(np.float32)
else:
self._resample_rate_hz = None
self._resample_remainder = 0
return self.process(samples)
def process(self, samples: np.ndarray) -> StreamingVADResult:
samples = np.asarray(samples, dtype=np.float32).reshape(-1)
if samples.size:
self._pending = np.concatenate((self._pending, samples))
started = False
stopped = False
start_ms: int | None = None
end_ms: int | None = None
max_probability = 0.0
contained_speech = self._speech_active
min_speech_samples = self.config.min_speech_duration_ms * 16
min_silence_samples = self.config.silence_duration_ms * 16
negative_threshold = max(0.0, self.config.threshold - SILERO_VAD_MIN_THRESHOLD)
while self._pending.size >= self._WINDOW_SAMPLES:
frame = self._pending[: self._WINDOW_SAMPLES]
self._pending = self._pending[self._WINDOW_SAMPLES :]
frame_start = self._processed_samples
self._processed_samples += self._WINDOW_SAMPLES
probability = min(1.0, max(0.0, float(self._score_frame(frame))))
max_probability = max(max_probability, probability)
if self._speech_active:
contained_speech = True
if probability < negative_threshold:
self._silence_samples += self._WINDOW_SAMPLES
if self._silence_samples >= min_silence_samples:
speech_end_sample = self._processed_samples - self._silence_samples
self._speech_active = False
self._silence_samples = 0
stopped = True
end_ms = max(0, int(round(speech_end_sample / 16)))
else:
self._silence_samples = 0
continue
if probability >= self.config.threshold:
if self._candidate_samples == 0:
self._candidate_start_sample = frame_start
self._candidate_samples += self._WINDOW_SAMPLES
if self._candidate_samples >= min_speech_samples:
candidate_start = self._candidate_start_sample or 0
prefix_samples = self.config.prefix_padding_ms * 16
self._speech_active = True
self._candidate_samples = self._silence_samples = 0
self._candidate_start_sample = None
contained_speech = True
started = True
start_ms = max(0, int(round((candidate_start - prefix_samples) / 16)))
else:
self._candidate_samples = 0
self._candidate_start_sample = None
return StreamingVADResult(
contained_speech, self._speech_active, started, stopped, max_probability, start_ms, end_ms
)
def _score_frame(self, frame: np.ndarray) -> float:
if self._frame_scorer is not None:
return float(self._frame_scorer(frame))
if self._model is None:
try:
import torch
from silero_vad import load_silero_vad
except ImportError as exc:
raise ServerVADUnavailableError(
"server_vad requires the optional 'silero-vad' package; install vllm-omni[server-vad]"
) from exc
self._model = load_silero_vad(onnx=False)
self._frame_scorer = lambda value: float(
self._model(torch.from_numpy(np.ascontiguousarray(value)), self._SAMPLE_RATE_HZ).item()
)
return float(self._frame_scorer(frame))
@@ -392,14 +392,13 @@ class NemotronVoiceChatTalkerForConditionalGeneration(nn.Module):
info = merge_runtime_info(info_dict)
device = input_ids.device
span = int(input_ids.shape[0])
is_prefill_raw = info.get("_omni_is_prefill")
is_prefill = bool(is_prefill_raw) if isinstance(is_prefill_raw, bool) else span > 1
request_id = require_request_id(info, "talker")
embeds = torch.zeros((span, self._hidden), device=device, dtype=self._dtype)
if is_prefill or request_id not in self._sessions:
# The 1-token vLLM prefill is the session boundary: run the vendored
# warmup here so the first decode step is a pure NeMo t=1 step.
if request_id not in self._sessions:
# The request id, not vLLM's per-segment prefill flag, is the
# session boundary. Resumable updates are prefill again in v0.28
# and must preserve the existing EAR-TTS cache and step.
self._init_session(request_id, info, device)
return input_ids, embeds, {}
@@ -550,6 +550,29 @@ def _native_tts_boundary_token_ids(special_token_ids):
}
def _native_duplex_output_is_control_only(
output_ids: Sequence[int],
special_token_ids: Mapping[str, int],
) -> bool:
if not output_ids:
return True
control_ids = {
token_id
for token_id in (
special_token_ids.get("tts_eos_token_id"),
special_token_ids.get("tts_pad_token_id"),
special_token_ids.get("listen_token_id"),
special_token_ids.get("chunk_eos_token_id"),
special_token_ids.get("chunk_tts_eos_token_id"),
special_token_ids.get("turn_eos_token_id"),
special_token_ids.get("unit_token_id"),
special_token_ids.get("unit_end_token_id"),
)
if token_id is not None
}
return bool(control_ids) and all(token_id in control_ids for token_id in output_ids)
def _decode_native_duplex_token_ids(
token_ids: list[int],
streaming_context,
@@ -757,10 +780,16 @@ def llm2tts(
)
prompt_token_ids_len = len(prompt_token_ids)
is_native_duplex_handoff = _has_native_duplex_prompt_metadata(mm_output)
latent = mm_output.get("latent", None)
if latent is None:
latent = output.hidden_states if hasattr(output, "hidden_states") else None
if latent is None:
if is_native_duplex_handoff and _native_duplex_output_is_control_only(
llm_output_ids, special_token_ids
):
continue
raise ValueError("No latent or hidden_states found in thinker output")
thinker_hidden_states = latent.detach()
@@ -771,7 +800,6 @@ def llm2tts(
full_token_ids = prompt_token_ids + llm_output_ids
tts_bos_id = special_token_ids.get("tts_bos_token_id")
is_native_duplex_handoff = _has_native_duplex_prompt_metadata(mm_output)
if is_native_duplex_handoff:
_require_native_tts_boundary_metadata(special_token_ids)
tts_end_ids = _native_tts_boundary_token_ids(special_token_ids)