diff --git a/.buildkite/cuda/test-merge.yml b/.buildkite/cuda/test-merge.yml index 22da9617b..88bd2fb3e 100644 --- a/.buildkite/cuda/test-merge.yml +++ b/.buildkite/cuda/test-merge.yml @@ -165,6 +165,31 @@ steps: path: /mnt/hf-cache type: DirectoryOrCreate + - label: "Omni · MiniCPM-o 4.5 Duplex Test" + source_file_dependencies: + - tests/assets/minicpmo_4_5/response_required_16k.wav + - tests/assets/minicpmo_4_5/soft_interrupt_16k.wav + - tests/e2e/online_serving/test_minicpmo_4_5_duplex.py + - tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py + - tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py + - tests/e2e/online_serving/minicpmo_realtime_duplex_scenarios.py + - tests/e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py + - tests/e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py + - tests/helpers/minicpmo_4_5_duplex.py + - vllm_omni/deploy/minicpmo_4_5_duplex.yaml + - vllm_omni/experimental/fullduplex/ + - vllm_omni/model_executor/models/minicpmo_4_5/ + - vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py + - vllm_omni/core/sched/ + - vllm_omni/outputs/multimodal_accumulation.py + - vllm_omni/engine/stage_init_utils.py + - vllm_omni/engine/async_omni_engine.py + - vllm_omni/entrypoints/cli/serve.py + timeout_in_minutes: 50 + commands: + - pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5_duplex.py -m 'advanced_model and cuda' --run-level 'advanced_model' + mirror_hardwares: h100_2 + - label: "TTS · VoxCPM2 Test" source_file_dependencies: - tests/e2e/offline_inference/test_voxcpm2_tts.py diff --git a/.buildkite/cuda/test-nightly.yml b/.buildkite/cuda/test-nightly.yml index 2aed12819..836bc5e3b 100644 --- a/.buildkite/cuda/test-nightly.yml +++ b/.buildkite/cuda/test-nightly.yml @@ -15,7 +15,13 @@ steps: - label: ":full_moon: Omni · Function Test with H100" timeout_in_minutes: 120 commands: - - pytest -sv tests/e2e --run-level "full_model" -m "full_model and H100 and omni" --ignore=tests/e2e/accuracy --ignore=tests/e2e/online_serving/test_qwen3_omni_multi_replicas.py + - pytest -sv tests/e2e --run-level "full_model" -m "full_model and H100 and omni" --ignore=tests/e2e/accuracy --ignore=tests/e2e/online_serving/test_qwen3_omni_multi_replicas.py --ignore=tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py + mirror_hardwares: h100_2 + + - label: ":full_moon: Omni · MiniCPM-o 4.5 Duplex Test" + timeout_in_minutes: 50 + commands: + - pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py -m "full_model and cuda and H100 and omni" --run-level "full_model" mirror_hardwares: h100_2 - label: ":full_moon: Omni · Doc Test with L4" diff --git a/.buildkite/cuda/test-ready.yml b/.buildkite/cuda/test-ready.yml index 664c6c711..b0ea85900 100644 --- a/.buildkite/cuda/test-ready.yml +++ b/.buildkite/cuda/test-ready.yml @@ -210,6 +210,33 @@ steps: path: /mnt/hf-cache type: DirectoryOrCreate + - label: "Omni · MiniCPM-o 4.5 Duplex Test" + source_file_dependencies: + - tests/assets/minicpmo_4_5/response_required_16k.wav + - tests/assets/minicpmo_4_5/soft_interrupt_16k.wav + - tests/e2e/online_serving/test_minicpmo_4_5_duplex.py + - tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py + - tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py + - tests/e2e/online_serving/minicpmo_realtime_duplex_scenarios.py + - tests/e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py + - tests/e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py + - tests/helpers/minicpmo_4_5_duplex.py + - vllm_omni/deploy/minicpmo_4_5_duplex.yaml + - vllm_omni/experimental/fullduplex/ + - vllm_omni/model_executor/models/minicpmo_4_5/ + - vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py + - vllm_omni/core/sched/ + - vllm_omni/outputs/multimodal_accumulation.py + - vllm_omni/engine/stage_init_utils.py + - vllm_omni/engine/async_omni_engine.py + - vllm_omni/entrypoints/cli/serve.py + commands: + - | + timeout 20m bash -c " + pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5_duplex.py -m 'core_model and cuda' --run-level 'core_model' + " + mirror_hardwares: h100_2 + - label: "TTS · VoxCPM2 Test" source_file_dependencies: - tests/e2e/online_serving/test_voxcpm2_tts.py diff --git a/examples/online_serving/minicpmo/realtime_web/app/static/playback_worklet.js b/examples/online_serving/minicpmo/realtime_web/app/static/playback_worklet.js index c3216fd7f..f0d3d42ae 100644 --- a/examples/online_serving/minicpmo/realtime_web/app/static/playback_worklet.js +++ b/examples/online_serving/minicpmo/realtime_web/app/static/playback_worklet.js @@ -6,6 +6,7 @@ class FullDuplexPcmPlayback extends AudioWorkletProcessor { this.playedFrames = 0; this.underrunFrames = 0; this.drain = null; + this.terminalFadeFrames = 0; this.started = false; this.activeResponseId = null; this.initialBufferFrames = Math.round(sampleRate * 0.2); @@ -31,6 +32,7 @@ class FullDuplexPcmPlayback extends AudioWorkletProcessor { } } else if (message.type === 'drain') { this.drain = { responseId: message.responseId || null }; + this.terminalFadeFrames = Math.min(this.fadeFrames, this.bufferedFrames()); if (!this.started && this.bufferedFrames() > 0) { this.bufferWaitFrames = 0; this.startPlayback(); @@ -42,6 +44,7 @@ class FullDuplexPcmPlayback extends AudioWorkletProcessor { this.playedFrames = 0; this.underrunFrames = 0; this.drain = null; + this.terminalFadeFrames = 0; this.started = false; this.activeResponseId = null; this.bufferWaitFrames = this.initialBufferFrames; @@ -76,6 +79,7 @@ class FullDuplexPcmPlayback extends AudioWorkletProcessor { this.playedFrames = 0; this.underrunFrames = 0; this.drain = null; + this.terminalFadeFrames = 0; this.started = false; this.activeResponseId = null; this.bufferWaitFrames = this.initialBufferFrames; @@ -116,6 +120,7 @@ class FullDuplexPcmPlayback extends AudioWorkletProcessor { while (target < output.length && this.queue.length > 0) { const pcm = this.queue[0]; const count = Math.min(output.length - target, pcm.length - this.offset); + const remainingBeforeChunk = this.bufferedFrames(); for (let index = 0; index < count; index += 1) { let sample = pcm[this.offset + index] / 32768; if (this.fadeInFrames > 0) { @@ -123,6 +128,16 @@ class FullDuplexPcmPlayback extends AudioWorkletProcessor { sample *= elapsed / this.fadeFrames; this.fadeInFrames -= 1; } + const remainingFrames = remainingBeforeChunk - index; + if ( + this.drain + && this.terminalFadeFrames > 0 + && remainingFrames <= this.terminalFadeFrames + ) { + sample *= this.terminalFadeFrames === 1 + ? 0 + : (remainingFrames - 1) / (this.terminalFadeFrames - 1); + } output[target + index] = sample; } target += count; diff --git a/tests/assets/minicpmo_4_5/response_required_16k.wav b/tests/assets/minicpmo_4_5/response_required_16k.wav new file mode 100644 index 000000000..ae4052493 Binary files /dev/null and b/tests/assets/minicpmo_4_5/response_required_16k.wav differ diff --git a/tests/assets/minicpmo_4_5/soft_interrupt_16k.wav b/tests/assets/minicpmo_4_5/soft_interrupt_16k.wav new file mode 100644 index 000000000..41b399af0 Binary files /dev/null and b/tests/assets/minicpmo_4_5/soft_interrupt_16k.wav differ diff --git a/tests/core/sched/test_omni_ar_scheduler_streaming.py b/tests/core/sched/test_omni_ar_scheduler_streaming.py index 06037d4cf..5c6561357 100644 --- a/tests/core/sched/test_omni_ar_scheduler_streaming.py +++ b/tests/core/sched/test_omni_ar_scheduler_streaming.py @@ -242,9 +242,52 @@ def test_explicit_streaming_payload_replaces_placeholder_prompt() -> None: "tts_token_ids": [10, 20], "meta": {"replace_streaming_prompt": True}, } + update.model_intermediate_buffer = { + "ids": {"tts": [41, 42, 99]}, + "meta": {"turn_eos_token_id": 99}, + } sched._update_request_as_session(session, update) assert session.prompt_token_ids == [10, 20] assert session.additional_information == update.additional_information + assert session.model_intermediate_buffer == { + "ids": {"tts": [41, 42, 99]}, + "meta": {"turn_eos_token_id": 99}, + } + assert session.status == RequestStatus.WAITING + + +def test_model_intermediate_streaming_payload_replaces_computed_prompt() -> None: + sched = _make_scheduler(stage_id=1) + sched.chunk_transfer_adapter = SimpleNamespace( + receives_chunks=False, + segment_finished_requests=set(), + ) + session = _make_request() + session.status = RequestStatus.WAITING_FOR_STREAMING_REQ + session.prompt_token_ids = [0] * 59 + session._all_token_ids.clear() + session._all_token_ids.extend(session.prompt_token_ids) + session.num_prompt_tokens = 59 + session.num_computed_tokens = 59 + update = _make_update([0] * 10) + update.additional_information = None + update.model_intermediate_buffer = { + "ids": {"tts": list(range(8))}, + "hidden_states": {"tts": [[0.0]] * 8}, + "meta": { + "replace_streaming_prompt": True, + "next_stage_prompt_len": 10, + }, + } + + sched._update_request_as_session(session, update) + + assert session.prompt_token_ids == [0] * 10 + assert list(session._all_token_ids) == [0] * 10 + assert session.num_prompt_tokens == 10 + assert session.num_computed_tokens == 0 + assert session.additional_information is None + assert session.model_intermediate_buffer == update.model_intermediate_buffer assert session.status == RequestStatus.WAITING diff --git a/tests/core/sched/test_omni_generation_scheduler_update_session.py b/tests/core/sched/test_omni_generation_scheduler_update_session.py index 28083349a..5cd829925 100644 --- a/tests/core/sched/test_omni_generation_scheduler_update_session.py +++ b/tests/core/sched/test_omni_generation_scheduler_update_session.py @@ -54,6 +54,18 @@ class _SchedulerStub(OmniGenerationScheduler): raise AssertionError("unexpected enqueue for skipped_waiting miss") +class _AsyncChunkStopSchedulerStub(OmniGenerationScheduler): + def __init__(self) -> None: + self.num_waiting_for_streaming_input = 0 + self.chunk_transfer_adapter = SimpleNamespace(receives_chunks=True) + self.enqueued: list[Request] = [] + self.enqueued_statuses: list[RequestStatus] = [] + + def _enqueue_waiting_request(self, session: Request) -> None: + self.enqueued.append(session) + self.enqueued_statuses.append(session.status) + + def _make_request(**kwargs) -> Request: sp = SamplingParams(max_tokens=8) defaults = dict( @@ -133,6 +145,21 @@ def test_resumable_generation_stop_marks_segment_boundary() -> None: assert output.is_segment_finished is True +def test_async_chunk_resumable_stop_rearms_connector_polling() -> None: + sched = _AsyncChunkStopSchedulerStub() + session = _make_request(request_id="req-async-chunk-next-segment") + session.status = RequestStatus.FINISHED_STOPPED + session.resumable = True + + finished = sched._handle_stopped_request(session) + + assert finished is False + assert sched.enqueued == [session] + assert sched.enqueued_statuses == [RequestStatus.WAITING] + assert session.status == RequestStatus.WAITING + assert sched.num_waiting_for_streaming_input == 0 + + class TestReplaceSessionWithStreamingUpdate: def test_resets_tokens_and_prompt_from_update(self) -> None: sched = _SchedulerStub() diff --git a/tests/dfx/reliability/invalid_param_test/test_invalid_minicpmo_4_5_omni.py b/tests/dfx/reliability/invalid_param_test/test_invalid_minicpmo_4_5_omni.py index c71dc2d16..2082be10d 100644 --- a/tests/dfx/reliability/invalid_param_test/test_invalid_minicpmo_4_5_omni.py +++ b/tests/dfx/reliability/invalid_param_test/test_invalid_minicpmo_4_5_omni.py @@ -22,10 +22,7 @@ _MINICPMO_SERVER = [ model="openbmb/MiniCPM-o-4_5", stage_config_path=_MINICPMO_DEPLOY, use_stage_cli=True, - server_args=[ - "--trust-remote-code", - "--no-async-chunk", - ], + server_args=["--trust-remote-code"], ), id="minicpmo_4_5", ) diff --git a/tests/distributed/omni_connectors/test_chunk_transfer_adapter.py b/tests/distributed/omni_connectors/test_chunk_transfer_adapter.py index 1485ace60..034f1a5c6 100644 --- a/tests/distributed/omni_connectors/test_chunk_transfer_adapter.py +++ b/tests/distributed/omni_connectors/test_chunk_transfer_adapter.py @@ -71,6 +71,30 @@ def test_streaming_payload_can_replace_placeholder_prompt(mocker: MockerFixture) request.update_block_hashes.assert_called_once_with() +def test_streaming_payload_can_append_exact_prompt_length(mocker: MockerFixture) -> None: + request = SimpleNamespace( + _all_token_ids=[0, 0, 7, 8], + _output_token_ids=[7, 8], + prompt_token_ids=[0, 0], + num_computed_tokens=4, + num_prompt_tokens=2, + update_block_hashes=mocker.Mock(), + ) + payload = { + "ids": {"prompt": [1, 2, 3]}, + "meta": {"next_stage_prompt_len": 3}, + } + + construct_next_stage_streaming_input_prompt(payload, request) + + assert request.prompt_token_ids == [0, 0, 7, 8, 0, 0, 0] + assert request._all_token_ids == [0, 0, 7, 8, 0, 0, 0] + assert request._output_token_ids == [] + assert request.num_computed_tokens == 4 + assert request.num_prompt_tokens == 7 + request.update_block_hashes.assert_called_once_with() + + @pytest.fixture def build_adapter(monkeypatch, mocker: MockerFixture): def _build( @@ -252,6 +276,37 @@ def test_save_async_uses_confirmed_tokens_for_async_scheduler_watermark(build_ad assert len(adapter._pending_save_reqs) == 1 +def test_send_single_request_terminal_chunk_still_flushes_processor(build_adapter, monkeypatch): + """A terminal stop is not a segment boundary (#5383), but the producer-side + processor must still receive the flush signal on the terminal chunk. + Passing only ``is_segment_finished`` starved processors of their final + accumulated payload once terminal stops stopped setting it (#5413: the + downstream stage got ``meta.finished`` with the tail data missing). + """ + adapter, connector = build_adapter(stage_id=0) + request = _req("req-terminal", RequestStatus.FINISHED_STOPPED, external_req_id="ext-terminal") + + seen_flush_flags = [] + + def recording_processor(**kwargs): + seen_flush_flags.append(kwargs["is_finished"]) + return OmniPayloadStruct( + codes=CodesStruct(audio=torch.tensor([1, 2, 3], dtype=torch.long)), + ) + + adapter.custom_process_next_stage_input_func = recording_processor + monkeypatch.setattr(adapter, "cleanup", lambda *a, **kw: None) + + adapter._send_single_request( + {"multimodal_output": None, "request": request, "is_finished": True, "is_segment_finished": False} + ) + + assert seen_flush_flags == [True] + sent_payload = connector.put.call_args.kwargs["data"] + assert bool(sent_payload.meta.finished.item()) is True + assert bool(sent_payload.meta.is_segment_finished.item()) is False + + def test_send_single_request_struct_without_meta_does_not_crash(build_adapter, monkeypatch): """Producer may return a struct with ``meta=None`` (e.g. payload that carries only ``embed`` or ``codes``). The sender's ``meta is not None`` @@ -314,6 +369,23 @@ def test_send_single_request_struct_preserves_segment_finished(build_adapter, mo assert sent_payload.meta.is_segment_finished.item() is True +def test_send_single_request_respects_processor_receiver_boundary(build_adapter, monkeypatch): + adapter, connector = build_adapter(stage_id=1) + request = _req("req-stream", RequestStatus.WAITING, external_req_id="ext-stream") + + adapter.custom_process_next_stage_input_func = lambda **kwargs: OmniPayloadStruct( + meta=MetaStruct(is_segment_finished=torch.tensor(False, dtype=torch.bool)) + ) + monkeypatch.setattr(adapter, "cleanup", lambda *a, **kw: None) + + adapter._send_single_request( + {"multimodal_output": None, "request": request, "is_finished": False, "is_segment_finished": True} + ) + + sent_payload = connector.put.call_args.kwargs["data"] + assert sent_payload.meta.is_segment_finished.item() is False + + def test_save_async_skips_stale_resumable_chunk_until_dedup_is_reset(build_adapter): adapter, _ = build_adapter(stage_id=1) request = _req("req-stream", RequestStatus.WAITING, external_req_id="ext-stream") @@ -463,7 +535,7 @@ def test_fifo_promotion(build_adapter): adapter.process_pending_chunks(waiting_queue, running_queue) assert list(adapter._active_streams) == ["req-1", "req-2"] - assert waiting_queue == reqs[2:] + assert waiting_queue == [] assert reqs[0].status == RequestStatus.WAITING_FOR_CHUNK assert reqs[1].status == RequestStatus.WAITING_FOR_CHUNK assert reqs[2].status == RequestStatus.WAITING @@ -473,6 +545,7 @@ def test_fifo_promotion(build_adapter): # (commit c4d95fd9 — otherwise the terminal chunk deadlocks at c=8 K=2). # Simulate it here so promotion can pick up the freed slot. adapter._evict_finished_active_streams({"req-1"}) + adapter.restore_queues(waiting_queue, running_queue) adapter.process_pending_chunks(waiting_queue, running_queue) assert list(adapter._active_streams) == ["req-2", "req-3"] @@ -481,6 +554,22 @@ def test_fifo_promotion(build_adapter): assert reqs[2].status == RequestStatus.WAITING_FOR_CHUNK +def test_non_active_waiting_request_is_held_off_scheduler(build_adapter): + adapter, _ = build_adapter(stage_id=2, max_num_seqs=2, active_stream_window=1) + active = _req("req-active", RequestStatus.WAITING) + non_active = _req("req-non-active", RequestStatus.WAITING) + waiting_queue = DummyWaitingQueue([active, non_active]) + running_queue = [] + + adapter.process_pending_chunks(waiting_queue, running_queue) + + assert waiting_queue == [] + assert active.status == RequestStatus.WAITING_FOR_CHUNK + assert non_active.status == RequestStatus.WAITING + assert list(adapter._pending_load_reqs) == [active] + assert list(adapter.waiting_for_chunk_waiting_requests) == [active, non_active] + + def test_legacy_k0(build_adapter): adapter, _ = build_adapter(stage_id=1, max_num_seqs=1, active_stream_window=0) waiting_req = _req("waiting", RequestStatus.WAITING) @@ -509,12 +598,13 @@ def test_finished_releases_slot(build_adapter): adapter.process_pending_chunks(waiting_queue, running_queue) assert list(adapter._active_streams) == ["req-1"] - assert waiting_queue == [req_2] + assert waiting_queue == [] adapter.finished_requests.add("req-1") # Eviction is deferred to postprocess_scheduler_output in the runtime path # (commit c4d95fd9). Simulate it so promotion can pick up the freed slot. adapter._evict_finished_active_streams({"req-1"}) + adapter.restore_queues(waiting_queue, running_queue) adapter.process_pending_chunks(waiting_queue, running_queue) assert list(adapter._active_streams) == ["req-2"] diff --git a/tests/e2e/offline_inference/test_minicpmo_4_5.py b/tests/e2e/offline_inference/test_minicpmo_4_5.py index fe7a2d2be..a8855e04b 100644 --- a/tests/e2e/offline_inference/test_minicpmo_4_5.py +++ b/tests/e2e/offline_inference/test_minicpmo_4_5.py @@ -83,7 +83,7 @@ def test_video_to_text(omni_runner, omni_runner_handler) -> None: @hardware_test(res={"cuda": "H100", "npu": "A2"}, num_cards=2) @pytest.mark.parametrize("omni_runner", test_params, indirect=True) def test_text_to_audio(omni_runner, omni_runner_handler) -> None: - """Test processing text, generating audio output through the talker token2wav path.""" + """Test processing text and generating audio through Talker and Code2Wav.""" request_config = {"prompts": get_question("text"), "modalities": ["audio"]} omni_runner_handler.send_omni_request(request_config) diff --git a/tests/e2e/online_serving/minicpmo_realtime_duplex_scenarios.py b/tests/e2e/online_serving/minicpmo_realtime_duplex_scenarios.py index 8b521c378..b6d562303 100644 --- a/tests/e2e/online_serving/minicpmo_realtime_duplex_scenarios.py +++ b/tests/e2e/online_serving/minicpmo_realtime_duplex_scenarios.py @@ -1174,10 +1174,13 @@ async def _send_listen_only_overlap_pair( before_second_listen = state.model_listen_count overlap_started_while_active = not state.response_done(first_response_id) model_unit_ready_while_active = False + second_unit_event_index: int | None = None def record_model_unit_ready() -> None: - nonlocal model_unit_ready_while_active + nonlocal model_unit_ready_while_active, second_unit_event_index model_unit_ready_while_active = not state.response_done(first_response_id) + if model_unit_ready_while_active: + second_unit_event_index = len(state.events) await _send_pcm16( ws, @@ -1239,14 +1242,13 @@ async def _send_listen_only_overlap_pair( label=transcripts[1], ) - second_speech_index = _nth_event_index(state, "input_audio_buffer.speech_started", 2) first_done_index = _event_index_for_response(state, "response.done", first_response_id) overlap_ok = ( overlap_started_while_active and model_unit_ready_while_active - and second_speech_index is not None + and second_unit_event_index is not None and first_done_index is not None - and second_speech_index < first_done_index + and second_unit_event_index < first_done_index ) return [first_response_id, second_response_id], ["speak", second_outcome], overlap_ok @@ -1380,6 +1382,17 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]: await ws.send(json.dumps({"type": "session.close"})) await _wait_for(state, lambda: state.count("session.closed") > 0, timeout_s=20, label="session.closed") finally: + if state.count("session.created") > 0 and state.count("session.closed") == 0: + try: + await ws.send(json.dumps({"type": "session.close"})) + await _wait_for( + state, + lambda: state.count("session.closed") > 0, + timeout_s=min(args.timeout_s, 5), + label="session.closed during cleanup", + ) + except (ConnectionClosed, TimeoutError): + pass stop.set() reader.cancel() try: diff --git a/tests/e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py b/tests/e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py index 61a466913..e317c412c 100644 --- a/tests/e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py +++ b/tests/e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py @@ -19,7 +19,11 @@ SCRIPT_DIR = Path(__file__).resolve().parent if str(SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(SCRIPT_DIR)) -from minicpmo_realtime_duplex_scenarios import _url_with_model, run_demo # noqa: E402 +from minicpmo_realtime_duplex_scenarios import ( # noqa: E402 + _ref_audio_data_url, + _url_with_model, + run_demo, +) def _with_resume_mode(url: str) -> str: @@ -114,7 +118,12 @@ async def _open_admission_session( args: argparse.Namespace, session_id: str, ) -> tuple[object, dict[str, object]]: - url = _url_with_model(args.url, args.model, session_id=session_id) + url = _url_with_model( + args.url, + args.model, + autostart=False if getattr(args, "ref_audio", None) else None, + session_id=session_id, + ) ws = await websockets.connect(url, max_size=64 * 1024 * 1024) await ws.send( json.dumps( @@ -125,6 +134,7 @@ async def _open_admission_session( "model": args.model, "modalities": ["audio", "text"], "extra_body": {"minicpmo45_native_duplex": True}, + **({"ref_audio": _ref_audio_data_url(args.ref_audio)} if getattr(args, "ref_audio", None) else {}), }, } ) @@ -151,7 +161,12 @@ async def _admission_probe(args: argparse.Namespace, *, limit: int) -> dict[str, accepted.append(await _open_admission_session(args, f"{prefix}-accepted-{index}")) overflow_id = f"{prefix}-overflow" - overflow_url = _url_with_model(args.url, args.model, session_id=overflow_id) + overflow_url = _url_with_model( + args.url, + args.model, + autostart=False if getattr(args, "ref_audio", None) else None, + session_id=overflow_id, + ) async with websockets.connect(overflow_url, max_size=64 * 1024 * 1024) as overflow: await overflow.send( json.dumps( @@ -162,6 +177,11 @@ async def _admission_probe(args: argparse.Namespace, *, limit: int) -> dict[str, "model": args.model, "modalities": ["audio", "text"], "extra_body": {"minicpmo45_native_duplex": True}, + **( + {"ref_audio": _ref_audio_data_url(args.ref_audio)} + if getattr(args, "ref_audio", None) + else {} + ), }, } ) @@ -207,16 +227,27 @@ async def _resume_probe( session_id: str, expect_expired: bool = False, ) -> dict[str, object]: - url = _url_with_model(args.url, args.model, session_id=session_id) + url = _url_with_model( + args.url, + args.model, + autostart=False if getattr(args, "ref_audio", None) else None, + session_id=session_id, + ) async with websockets.connect(url, max_size=64 * 1024 * 1024) as first: await first.send( json.dumps( { "type": "session.update", "session": { + "session_id": session_id, "model": args.model, "modalities": ["audio", "text"], "extra_body": {"minicpmo45_native_duplex": True}, + **( + {"ref_audio": _ref_audio_data_url(args.ref_audio)} + if getattr(args, "ref_audio", None) + else {} + ), }, } ) @@ -306,7 +337,12 @@ async def _takeover_probe( *, session_id: str, ) -> dict[str, object]: - url = _url_with_model(args.url, args.model, session_id=session_id) + url = _url_with_model( + args.url, + args.model, + autostart=False if getattr(args, "ref_audio", None) else None, + session_id=session_id, + ) first = await websockets.connect(url, max_size=64 * 1024 * 1024) second = None try: @@ -315,9 +351,15 @@ async def _takeover_probe( { "type": "session.update", "session": { + "session_id": session_id, "model": args.model, "modalities": ["audio", "text"], "extra_body": {"minicpmo45_native_duplex": True}, + **( + {"ref_audio": _ref_audio_data_url(args.ref_audio)} + if getattr(args, "ref_audio", None) + else {} + ), }, } ) @@ -451,20 +493,7 @@ async def run_multi_session(args: argparse.Namespace) -> dict[str, object]: args, session_id=f"takeover-{args.takeover_session_index}-{uuid.uuid4().hex}", ) - expiry_result = None - if args.expire_session_index is not None: - if not 0 <= args.expire_session_index < args.sessions: - raise ValueError("--expire-session-index is outside the session range") - expiry_result = await _resume_probe( - args, - session_id=f"expire-{args.expire_session_index}-{uuid.uuid4().hex}", - expect_expired=True, - ) - admission_result = ( - await _admission_probe(args, limit=args.verify_admission_limit) - if args.verify_admission_limit is not None - else None - ) + lifecycle_result = await run_lifecycle_probes(args) session_results = await asyncio.gather( *(run_demo(_demo_args(args, index)) for index in range(args.sessions)), @@ -487,16 +516,15 @@ async def run_multi_session(args: argparse.Namespace) -> dict[str, object]: and semantic_isolation_ok and (resume_result is None or resume_result.get("ok") is True) and (takeover_result is None or takeover_result.get("ok") is True) - and (expiry_result is None or expiry_result.get("ok") is True) - and (admission_result is None or admission_result.get("ok") is True) + and lifecycle_result["ok"] is True ), "session_count": args.sessions, "identity_isolation_ok": identity_isolation_ok, "semantic_isolation_ok": semantic_isolation_ok, "resume": resume_result, "takeover": takeover_result, - "expiry": expiry_result, - "admission": admission_result, + "expiry": lifecycle_result["expiry"], + "admission": lifecycle_result["admission"], "failures": failures, "sessions": completed, } @@ -507,6 +535,34 @@ async def run_multi_session(args: argparse.Namespace) -> dict[str, object]: return result +async def run_lifecycle_probes(args: argparse.Namespace) -> dict[str, object]: + """Run expiry and admission probes without requiring model output.""" + if args.sessions < 1: + raise ValueError("--sessions must be positive") + expiry_result = None + if args.expire_session_index is not None: + if not 0 <= args.expire_session_index < args.sessions: + raise ValueError("--expire-session-index is outside the session range") + expiry_result = await _resume_probe( + args, + session_id=f"expire-{args.expire_session_index}-{uuid.uuid4().hex}", + expect_expired=True, + ) + admission_result = ( + await _admission_probe(args, limit=args.verify_admission_limit) + if args.verify_admission_limit is not None + else None + ) + return { + "ok": ( + (expiry_result is None or expiry_result.get("ok") is True) + and (admission_result is None or admission_result.get("ok") is True) + ), + "expiry": expiry_result, + "admission": admission_result, + } + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--url", default="ws://127.0.0.1:8113/v1/realtime?duplex=1") diff --git a/tests/e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py b/tests/e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py index 3eb9405a6..57d9e433f 100644 --- a/tests/e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py +++ b/tests/e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py @@ -2,8 +2,8 @@ This E2E driver runs the public ``realtime_duplex_demo.py`` against a live duplex backend. Arbitrary audio defaults to the model-policy lifecycle contract. -The stronger response-required mode binds the two-response contract to a known -input checksum and expected second response. +The stronger response-required mode binds the multi-response contract to a +known input checksum and expected follow-up response. """ from __future__ import annotations @@ -39,6 +39,17 @@ def _validate_input_sha256(path: Path, expected: str) -> str: return actual +def _input_duration_s(path: Path) -> float: + with wave.open(str(path), "rb") as wav_file: + return wav_file.getnframes() / wav_file.getframerate() + + +def _client_process_timeout_s(input_wav: Path, protocol_timeout_s: float) -> float: + # The child first streams the WAV in real time, then can independently + # exhaust its post-commit and session-close protocol waits. + return _input_duration_s(input_wav) + 2 * protocol_timeout_s + 30.0 + + def _read_json(path: Path) -> dict[str, object]: if not path.is_file(): return {} @@ -211,7 +222,7 @@ def summarize_artifacts( validation_mode: str, min_responses: int, min_audio_deltas_per_response: int, - expect_second_response_substring: str | None, + expect_followup_response_substring: str | None, ) -> dict[str, object]: if validation_mode not in {"model-policy", "response-required"}: raise ValueError(f"unsupported validation mode: {validation_mode}") @@ -274,12 +285,20 @@ def summarize_artifacts( ) final_listen_after_commit = commit_index is not None and any(index > commit_index for index in listen_indices) transcript = "".join(str(summary.get("transcript") or "") for summary in response_summaries) - second_response_transcript = ( - str(response_summaries[1].get("transcript") or "") if len(response_summaries) >= 2 else "" + followup_response_transcripts = [ + str(summary.get("transcript") or "") + for summary in response_summaries[1:] + if commit_index is not None + and isinstance(summary.get("created_index"), int) + and summary["created_index"] < commit_index + ] + followup_response_transcript_ok = any( + bool(_normalize_text(transcript)) for transcript in followup_response_transcripts + ) + followup_response_transcript_expectation_ok = not expect_followup_response_substring or any( + _normalize_text(expect_followup_response_substring) in _normalize_text(transcript) + for transcript in followup_response_transcripts ) - second_response_transcript_expectation_ok = not expect_second_response_substring or _normalize_text( - expect_second_response_substring - ) in _normalize_text(second_response_transcript) error_events = [ event for event in events @@ -310,9 +329,8 @@ def summarize_artifacts( mode_contract_ok = validation_mode == "model-policy" or ( second_response_before_final_commit and listen_before_first_response - and listen_between_responses and listen_after_last_done - and second_response_transcript_expectation_ok + and followup_response_transcript_ok ) ok = common_contract_ok and mode_contract_ok return { @@ -337,9 +355,10 @@ def summarize_artifacts( "listen_after_response_before_commit": listen_after_response_before_commit, "final_listen_after_commit": final_listen_after_commit, "transcript": transcript, - "second_response_transcript": second_response_transcript, - "expect_second_response_substring": expect_second_response_substring, - "second_response_transcript_expectation_ok": second_response_transcript_expectation_ok, + "followup_response_transcripts": followup_response_transcripts, + "followup_response_transcript_ok": followup_response_transcript_ok, + "expect_followup_response_substring": expect_followup_response_substring, + "followup_response_transcript_expectation_ok": followup_response_transcript_expectation_ok, "error_count": len(error_events), "cancelled_count": cancelled_count, "compact_sequence": _compact_sequence(events), @@ -388,7 +407,7 @@ async def run_soft_interrupt(args: argparse.Namespace) -> dict[str, object]: try: stdout_bytes, stderr_bytes = await asyncio.wait_for( process.communicate(), - timeout=args.timeout_s + 30.0, + timeout=_client_process_timeout_s(input_wav, args.timeout_s), ) except TimeoutError: process.kill() @@ -406,7 +425,7 @@ async def run_soft_interrupt(args: argparse.Namespace) -> dict[str, object]: validation_mode=args.validation_mode, min_responses=args.min_responses, min_audio_deltas_per_response=args.min_audio_deltas_per_response, - expect_second_response_substring=args.expect_second_response_substring, + expect_followup_response_substring=args.expect_followup_response_substring, ) summary.update( { @@ -444,14 +463,12 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--min-responses", type=int, default=2) parser.add_argument("--min-audio-deltas-per-response", type=int, default=2) parser.add_argument("--input-sha256") - parser.add_argument("--expect-second-response-substring") + parser.add_argument("--expect-followup-response-substring") args = parser.parse_args() if args.validation_mode == "response-required" and args.min_responses < 2: parser.error("--min-responses must be at least 2") if args.validation_mode == "response-required" and not args.input_sha256: parser.error("--input-sha256 is required in response-required mode") - if args.validation_mode == "response-required" and not args.expect_second_response_substring: - parser.error("--expect-second-response-substring is required in response-required mode") if args.min_audio_deltas_per_response < 1: parser.error("--min-audio-deltas-per-response must be positive") return args diff --git a/tests/e2e/online_serving/test_minicpmo_4_5.py b/tests/e2e/online_serving/test_minicpmo_4_5.py index f875c00d8..a4fefec98 100644 --- a/tests/e2e/online_serving/test_minicpmo_4_5.py +++ b/tests/e2e/online_serving/test_minicpmo_4_5.py @@ -1,9 +1,7 @@ -""" -E2E Online tests for MiniCPM-o 4.5 model with multimodal input and audio / text output. +"""E2E online tests for MiniCPM-o 4.5 multimodal input and audio/text output. -MiniCPM-o 4.5 has ``async_chunk: false``, ``max_num_seqs: 1`` on both stages, -and the vocoder runs in-process inside the talker stage rather than as a separate -Code2Wav stage. +The batching deployment uses async chunk transfer across separate Thinker, +Talker, and Code2Wav stages. """ import os @@ -26,10 +24,7 @@ test_params = [ model=_MODEL, stage_config_path=_CI_DEPLOY, use_stage_cli=True, - server_args=[ - "--trust-remote-code", - "--no-async-chunk", - ], + server_args=["--trust-remote-code"], ), id="default", ) @@ -99,7 +94,7 @@ def test_text_to_text_001(omni_server, openai_client) -> None: def test_text_to_audio_001(omni_server, openai_client) -> None: """ Test text-only input generating text + audio output via OpenAI API. - This exercises the talker TTS region detection and in-process token2wav vocoder. + This exercises Talker TTS region detection and the Code2Wav stage. Deploy Setting: default 2GPU Input Modal: text Output Modal: text + audio @@ -133,13 +128,14 @@ def test_audio_to_text_audio_001(omni_server, openai_client) -> None: messages = dummy_messages_from_mix_data( system_prompt=get_system_prompt(), audio_data_url=audio_data_url, - content_text=get_prompt("mix"), + content_text=get_prompt(), ) request_config = { "model": omni_server.model, "messages": messages, "stream": True, + "key_words": {"text": ["Beijing"]}, } openai_client.send_omni_request(request_config, request_num=get_max_batch_size()) diff --git a/tests/e2e/online_serving/test_minicpmo_4_5_duplex.py b/tests/e2e/online_serving/test_minicpmo_4_5_duplex.py new file mode 100644 index 000000000..2e409f689 --- /dev/null +++ b/tests/e2e/online_serving/test_minicpmo_4_5_duplex.py @@ -0,0 +1,147 @@ +"""CI coverage for the MiniCPM-o 4.5 native-duplex Realtime API.""" + +from __future__ import annotations + +import asyncio +import json +import uuid +from pathlib import Path + +import pytest +import websockets + +from tests.e2e.online_serving.minicpmo_realtime_duplex_scenarios import ( + _ref_audio_data_url, + run_demo, +) +from tests.e2e.online_serving.run_minicpmo_realtime_duplex_multi_session import ( + run_multi_session, +) +from tests.helpers.mark import hardware_test +from tests.helpers.minicpmo_4_5_duplex import ( + CORE_SERVER_PARAMS, + SERVER_PARAMS, + demo_args, + multi_session_args, + realtime_url, + resolve_ref_audio, + validated_input_wav, +) +from vllm_omni.experimental.fullduplex.client import build_realtime_url + +pytestmark = pytest.mark.omni + + +async def _receive_protocol_events(ws, required_types: set[str], *, timeout_s: float) -> list[dict[str, object]]: + async def receive() -> list[dict[str, object]]: + events: list[dict[str, object]] = [] + seen: set[str] = set() + while not required_types.issubset(seen): + raw = await ws.recv() + if not isinstance(raw, str): + continue + event = json.loads(raw) + if not isinstance(event, dict): + continue + events.append(event) + event_type = event.get("type") + if event_type == "error": + raise AssertionError(f"WebSocket protocol smoke received an error: {event}") + if isinstance(event_type, str): + seen.add(event_type) + return events + + return await asyncio.wait_for(receive(), timeout=timeout_s) + + +async def _run_protocol_smoke(*, url: str, model: str, ref_audio: Path) -> list[dict[str, object]]: + session_id = f"duplex-ci-protocol-{uuid.uuid4().hex}" + websocket_url = build_realtime_url(url, model, autostart=False, session_id=session_id) + async with websockets.connect(websocket_url, max_size=64 * 1024 * 1024) as ws: + await ws.send( + json.dumps( + { + "type": "session.update", + "session": { + "session_id": session_id, + "model": model, + "modalities": ["audio", "text"], + "ref_audio": _ref_audio_data_url(str(ref_audio)), + "extra_body": {"minicpmo45_native_duplex": True}, + }, + } + ) + ) + events = await _receive_protocol_events( + ws, + {"session.created", "session.updated"}, + timeout_s=60, + ) + await ws.send(json.dumps({"type": "session.close"})) + events.extend(await _receive_protocol_events(ws, {"session.closed"}, timeout_s=60)) + return events + + +@pytest.mark.core_model +@hardware_test(res={"cuda": "H100"}, num_cards=2) +@pytest.mark.parametrize("omni_server", CORE_SERVER_PARAMS, indirect=True) +def test_duplex_websocket_protocol_smoke(omni_server, model_prefix: str) -> None: + ref_audio = resolve_ref_audio(model_prefix) + events = asyncio.run( + _run_protocol_smoke( + url=realtime_url(omni_server), + model=omni_server.model, + ref_audio=ref_audio, + ) + ) + event_types = [event.get("type") for event in events] + assert "session.created" in event_types + assert "session.updated" in event_types + assert event_types[-1] == "session.closed" + + +@pytest.mark.advanced_model +@hardware_test(res={"cuda": "H100"}, num_cards=2) +@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True) +def test_duplex_single_session_response_required(omni_server, model_prefix: str, tmp_path: Path) -> None: + result = asyncio.run( + run_demo( + demo_args( + omni_server=omni_server, + input_wav=validated_input_wav(), + ref_audio=resolve_ref_audio(model_prefix), + output_dir=tmp_path / "single_session", + ) + ) + ) + assert result["ok"] is True + assert result["audio_delta_count"] > 0 + assert result["done_count"] == 1 + assert result["error_count"] == 0 + assert result["all_audio_responses_have_transcript"] is True + assert result["transcript_delta_done_ok"] is True + + +@pytest.mark.advanced_model +@hardware_test(res={"cuda": "H100"}, num_cards=2) +@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True) +def test_duplex_two_sessions_resume_and_takeover(omni_server, model_prefix: str, tmp_path: Path) -> None: + result = asyncio.run( + run_multi_session( + multi_session_args( + omni_server=omni_server, + input_wav=validated_input_wav(), + ref_audio=resolve_ref_audio(model_prefix), + output_dir=tmp_path / "multi_session", + response_required=True, + ) + ) + ) + assert result["ok"] is True + assert result["session_count"] == 2 + assert result["resume"]["ok"] is True + assert result["takeover"]["ok"] is True + assert not result["failures"] + assert all(session["audio_delta_count"] > 0 for session in result["sessions"]) + assert all(session["done_count"] == 1 for session in result["sessions"]) + assert all(session["error_count"] == 0 for session in result["sessions"]) diff --git a/tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py b/tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py new file mode 100644 index 000000000..69c266456 --- /dev/null +++ b/tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py @@ -0,0 +1,86 @@ +"""Nightly lifecycle coverage for the MiniCPM-o 4.5 native-duplex API.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from tests.e2e.online_serving.run_minicpmo_realtime_duplex_multi_session import ( + run_lifecycle_probes, +) +from tests.e2e.online_serving.run_minicpmo_realtime_duplex_soft_interrupt import ( + run_soft_interrupt, +) +from tests.helpers.mark import hardware_test +from tests.helpers.minicpmo_4_5_duplex import ( + SERVER_PARAMS, + SOFT_INTERRUPT_SHA256, + multi_session_args, + realtime_url, + resolve_ref_audio, + validated_input_wav, + validated_soft_interrupt_wav, +) + +pytestmark = [pytest.mark.full_model, pytest.mark.omni] + + +@hardware_test(res={"cuda": "H100"}, num_cards=2) +@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True) +def test_duplex_admission_and_expiry_reaper(omni_server, model_prefix: str, tmp_path: Path) -> None: + args = multi_session_args( + omni_server=omni_server, + input_wav=validated_input_wav(), + ref_audio=resolve_ref_audio(model_prefix), + output_dir=tmp_path / "admission_expiry", + response_required=False, + ) + args.sessions = 1 + args.disconnect_session_index = None + args.takeover_session_index = None + args.expire_session_index = 0 + args.verify_admission_limit = 2 + result = asyncio.run(run_lifecycle_probes(args)) + + assert result["ok"] is True, json.dumps(result, ensure_ascii=False, indent=2) + assert result["expiry"]["ok"] is True + assert result["expiry"]["error_code"] == "session_resume_expired" + assert result["admission"]["ok"] is True + assert result["admission"]["overflow_error_code"] == "resource_exhausted" + + +@hardware_test(res={"cuda": "H100"}, num_cards=2) +@pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True) +def test_duplex_soft_interrupt(omni_server, model_prefix: str, tmp_path: Path) -> None: + input_wav = validated_soft_interrupt_wav() + result = asyncio.run( + run_soft_interrupt( + SimpleNamespace( + url=realtime_url(omni_server), + model=omni_server.model, + input_wav=str(input_wav), + ref_audio=str(resolve_ref_audio(model_prefix)), + output_dir=str(tmp_path / "soft_interrupt"), + summary_output=None, + chunk_ms=200, + timeout_s=180.0, + require_audio=True, + no_realtime_pacing=False, + validation_mode="response-required", + min_responses=2, + min_audio_deltas_per_response=2, + input_sha256=SOFT_INTERRUPT_SHA256, + expect_followup_response_substring=None, + ) + ) + ) + + assert result["ok"] is True, json.dumps(result, ensure_ascii=False, indent=2) + assert result["error_count"] == 0 + assert result["response_lifecycle_ok"] is True + assert result["response_audio_contract_ok"] is True + assert result["followup_response_transcript_ok"] is True diff --git a/tests/e2e/online_serving/test_minicpmo_4_5_expansion.py b/tests/e2e/online_serving/test_minicpmo_4_5_expansion.py index 8db6cd92b..068502903 100644 --- a/tests/e2e/online_serving/test_minicpmo_4_5_expansion.py +++ b/tests/e2e/online_serving/test_minicpmo_4_5_expansion.py @@ -1,7 +1,7 @@ -""" -E2E Online expansion tests for MiniCPM-o 4.5 covering modality combinations, -in-process token2wav vocoder behavior, max_num_seqs=1 serial execution, -and edge-case validation. +"""E2E online expansion tests for MiniCPM-o 4.5. + +These cover modality combinations, separate Code2Wav behavior, sequential +request isolation, longer audio output, and Chinese speech. """ import os @@ -94,10 +94,8 @@ def test_text_video_to_text_001(omni_server, openai_client) -> None: @pytest.mark.parametrize("omni_server", test_params, indirect=True) def test_sequential_requests_independent(omni_server, openai_client) -> None: """ - Verify that sequential requests produce independent results and state - does not leak between requests. MiniCPM-o 4.5 has max_num_seqs=1 so - requests are processed one-at-a-time; the second request must not - receive the first request's audio or token state. + Verify that sequential requests produce independent results and that the + second request does not receive the first request's audio or token state. Deploy Setting: default 2GPU Input Modal: text (two different prompts) Output Modal: text + audio (both) @@ -138,7 +136,7 @@ def test_sequential_requests_independent(omni_server, openai_client) -> None: def test_text_to_audio_long_output_001(omni_server, openai_client) -> None: """ Test text input generating a longer audio output to exercise the - token2wav decoder across multiple frames. + Code2Wav stage across multiple frames. Deploy Setting: default 2GPU Input Modal: text (longer prompt) Output Modal: text + audio @@ -146,14 +144,14 @@ def test_text_to_audio_long_output_001(omni_server, openai_client) -> None: """ messages = dummy_messages_from_mix_data( system_prompt=get_system_prompt(), - content_text="Tell me a short story about a cat in about 50 words.", + content_text="What is the capital of China? Answer in 40 words.", ) request_config = { "model": omni_server.model, "messages": messages, "stream": True, - "key_words": {"audio": ["cat"]}, + "key_words": {"audio": ["Beijing"]}, } openai_client.send_omni_request(request_config, request_num=get_max_batch_size()) diff --git a/tests/examples/test_minicpmo_realtime_web.py b/tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py similarity index 96% rename from tests/examples/test_minicpmo_realtime_web.py rename to tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py index 03fbc6d6d..f21035f44 100644 --- a/tests/examples/test_minicpmo_realtime_web.py +++ b/tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py @@ -12,14 +12,11 @@ import pytest pytestmark = [pytest.mark.core_model, pytest.mark.cpu] -DEMO_PATH = Path(__file__).resolve().parents[1] / "e2e/online_serving/minicpmo_realtime_duplex_scenarios.py" -MULTI_DEMO_PATH = ( - Path(__file__).resolve().parents[1] / "e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py" -) -PAIR_DEMO_PATH = Path(__file__).resolve().parents[1] / "e2e/online_serving/run_minicpmo_realtime_duplex_demo_pair.py" -SOFT_INTERRUPT_DEMO_PATH = ( - Path(__file__).resolve().parents[1] / "e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py" -) +DRIVER_DIR = Path(__file__).resolve().parent +DEMO_PATH = DRIVER_DIR / "minicpmo_realtime_duplex_scenarios.py" +MULTI_DEMO_PATH = DRIVER_DIR / "run_minicpmo_realtime_duplex_multi_session.py" +PAIR_DEMO_PATH = DRIVER_DIR / "run_minicpmo_realtime_duplex_demo_pair.py" +SOFT_INTERRUPT_DEMO_PATH = DRIVER_DIR / "run_minicpmo_realtime_duplex_soft_interrupt.py" def _load_demo_module(): @@ -338,14 +335,7 @@ def test_realtime_duplex_soft_interrupt_accepts_explicit_ref_audio(monkeypatch): assert args.validation_mode == "model-policy" -@pytest.mark.parametrize( - "extra_args", - [ - ["--expect-second-response-substring", "一加一等于二"], - ["--input-sha256", "a" * 64], - ], -) -def test_realtime_duplex_soft_interrupt_response_required_needs_bound_fixture(monkeypatch, extra_args): +def test_realtime_duplex_soft_interrupt_response_required_needs_bound_fixture(monkeypatch): demo = _load_soft_interrupt_demo_module() monkeypatch.setattr( demo.sys, @@ -360,7 +350,6 @@ def test_realtime_duplex_soft_interrupt_response_required_needs_bound_fixture(mo "ref.wav", "--validation-mode", "response-required", - *extra_args, ], ) @@ -412,7 +401,6 @@ def test_realtime_duplex_soft_interrupt_accepts_multi_delta_handoff_sequence(tmp "_client_received_at_s": 2.6, }, {"type": "response.done", "response_id": first_response_id, "_client_received_at_s": 3.0}, - {"type": "response.listen", "_client_received_at_s": 3.2}, {"type": "response.created", "response": {"id": second_response_id}, "_client_received_at_s": 4.0}, {"type": "response.speak", "response_id": second_response_id, "_client_received_at_s": 4.0}, { @@ -458,10 +446,11 @@ def test_realtime_duplex_soft_interrupt_accepts_multi_delta_handoff_sequence(tmp validation_mode="response-required", min_responses=2, min_audio_deltas_per_response=2, - expect_second_response_substring="一加一等于二", + expect_followup_response_substring="一加一等于二", ) assert summary["ok"] is True + assert summary["listen_between_responses"] is False assert summary["second_response_before_final_commit"] is True assert summary["final_listen_after_commit"] is True assert summary["response_audio_contract_ok"] is True @@ -498,7 +487,7 @@ def test_realtime_duplex_soft_interrupt_model_policy_accepts_single_response(tmp validation_mode="model-policy", min_responses=2, min_audio_deltas_per_response=2, - expect_second_response_substring=None, + expect_followup_response_substring=None, ) assert summary["ok"] is True @@ -537,14 +526,14 @@ def test_realtime_duplex_soft_interrupt_response_required_rejects_single_respons validation_mode="response-required", min_responses=2, min_audio_deltas_per_response=2, - expect_second_response_substring="一加一等于二", + expect_followup_response_substring="一加一等于二", ) assert summary["ok"] is False assert summary["enough_responses"] is False -def test_realtime_duplex_soft_interrupt_checks_expected_text_in_second_response(tmp_path): +def test_realtime_duplex_soft_interrupt_reports_text_expectation_without_gating(tmp_path): demo = _load_soft_interrupt_demo_module() output = tmp_path / "second_response_text" output.mkdir() @@ -611,11 +600,32 @@ def test_realtime_duplex_soft_interrupt_checks_expected_text_in_second_response( validation_mode="response-required", min_responses=2, min_audio_deltas_per_response=2, - expect_second_response_substring="一加一等于二", + expect_followup_response_substring="一加一等于二", ) - assert summary["ok"] is False - assert summary["second_response_transcript_expectation_ok"] is False + assert summary["ok"] is True + assert summary["followup_response_transcript_ok"] is True + assert summary["followup_response_transcript_expectation_ok"] is False + + events = [ + event + for event in events + if event.get("response_id") != second_response_id or event["type"] != "response.audio_transcript.delta" + ] + (output / "events.jsonl").write_text( + "".join(demo.json.dumps(event) + "\n" for event in events), + encoding="utf-8", + ) + missing_transcript_summary = demo.summarize_artifacts( + output_dir=output, + validation_mode="response-required", + min_responses=2, + min_audio_deltas_per_response=2, + expect_followup_response_substring="一加一等于二", + ) + + assert missing_transcript_summary["ok"] is False + assert missing_transcript_summary["followup_response_transcript_ok"] is False def test_realtime_duplex_multi_session_resume_url_disables_autostart(): @@ -1523,7 +1533,7 @@ def test_realtime_duplex_demo_waits_at_each_model_unit_and_stops_after_speak(): assert len(ws.messages) == 10 -def test_realtime_duplex_demo_listen_only_overlap_sends_next_turn_before_first_done(monkeypatch): +def test_realtime_duplex_demo_listen_only_overlap_accepts_silence_unit_before_first_done(monkeypatch): demo = _load_demo_module() state = demo.DemoState() send_calls = 0 @@ -1548,7 +1558,6 @@ def test_realtime_duplex_demo_listen_only_overlap_sends_next_turn_before_first_d del args assert not state.response_done("resp-first") kwargs["on_model_unit_ready"]() - state.add({"type": "input_audio_buffer.speech_started", "turn": 2}) _add_response_transcript(state, "resp-first", transcript="第一轮完成", audio=False) state.add({"type": "response.created", "response": {"id": "resp-second"}}) _add_response_transcript(state, "resp-second", transcript="第二轮完成", audio=True) @@ -1568,7 +1577,7 @@ def test_realtime_duplex_demo_listen_only_overlap_sends_next_turn_before_first_d ws, state, b"\x00\x00", - b"\x01\x00", + b"\x00\x00", transcripts=("first", "second"), durations_ms=(None, None), chunk_ms=200, diff --git a/tests/engine/test_multimodal_accumulation.py b/tests/engine/test_multimodal_accumulation.py index ca449154c..dbde83fef 100644 --- a/tests/engine/test_multimodal_accumulation.py +++ b/tests/engine/test_multimodal_accumulation.py @@ -18,6 +18,7 @@ def test_chunk_accumulation_policy_replaces_snapshots_and_drains_delta_state(): accumulated = MultimodalPayload.from_dict( { "audio": torch.tensor([1.0]), + "meta.segment_end": torch.tensor([0]), "meta.tts_is_last_chunk": torch.tensor([0]), "meta.turn_end": torch.tensor([0]), "meta.stable_request_value": "keep", @@ -26,6 +27,7 @@ def test_chunk_accumulation_policy_replaces_snapshots_and_drains_delta_state(): incoming = MultimodalPayload.from_dict( { "audio": torch.tensor([2.0]), + "meta.segment_end": torch.tensor([1]), "meta.tts_is_last_chunk": torch.tensor([1]), "meta.turn_end": torch.tensor([1]), } @@ -41,6 +43,7 @@ def test_chunk_accumulation_policy_replaces_snapshots_and_drains_delta_state(): drain_delta_payload(merged) assert "audio" not in merged + assert "meta.segment_end" not in merged assert "meta.tts_is_last_chunk" not in merged assert "meta.turn_end" not in merged assert merged.metadata["meta.stable_request_value"] == "keep" diff --git a/tests/engine/test_orchestrator.py b/tests/engine/test_orchestrator.py index 4217e2156..5c7066e0c 100644 --- a/tests/engine/test_orchestrator.py +++ b/tests/engine/test_orchestrator.py @@ -1813,6 +1813,56 @@ async def test_stage_pool_submit_update_refreshes_output_processor_state() -> No @pytest.mark.asyncio +async def test_handle_streaming_update_unknown_request_is_dropped() -> None: + """A streaming update for an untracked request must not resurrect it. + + Updates always follow the first-chunk add_request through the same + ordered submission queue, so an unknown id can only mean the request + already finished or was aborted (e.g. the client disconnected). + Falling back to add_request created headless sessions that kept + cycling through the stages (issue #4271). + """ + + class RecordingPool: + def __init__(self) -> None: + self.calls: list[tuple[str, Any]] = [] + + async def submit_update(self, request_id, req_state, request, *, prompt_text=None) -> int: + self.calls.append((request_id, prompt_text)) + return 0 + + pool = RecordingPool() + orchestrator = object.__new__(Orchestrator) + orchestrator.async_chunk = False + orchestrator.request_states = {} + orchestrator.stage_pools = [pool] + add_request_calls: list[str] = [] + + async def record_add_request(msg) -> None: + add_request_calls.append(msg.request_id) + + orchestrator._handle_add_request = record_add_request + + await orchestrator._handle_streaming_update( + StageSubmissionMessage( + type="streaming_update", + request_id="req-unknown", + prompt=SimpleNamespace(request_id="req-unknown", prompt_token_ids=[1], resumable=True), + original_prompt={"prompt": "segment-2"}, + output_prompt_text="segment-2", + sampling_params_list=[_sampling_params()], + final_stage_id=0, + preprocess_ms=0.0, + request_timestamp=time.time(), + enqueue_ts=time.perf_counter(), + ) + ) + + assert pool.calls == [] + assert add_request_calls == [] + assert "req-unknown" not in orchestrator.request_states + + async def test_handle_streaming_update_passes_prompt_text_to_stage_pool() -> None: class RecordingPool: def __init__(self) -> None: diff --git a/tests/engine/test_orchestrator_stage_input_bridge.py b/tests/engine/test_orchestrator_stage_input_bridge.py index 0c5bb217d..cf347e49f 100644 --- a/tests/engine/test_orchestrator_stage_input_bridge.py +++ b/tests/engine/test_orchestrator_stage_input_bridge.py @@ -7,15 +7,24 @@ import asyncio import queue from types import SimpleNamespace from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import ANY, AsyncMock, MagicMock import janus import pytest from vllm.outputs import CompletionOutput, RequestOutput from vllm.sampling_params import SamplingParams -from vllm_omni.engine.orchestrator import Orchestrator, OrchestratorRequestState +from vllm_omni.engine.orchestrator import ( + Orchestrator, + OrchestratorRequestState, + _OrchestratorDuplexStagePort, +) from vllm_omni.engine.stage_pool import StagePool +from vllm_omni.experimental.fullduplex.engine.contracts import ( + DuplexStageRequestContext, + DuplexStageSubmission, +) +from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -107,11 +116,50 @@ class FakePrewarmPool: async def submit_initial(self, _request_id, _req_state, request, prompt_text=None): self.submitted.append(request) + return 0 def get_bound_replica_id(self, _request_id): return 0 +def _duplex_stage_port_submission(): + stage_pools = [] + for stage_id in range(3): + pool = SimpleNamespace( + stage_client=SimpleNamespace(default_sampling_params=SamplingParams(max_tokens=1)), + stage_vllm_config=SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)), + submit_initial=AsyncMock(return_value=stage_id + 10), + submit_update=AsyncMock(return_value=stage_id + 20), + ) + stage_pools.append(pool) + request_states: dict[str, OrchestratorRequestState] = {} + prewarm = AsyncMock() + port = _OrchestratorDuplexStagePort( + stage_pools=stage_pools, + request_states=request_states, + running_counter=None, + cleanup_request_ids=AsyncMock(), + async_chunk=True, + prewarm_async_chunk_stages=prewarm, + ) + context = DuplexStageRequestContext( + request_id="req-duplex", + session_id="session-duplex", + fence=DuplexFence("session-duplex"), + stage_id=0, + final_stage_id=2, + config_generation=0, + sampling_params=tuple(SamplingParams(max_tokens=1) for _ in range(3)), + ) + port.ensure_request(context) + submission = DuplexStageSubmission( + context=context, + prompt={"prompt_token_ids": [1, 2]}, + already_submitted=False, + ) + return port, stage_pools, request_states, prewarm, submission + + def _request_output(request_id: str) -> RequestOutput: completion = CompletionOutput( index=0, @@ -199,11 +247,13 @@ async def test_async_prewarm_skips_outgoing_only_stage() -> None: stage2 = FakePrewarmPool("receiver") orchestrator.stage_pools = [stage0, stage1, stage2] orchestrator._emit_tx_edge = lambda **_kwargs: None + orchestrator._record_duplex_stage_submission = MagicMock() req_state = OrchestratorRequestState( request_id="req-prewarm", prompt={"prompt_token_ids": [1, 2]}, sampling_params_list=[SamplingParams(max_tokens=1) for _ in range(3)], final_stage_id=2, + duplex_identity=SimpleNamespace(), ) await orchestrator._prewarm_async_chunk_stages( @@ -216,6 +266,23 @@ async def test_async_prewarm_skips_outgoing_only_stage() -> None: assert len(stage2.submitted) == 1 assert 1 not in req_state.stage_submit_ts assert 2 in req_state.stage_submit_ts + orchestrator._record_duplex_stage_submission.assert_called_once_with( + 2, + "req-prewarm", + 0, + req_state, + ) + + +@pytest.mark.asyncio +async def test_duplex_prewarm_runs_after_first_stage0_submission() -> None: + port, stage_pools, request_states, prewarm, submission = _duplex_stage_port_submission() + + result = await port.submit(submission) + + assert result.stage_id == 0 + stage_pools[0].submit_initial.assert_awaited_once() + prewarm.assert_awaited_once_with("req-duplex", ANY, request_states["req-duplex"]) @pytest.mark.asyncio diff --git a/tests/engine/test_output_processor.py b/tests/engine/test_output_processor.py index aef78e47e..df0911aa0 100644 --- a/tests/engine/test_output_processor.py +++ b/tests/engine/test_output_processor.py @@ -14,7 +14,7 @@ from vllm.v1.engine import FinishReason from vllm.v1.engine.output_processor import OutputProcessor as VLLMOutputProcessor from vllm_omni.outputs import output_processor -from vllm_omni.outputs.output_modality import OutputModalityNames +from vllm_omni.outputs.output_modality import OutputModality, OutputModalityNames from vllm_omni.outputs.output_processor import MultimodalOutputProcessor, OmniRequestState pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -452,6 +452,127 @@ def test_no_detokenizer_make_request_output(): assert AUDIO in result.outputs[0].multimodal_output +def test_no_detokenizer_process_outputs_returns_nonterminal_audio_chunk(monkeypatch): + """Generation-stage MM-only chunks must reach the orchestrator. + + StagePool registers generation requests without a per-request collector, + so the processor return value is the only path from Stage2 to + Orchestrator._handle_processed_outputs(). + """ + state = _make_no_detok_state(RequestOutputKind.DELTA) + processor = object.__new__(MultimodalOutputProcessor) + processor.output_modality = OutputModality.AUDIO + processor.request_states = {"r": state} + upstream_result = SimpleNamespace(request_outputs=[], reqs_to_abort=[]) + monkeypatch.setattr( + VLLMOutputProcessor, + "process_outputs", + lambda *_args, **_kwargs: upstream_result, + ) + engine_output = SimpleNamespace( + request_id="r", + multimodal_output={ + "model_outputs": torch.arange(8, dtype=torch.float32), + "sr": torch.tensor(24000, dtype=torch.int32), + }, + output_type="audio", + pooling_output=None, + new_token_ids=[], + finish_reason=None, + stop_reason=None, + kv_transfer_params=None, + routed_experts=None, + num_cached_tokens=0, + ) + + result = processor.process_outputs([engine_output]) + + assert len(result.request_outputs) == 1 + assert result.request_outputs[0].request_id == "r" + assert AUDIO in result.request_outputs[0].outputs[0].multimodal_output + + +def _make_mm_only_output_processor(monkeypatch): + processor = object.__new__(MultimodalOutputProcessor) + processor.output_modality = OutputModality.AUDIO + processor.request_states = {"r": _make_no_detok_state(RequestOutputKind.DELTA)} + monkeypatch.setattr( + VLLMOutputProcessor, + "process_outputs", + lambda *_args, **_kwargs: SimpleNamespace( + request_outputs=[], + reqs_to_abort=[], + ), + ) + + def finish_request(req_state): + processor.request_states.pop(req_state.request_id, None) + + processor._finish_request = finish_request + return processor + + +def _audio_engine_output(*, is_segment_finished: bool, is_last_chunk: bool): + return SimpleNamespace( + request_id="r", + multimodal_output={ + "model_outputs": torch.arange(8, dtype=torch.float32), + "meta.tts_is_last_chunk": torch.tensor([int(is_last_chunk)]), + }, + output_type="audio", + pooling_output=None, + new_token_ids=[], + finish_reason=FinishReason.STOP, + stop_reason=None, + kv_transfer_params=None, + routed_experts=None, + num_cached_tokens=0, + is_segment_finished=is_segment_finished, + ) + + +def test_mm_only_segment_finish_retains_request_state_for_next_audio_chunk(monkeypatch): + processor = _make_mm_only_output_processor(monkeypatch) + + first = processor.process_outputs([_audio_engine_output(is_segment_finished=True, is_last_chunk=False)]) + + assert "r" in processor.request_states + assert len(first.request_outputs) == 1 + assert first.request_outputs[0].finished is False + + second = processor.process_outputs([_audio_engine_output(is_segment_finished=False, is_last_chunk=True)]) + + assert len(second.request_outputs) == 1 + assert second.request_outputs[0].finished is True + assert "r" not in processor.request_states + + +def test_mm_only_nonfinal_audio_retains_state_without_segment_marker(monkeypatch): + processor = _make_mm_only_output_processor(monkeypatch) + + first = processor.process_outputs([_audio_engine_output(is_segment_finished=False, is_last_chunk=False)]) + + assert "r" in processor.request_states + assert len(first.request_outputs) == 1 + assert first.request_outputs[0].finished is False + + second = processor.process_outputs([_audio_engine_output(is_segment_finished=False, is_last_chunk=True)]) + + assert len(second.request_outputs) == 1 + assert second.request_outputs[0].finished is True + assert "r" not in processor.request_states + + +def test_mm_only_terminal_finish_removes_request_state(monkeypatch): + processor = _make_mm_only_output_processor(monkeypatch) + + result = processor.process_outputs([_audio_engine_output(is_segment_finished=False, is_last_chunk=True)]) + + assert len(result.request_outputs) == 1 + assert result.request_outputs[0].finished is True + assert "r" not in processor.request_states + + def test_no_detokenizer_make_request_output_with_routed_experts(): """make_request_output accepts the routed_experts arg that the multimodal output channel (_process_mm_only_outputs) passes positionally, and attaches diff --git a/tests/engine/test_single_stage_mode.py b/tests/engine/test_single_stage_mode.py index 7cf4c9d75..c55ee14e1 100644 --- a/tests/engine/test_single_stage_mode.py +++ b/tests/engine/test_single_stage_mode.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import socket from contextlib import contextmanager from types import SimpleNamespace from typing import Any @@ -11,6 +12,7 @@ import pytest from pytest_mock import MockerFixture from vllm.v1.engine.utils import EngineZmqAddresses +from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig from vllm_omni.engine.async_omni_engine import AsyncOmniEngine from vllm_omni.engine.stage_engine_core_client import StageEngineCoreClientBase from vllm_omni.engine.stage_engine_startup import ( @@ -189,6 +191,34 @@ class TestOmniMasterServerAllocation: output_port = int(alloc.output_bind_address.split(":")[-1]) assert len({handshake_port, input_port, output_port}) == 3 + def test_route_ports_remain_os_reserved_until_master_releases_them(self): + server = OmniMasterServer(master_address="127.0.0.1", master_port=15005, stage_ids=[0]) + alloc = server.get_allocation(0) + ports = [ + int(alloc.handshake_bind_address.rsplit(":", 1)[-1]), + int(alloc.input_bind_address.rsplit(":", 1)[-1]), + int(alloc.output_bind_address.rsplit(":", 1)[-1]), + ] + + for port in ports: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + with pytest.raises(OSError): + probe.bind(("127.0.0.1", port)) + + server.release_route_port_reservations(0, handshake=True, data=False) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.bind(("127.0.0.1", ports[0])) + for port in ports[1:]: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + with pytest.raises(OSError): + probe.bind(("127.0.0.1", port)) + + server.stop() + + for port in ports[1:]: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.bind(("127.0.0.1", port)) + def test_get_zmq_addresses_returns_bind_addresses(self): server = OmniMasterServer(master_address="127.0.0.1", master_port=15002, stage_ids=[0]) alloc = server.get_allocation(0) @@ -397,6 +427,25 @@ class TestSingleStageModeDetection: assert engine.single_stage_mode is False assert engine._single_stage_id_filter is None + def test_stage_configs_path_loads_duplex_runtime_config(self, mocker: MockerFixture): + duplex_session = DuplexSessionRuntimeConfig(max_sessions=2) + mocker.patch( + "vllm_omni.engine.async_omni_engine.StageConfigFactory.get_pipeline_config", + return_value=None, + ) + load_deploy_config = mocker.patch( + "vllm_omni.engine.async_omni_engine.load_deploy_config", + return_value=SimpleNamespace(duplex_session=duplex_session), + ) + + engine = self._make_engine_no_thread( + mocker, + stage_configs_path="/fake/duplex.yaml", + ) + + load_deploy_config.assert_called_once_with("/fake/duplex.yaml") + assert engine.duplex_session_config is duplex_session + def test_single_stage_mode_without_stage_id_has_no_filter(self, mocker: MockerFixture): engine = self._make_engine_no_thread( mocker, diff --git a/tests/entrypoints/openai_api/test_duplex_handler.py b/tests/entrypoints/openai_api/test_duplex_handler.py index 3ed30e7e8..9f8768bf4 100644 --- a/tests/entrypoints/openai_api/test_duplex_handler.py +++ b/tests/entrypoints/openai_api/test_duplex_handler.py @@ -2520,6 +2520,52 @@ def test_duplex_auto_response_tts_scheduler_eos_fallback(): assert results[0]["abort_data_plane_request"] is True +def test_duplex_auto_response_rearms_tts_boundary_for_next_segment_in_same_turn(): + data_plane = _test_data_plane() + request_id = "duplex-sid-next-segment-same-turn-e0-stage0" + session = DuplexSession( + session_id="sid-next-segment-same-turn", + config=DuplexSessionConfig(extra_body={"auto_response": True}), + ) + session.bind_response_turn(0) + data_plane.begin_request(request_id) + + first_segment = _duplex_tts_output( + request_id=request_id, + samples=24000, + finished=False, + tts_is_last_chunk=True, + turn_id=0, + ) + first_results = _project_data_plane( + data_plane, + {"data_plane_outputs": [first_segment]}, + session=session, + ) + + assert len(first_results) == 1 + assert first_results[0]["abort_data_plane_request"] is True + + data_plane.begin_request(request_id) + second_segment = _duplex_tts_output( + request_id=request_id, + samples=48000, + finished=False, + text="hello again", + tts_is_last_chunk=True, + turn_id=0, + ) + second_results = _project_data_plane( + data_plane, + {"data_plane_outputs": [second_segment]}, + session=session, + ) + + assert len(second_results) == 1 + assert second_results[0]["audio_data"] == "wav-24000" + assert second_results[0]["abort_data_plane_request"] is True + + def test_duplex_turn_end_is_not_swallowed_by_finished_segment_fallback(): data_plane = _test_data_plane() request_id = "duplex-sid-turn-end-after-segment-e0-stage0" @@ -6202,19 +6248,27 @@ async def test_minicpmo_native_auto_response_keeps_request_bound_for_segment_con config_timeout_s=0.1, idle_timeout_s=1, ) - ws = TimedWebSocket() + + def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None: + if data.get("type") != "session.created": + return + session = handler._registry.get("sid-native-auto-continuation") + assert session is not None + session.capabilities.chunk_period_ms = 50 + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(800), + "format": "pcm_f32le", + "sample_rate_hz": 16000, + } + ) + ws.put({"type": "input_audio_buffer.commit", "final": True}) + + ws = TimedWebSocket(on_send=on_send) event = _native_session_create("sid-native-auto-continuation") event["session"]["extra_body"]["auto_response"] = True ws.put(event) - ws.put( - { - "type": "input_audio_buffer.append", - "audio": _pcm_f32_b64(16000), - "format": "pcm_f32le", - "sample_rate_hz": 16000, - } - ) - ws.put({"type": "input_audio_buffer.commit", "final": True}) await handler.handle_session(ws) @@ -6225,6 +6279,112 @@ async def test_minicpmo_native_auto_response_keeps_request_bound_for_segment_con assert final is False +@pytest.mark.asyncio +async def test_minicpmo_native_auto_response_real_input_preempts_unsent_silence(): + request_id = "duplex-sid-native-real-before-silence-e0-stage0" + + class SequencedAppendEngine(FakeEngineClient): + def __init__(self) -> None: + control_result = { + "operation": "append", + "session_id": "sid-native-real-before-silence", + "ok": True, + "unsupported_count": 0, + "error_count": 0, + "stage_results": [ + { + "result": { + "data_plane_append": True, + "request_id": request_id, + "response_stage_id": 1, + } + } + ], + } + terminal_segment = _duplex_tts_output( + request_id=request_id, + samples=0, + finished=True, + tts_is_last_chunk=True, + token_ids=[151645], + ) + super().__init__( + control_result=control_result, + collect_outputs=[[terminal_segment], [], []], + collect_delay_s=0.01, + ) + self.append_sequence: list[str] = [] + self.first_real_started = asyncio.Event() + self.second_real_started = asyncio.Event() + self.real_count = 0 + + async def append_duplex_input_async(self, session_id: str, **kwargs): + kwargs.pop("expected_epoch", None) + result = await super().append_duplex_input_async(session_id, **kwargs) + payload = kwargs.get("payload") + is_silence = ( + isinstance(payload, dict) + and payload.get("audio") == OmniDuplexSessionHandler._NATIVE_SILENCE_UNIT_PAYLOAD_AUDIO + ) + if is_silence: + self.append_sequence.append("silence") + else: + self.real_count += 1 + self.append_sequence.append(f"real-{self.real_count}") + if self.real_count == 1: + self.first_real_started.set() + elif self.real_count == 2: + self.second_real_started.set() + return result + + session_created = asyncio.Event() + engine = SequencedAppendEngine() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + + def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None: + if data.get("type") != "session.created": + return + session = handler._registry.get("sid-native-real-before-silence") + assert session is not None + session.capabilities.chunk_period_ms = 50 + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(800), + "format": "pcm_f32le", + "sample_rate_hz": 16000, + } + ) + session_created.set() + + ws = TimedWebSocket(on_send=on_send, receive_timeout_s=2.0) + event = _native_session_create("sid-native-real-before-silence") + event["session"]["extra_body"]["auto_response"] = True + ws.put(event) + handler_task = asyncio.create_task(handler.handle_session(ws)) + + await asyncio.wait_for(session_created.wait(), timeout=1) + await asyncio.wait_for(engine.first_real_started.wait(), timeout=1) + await asyncio.sleep(0.02) + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(800, value=0.07), + "format": "pcm_f32le", + "sample_rate_hz": 16000, + } + ) + await asyncio.wait_for(engine.second_real_started.wait(), timeout=1) + ws.put({"type": "session.close"}) + await asyncio.wait_for(handler_task, timeout=2) + + assert engine.append_sequence[:2] == ["real-1", "real-2"] + + @pytest.mark.asyncio async def test_minicpmo_native_auto_response_real_input_waits_for_submitted_silence_tail(): request_id = "duplex-sid-native-silence-tail-e0-stage0" @@ -6294,18 +6454,26 @@ async def test_minicpmo_native_auto_response_real_input_waits_for_submitted_sile config_timeout_s=0.1, idle_timeout_s=1, ) - ws = TimedWebSocket(receive_timeout_s=2.0) + + def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None: + if data.get("type") != "session.created": + return + session = handler._registry.get("sid-native-silence-tail") + assert session is not None + session.capabilities.chunk_period_ms = 50 + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(800, value=0.05), + "format": "pcm_f32le", + "sample_rate_hz": 16000, + } + ) + + ws = TimedWebSocket(on_send=on_send, receive_timeout_s=2.0) event = _native_session_create("sid-native-silence-tail") event["session"]["extra_body"]["auto_response"] = True ws.put(event) - ws.put( - { - "type": "input_audio_buffer.append", - "audio": _pcm_f32_b64(16000, value=0.05), - "format": "pcm_f32le", - "sample_rate_hz": 16000, - } - ) handler_task = asyncio.create_task(handler.handle_session(ws)) await asyncio.wait_for(engine.silence_append_started.wait(), timeout=1) @@ -6330,6 +6498,135 @@ async def test_minicpmo_native_auto_response_real_input_waits_for_submitted_sile assert engine.append_sequence == ["real-1", "silence-started", "silence-returned", "real-2"] +@pytest.mark.asyncio +async def test_minicpmo_native_auto_response_preserves_silence_continuations_across_append_tails(): + request_id = "duplex-sid-native-continuation-tails-e0-stage0" + + class SequencedAppendEngine(FakeEngineClient): + def __init__(self) -> None: + control_result = { + "operation": "append", + "session_id": "sid-native-continuation-tails", + "ok": True, + "unsupported_count": 0, + "error_count": 0, + "stage_results": [ + { + "result": { + "data_plane_append": True, + "request_id": request_id, + "response_stage_id": 1, + } + } + ], + } + super().__init__(control_result=control_result) + self.real_append_started = asyncio.Event() + self.release_real_append = asyncio.Event() + self.first_silence_started = asyncio.Event() + self.release_first_silence = asyncio.Event() + self.second_silence_started = asyncio.Event() + self.release_collect = asyncio.Event() + self.silence_count = 0 + + async def append_duplex_input_async(self, session_id: str, **kwargs): + kwargs.pop("expected_epoch", None) + payload = kwargs.get("payload") + assert isinstance(payload, dict) + result = await super().append_duplex_input_async(session_id, **kwargs) + if payload.get("audio") != OmniDuplexSessionHandler._NATIVE_SILENCE_UNIT_PAYLOAD_AUDIO: + self.real_append_started.set() + await self.release_real_append.wait() + return result + + self.silence_count += 1 + if self.silence_count == 1: + self.first_silence_started.set() + await self.release_first_silence.wait() + else: + self.second_silence_started.set() + return result + + async def collect_duplex_data_plane_outputs_async( + self, + request_id: str, + *, + response_stage_id: int | None = None, + timeout: float | None = None, + ) -> list[object]: + del timeout + self.collected.append((request_id, response_stage_id)) + await self.release_collect.wait() + return [] + + session_created = asyncio.Event() + + def on_send(_ws: TimedWebSocket, data: dict[str, Any]) -> None: + if data.get("type") == "session.created": + session_created.set() + + engine = SequencedAppendEngine() + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + ) + ws = TimedWebSocket(on_send=on_send, receive_timeout_s=2.0) + event = _native_session_create("sid-native-continuation-tails") + event["session"]["extra_body"]["auto_response"] = True + ws.put(event) + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(16000, value=0.05), + "format": "pcm_f32le", + "sample_rate_hz": 16000, + } + ) + handler_task = asyncio.create_task(handler.handle_session(ws)) + + await asyncio.wait_for(session_created.wait(), timeout=1) + await asyncio.wait_for(engine.real_append_started.wait(), timeout=1) + session = handler._registry.get("sid-native-continuation-tails") + assert session is not None + session.capabilities.chunk_period_ms = 50 + session.bind_request(request_id) + response_id = session.begin_response(turn_id=session.turn_id) + native = handler._minicpmo_session_state(session) + scheduler = native.silence_continuation_scheduler + assert scheduler is not None + + payload = handler._native_silence_unit_payload() + payload["duplex_turn_id"] = session.turn_id + kwargs = { + "request_id": request_id, + "owner_id": f"response:{response_id}", + "response_id": response_id, + "response_owned": True, + "expected_epoch": session.epoch, + "expected_incarnation": session.incarnation, + "expected_model_turn_id": None, + "send_json": ws.send_json, + } + assert await scheduler(payload, **kwargs) is True + + next_schedule = asyncio.create_task(scheduler(payload, **kwargs)) + await asyncio.sleep(0.05) + assert not next_schedule.done() + + engine.release_real_append.set() + await asyncio.wait_for(engine.first_silence_started.wait(), timeout=1) + assert not next_schedule.done() + + engine.release_first_silence.set() + assert await asyncio.wait_for(next_schedule, timeout=1) is True + await asyncio.wait_for(engine.second_silence_started.wait(), timeout=1) + ws.put({"type": "session.close"}) + await asyncio.wait_for(handler_task, timeout=2) + + assert engine.silence_count == 2 + + @pytest.mark.asyncio async def test_minicpmo_native_duplex_uses_segment_text_metadata_for_transcript_cursor(): request_id = "duplex-sid-native-repeat-text-e0-stage0" diff --git a/tests/entrypoints/test_serve.py b/tests/entrypoints/test_serve.py index bbed8fc08..cbe69302f 100644 --- a/tests/entrypoints/test_serve.py +++ b/tests/entrypoints/test_serve.py @@ -222,6 +222,7 @@ def test_run_headless_llm_registers_with_auto_assigned_replica_id(mocker: Mocker from vllm_omni.engine.stage_engine_startup import StageRegistrationResponse stage_cfg = _make_stage_cfg(0, stage_type="llm") + stage_cfg.engine_args["async_chunk"] = True parallel_config = SimpleNamespace( data_parallel_size_local=1, data_parallel_rank=0, @@ -241,7 +242,10 @@ def test_run_headless_llm_registers_with_auto_assigned_replica_id(mocker: Mocker "vllm_omni.distributed.omni_connectors.utils.initialization.resolve_omni_kv_config_for_stage", return_value=(None, None, None), ) - mocker.patch("vllm_omni.engine.stage_init_utils.get_stage_connector_spec", return_value={}) + mock_connector_spec = mocker.patch( + "vllm_omni.engine.stage_init_utils.get_stage_connector_spec", + return_value={}, + ) mocker.patch("vllm_omni.engine.stage_init_utils.build_engine_args_dict", return_value={}) mocker.patch( "vllm_omni.engine.stage_init_utils.build_vllm_config", @@ -277,6 +281,7 @@ def test_run_headless_llm_registers_with_auto_assigned_replica_id(mocker: Mocker assert kwargs["omni_stage_config"] is stage_cfg assert kwargs["replica_id"] is None assert "socket_ownership" not in kwargs + assert mock_connector_spec.call_args.kwargs["async_chunk"] is True assert mock_manager_cls.call_count == 1 mgr_kwargs = mock_manager_cls.call_args.kwargs diff --git a/tests/examples/test_minicpmo_realtime_web_static.py b/tests/examples/test_minicpmo_realtime_web_static.py index aa9f60fe2..00859dabb 100644 --- a/tests/examples/test_minicpmo_realtime_web_static.py +++ b/tests/examples/test_minicpmo_realtime_web_static.py @@ -207,3 +207,68 @@ def test_playback_worklet_waits_before_playing_and_rebuffers_after_underrun(): capture_output=True, text=True, ) + + +def test_playback_worklet_fades_terminal_drain_to_zero(): + node = shutil.which("node") + if node is None: + pytest.skip("node is required for the AudioWorklet regression test") + + script = textwrap.dedent( + """ + const fs = require('fs'); + const vm = require('vm'); + + global.sampleRate = 1000; + const messages = []; + global.AudioWorkletProcessor = class { + constructor() { + this.port = { + onmessage: null, + postMessage: (message) => messages.push(message), + }; + } + }; + let Processor = null; + global.registerProcessor = (_name, processor) => { Processor = processor; }; + vm.runInThisContext(fs.readFileSync(process.argv[1], 'utf8')); + + const processor = new Processor(); + const pcm = new Int16Array(100); + pcm.fill(16384); + processor.handleMessage({ + type: 'audio', + pcm, + responseId: 'response-terminal', + initialBufferMs: 400, + }); + processor.handleMessage({ + type: 'drain', + responseId: 'response-terminal', + }); + + const output = new Float32Array(100); + processor.process([], [[output]]); + const assert = (condition, message) => { + if (!condition) throw new Error(message); + }; + + assert(output.some((sample) => sample !== 0), 'terminal audio must still play'); + assert(output[output.length - 1] === 0, 'terminal drain must fade the final sample to zero'); + const tail = Array.from(output.slice(-5), Math.abs); + assert( + tail.every((sample, index) => index === 0 || sample <= tail[index - 1]), + 'terminal drain fade must decrease monotonically', + ); + assert( + messages.filter((message) => message.type === 'playback-drained').length === 1, + 'terminal drain must be reported exactly once after playback', + ); + """ + ) + subprocess.run( + [node, "-e", script, str(STATIC_ROOT / "playback_worklet.js")], + check=True, + capture_output=True, + text=True, + ) diff --git a/tests/fullduplex/minicpmo45/test_input.py b/tests/fullduplex/minicpmo45/test_input.py index 035ab47ef..5562fb237 100644 --- a/tests/fullduplex/minicpmo45/test_input.py +++ b/tests/fullduplex/minicpmo45/test_input.py @@ -43,6 +43,54 @@ def test_append_emits_one_model_unit_when_multiple_units_are_buffered(): assert buffer.pending_byte_count == 16_000 * 4 +def test_speech_marker_does_not_leak_across_irregular_chunk_boundaries(): + buffer = MiniCPMO45PcmAppendBuffer() + + assert buffer.append(pcm_payload(15_000), chunk_period_ms=1_000) is None + speech_unit = buffer.append(pcm_payload(2_000, speech=False), chunk_period_ms=1_000) + silence_unit = buffer.append(pcm_payload(15_000, speech=False), chunk_period_ms=1_000) + + assert speech_unit is not None + assert speech_unit["is_speech"] is True + assert silence_unit is not None + assert silence_unit["is_speech"] is False + + +def test_force_listen_marker_does_not_leak_across_irregular_chunk_boundaries(): + buffer = MiniCPMO45PcmAppendBuffer() + forced = pcm_payload(15_000, speech=False) + forced["force_listen"] = True + + assert buffer.append(forced, chunk_period_ms=1_000) is None + forced_unit = buffer.append(pcm_payload(2_000, speech=False), chunk_period_ms=1_000) + unforced_unit = buffer.append(pcm_payload(15_000, speech=False), chunk_period_ms=1_000) + + assert forced_unit is not None + assert forced_unit["force_listen"] is True + assert unforced_unit is not None + assert unforced_unit["force_listen"] is False + + +def test_pcm_append_rollback_restores_per_span_speech_markers(): + buffer = MiniCPMO45PcmAppendBuffer() + assert buffer.append(pcm_payload(15_000), chunk_period_ms=1_000) is None + reservation = buffer.prepare_append( + pcm_payload(2_000, speech=False), + operation_id="mixed-unit", + chunk_period_ms=1_000, + ) + + assert reservation is not None + reservation.rollback() + first = buffer.append(pcm_payload(15_000, speech=False), chunk_period_ms=1_000) + second = buffer.flush(chunk_period_ms=1_000) + + assert first is not None + assert first["is_speech"] is True + assert second is not None + assert second["is_speech"] is False + + def test_commit_without_speech_does_not_synthesize_terminal_audio(): buffer = MiniCPMO45PcmAppendBuffer() buffer.append(pcm_payload(8_000, speech=False), chunk_period_ms=1_000) diff --git a/tests/helpers/minicpmo_4_5_duplex.py b/tests/helpers/minicpmo_4_5_duplex.py new file mode 100644 index 000000000..dd32c0dcf --- /dev/null +++ b/tests/helpers/minicpmo_4_5_duplex.py @@ -0,0 +1,179 @@ +"""Shared fixtures and argument builders for MiniCPM-o 4.5 duplex E2E tests.""" + +from __future__ import annotations + +import hashlib +import uuid +import wave +from pathlib import Path +from types import SimpleNamespace + +import pytest +from huggingface_hub import snapshot_download + +from tests.helpers.runtime import OmniServerParams +from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config + +MODEL = "openbmb/MiniCPM-o-4_5" +DEPLOY_CONFIG = modify_stage_config( + get_deploy_config_path("minicpmo_4_5_duplex.yaml"), + updates={ + "base_config": get_deploy_config_path("minicpmo_4_5.yaml"), + "stages": { + 0: {"kv_cache_memory_bytes": 6 * 1024 * 1024 * 1024}, + 1: {"kv_cache_memory_bytes": 512 * 1024 * 1024}, + 2: {"kv_cache_memory_bytes": 256 * 1024 * 1024}, + }, + }, +) +CORE_DEPLOY_CONFIG = modify_stage_config( + DEPLOY_CONFIG, + updates={ + "stages": { + 0: {"enforce_eager": True}, + 1: {"enforce_eager": True}, + } + }, +) +ASSET_DIR = Path(__file__).resolve().parents[1] / "assets" / "minicpmo_4_5" +RESPONSE_REQUIRED_WAV = ASSET_DIR / "response_required_16k.wav" +RESPONSE_REQUIRED_SHA256 = "2e5fd4eb3ee434ce107ee3a0591fa624a33f7683c7462f45fe651c443c9af941" +SOFT_INTERRUPT_WAV = ASSET_DIR / "soft_interrupt_16k.wav" +SOFT_INTERRUPT_SHA256 = "cadae6d0ddc510310f16f8775d6379f30a5195369ccb54ff10a8e3f9a1f4a2ea" +REF_AUDIO_RELATIVE_PATH = Path("assets") / "HT_ref_audio.wav" + +SERVER_PARAMS = [ + pytest.param( + OmniServerParams( + model=MODEL, + stage_config_path=DEPLOY_CONFIG, + use_stage_cli=True, + server_args=["--trust-remote-code"], + ), + id="three-stage-single-gpu", + ) +] +CORE_SERVER_PARAMS = [ + pytest.param( + OmniServerParams( + model=MODEL, + stage_config_path=CORE_DEPLOY_CONFIG, + use_stage_cli=True, + server_args=["--trust-remote-code"], + ), + id="three-stage-single-gpu", + ) +] + + +def validated_wav(path: Path, expected_sha256: str) -> Path: + actual_sha256 = hashlib.sha256(path.read_bytes()).hexdigest() + if actual_sha256 != expected_sha256: + raise AssertionError( + f"MiniCPM-o duplex fixture SHA256 mismatch: expected {expected_sha256}, got {actual_sha256}" + ) + with wave.open(str(path), "rb") as wav_file: + actual_format = ( + wav_file.getframerate(), + wav_file.getnchannels(), + wav_file.getsampwidth(), + wav_file.getcomptype(), + ) + expected_format = (16_000, 1, 2, "NONE") + if actual_format != expected_format: + raise AssertionError(f"MiniCPM-o duplex fixture must be 16 kHz mono PCM16, got {actual_format}") + return path + + +def validated_input_wav() -> Path: + return validated_wav(RESPONSE_REQUIRED_WAV, RESPONSE_REQUIRED_SHA256) + + +def validated_soft_interrupt_wav() -> Path: + return validated_wav(SOFT_INTERRUPT_WAV, SOFT_INTERRUPT_SHA256) + + +def resolve_ref_audio(model_prefix: str) -> Path: + if model_prefix: + model_root = Path(model_prefix) / MODEL + else: + model_root = Path(snapshot_download(MODEL, local_files_only=True)) + ref_audio = model_root / REF_AUDIO_RELATIVE_PATH + if not ref_audio.is_file(): + raise FileNotFoundError(f"MiniCPM-o checkpoint ref audio is missing: {ref_audio}") + return ref_audio + + +def realtime_url(omni_server) -> str: + return f"ws://{omni_server.host}:{omni_server.port}/v1/realtime?duplex=1" + + +def demo_args( + *, + omni_server, + input_wav: Path, + ref_audio: Path, + output_dir: Path, +) -> SimpleNamespace: + return SimpleNamespace( + url=realtime_url(omni_server), + model=omni_server.model, + session_id=f"duplex-ci-single-{uuid.uuid4().hex}", + input_wav=str(input_wav), + ref_audio=str(ref_audio), + turn_input_wav=[], + output_dir=str(output_dir), + output_audio_format="pcm16", + chunk_ms=200, + realtime_input=True, + first_turn_ms=1400, + turn_duration_ms=[], + first_turn_transcript="duplex CI speech", + omit_transcript_hints=False, + validation_mode="response-required", + temperature=0.0, + scenario="sequential", + require_audio=True, + require_distinct_inputs=False, + expect_empty_turn=[], + short_ack_ms=350, + turns=1, + timeout_s=120.0, + model_policy_settle_ms=2000, + ) + + +def multi_session_args( + *, + omni_server, + input_wav: Path, + ref_audio: Path, + output_dir: Path, + response_required: bool, +) -> SimpleNamespace: + return SimpleNamespace( + url=realtime_url(omni_server), + model=omni_server.model, + sessions=2, + input_wav=str(input_wav), + ref_audio=str(ref_audio), + session_input_wav=[], + session_expected_token=[], + turn_input_wav=[], + output_dir=str(output_dir), + realtime_input=True, + chunk_ms=200, + turns=1, + first_turn_ms=1400, + turn_duration_ms=[], + response_required=response_required, + temperature=0.0 if response_required else None, + disconnect_session_index=0, + takeover_session_index=1, + resume_after_ms=100, + expire_session_index=None, + expire_after_s=40.0, + verify_admission_limit=None, + model_policy_settle_ms=2000, + timeout_s=180.0, + ) diff --git a/tests/model_executor/models/minicpmo_4_5/test_code2wav_batching.py b/tests/model_executor/models/minicpmo_4_5/test_code2wav_batching.py index 01a51bea8..7b5bb1044 100644 --- a/tests/model_executor/models/minicpmo_4_5/test_code2wav_batching.py +++ b/tests/model_executor/models/minicpmo_4_5/test_code2wav_batching.py @@ -19,10 +19,12 @@ class _FakeEncoder(nn.Module): def __init__(self): super().__init__() self.calls: list[int] = [] + self.last_chunk_calls: list[bool] = [] def forward_chunk(self, xs, last_chunk=False, cnn_cache=None, att_cache=None): batch, length, _ = xs.shape self.calls.append(batch) + self.last_chunk_calls.append(last_chunk) old_length = 0 if att_cache is None else att_cache.shape[3] output = xs[:, : max(1, length - 1)] cnn = xs[:, :1, :].transpose(1, 2).contiguous() @@ -156,6 +158,35 @@ def _model(): return model, token2wav +def test_code2wav_resolves_hf_model_id_for_assets(mocker, tmp_path): + resolved_root = tmp_path / "snapshot" + resolved_root.mkdir() + config = _config() + config.model_config.model = "openbmb/MiniCPM-o-4_5" + config.model_config.revision = "test-revision" + config.model_config.stage_connector_config["extra"].pop("prompt_wav") + config.load_config = SimpleNamespace(download_dir="/model-cache") + model = MiniCPMO45Code2Wav(vllm_config=config) + mock_download = mocker.patch( + "vllm_omni.model_executor.model_loader.weight_utils.download_weights_from_hf_specific", + return_value=str(resolved_root), + ) + + assert model._resolve_model_root() == resolved_root + assert model.model_path == str(resolved_root) + assert model._default_prompt_wav == str(resolved_root / "assets" / "HT_ref_audio.wav") + mock_download.assert_called_once_with( + "openbmb/MiniCPM-o-4_5", + "/model-cache", + allow_patterns=[ + "assets/HT_ref_audio.wav", + "assets/token2wav/*", + ], + revision="test-revision", + require_all=True, + ) + + def _info( request_id: str, chunk_seq: int, @@ -212,6 +243,18 @@ def test_adapter_runs_true_batch_cfg_and_splits_request_caches(): assert cache1[0, 0, 0, 0, 0].item() == 20 +def test_fade_in_out_limits_overlap_to_available_previous_audio(): + speech = torch.arange(6, dtype=torch.float32).reshape(1, -1) + previous = torch.full((1, 3), 2.0) + window = torch.hamming_window(8, periodic=False) + + actual = BatchedToken2Wav._fade_in_out(speech, previous, window) + + expected = speech.clone() + expected[..., :3] = speech[..., :3] * window[:3] + previous * window[-3:] + torch.testing.assert_close(actual, expected) + + def test_estimator_cache_stack_split_round_trip_preserves_cfg_rows(): token2wav = _FakeToken2Wav() adapter = BatchedToken2Wav(token2wav) @@ -259,6 +302,166 @@ def test_model_preserves_output_slots_and_prefers_runtime_codes(): assert token2wav.flow.encoder.calls[-1] == 2 +def test_code2wav_projects_duplex_metadata_to_final_audio_output(): + model, token2wav = _model() + segment = _info("duplex", 0, [10, 11]) + segment_text_utf8 = torch.tensor(list(b"hello"), dtype=torch.uint8) + segment["meta"].update( + { + "duplex_epoch": 3, + "duplex_turn_id": 7, + "llm_output_text_utf8": segment_text_utf8, + "tts_is_last_chunk": True, + "turn_end": False, + } + ) + + segment_output = _forward(model, [segment]) + + assert segment_output.multimodal_outputs["meta.turn_end"][0].item() is False + # A Talker unit boundary only drains pending codec tokens. The official + # streaming path keeps Token2wav open until the assistant turn ends. + assert token2wav.flow.encoder.last_chunk_calls[-1] is False + assert "duplex" in model._states + + final = _info("duplex", 1, [12, 13], last_chunk=True) + final["meta"].update(segment["meta"]) + final["meta"]["chunk_seq"] = 1 + final["meta"]["last_chunk"] = True + final["meta"]["turn_end"] = True + output = _forward(model, [final]) + + payload = output.multimodal_outputs + assert "meta" not in payload + assert payload["meta.duplex_epoch"][0].item() == 3 + assert payload["meta.duplex_turn_id"][0].item() == 7 + torch.testing.assert_close( + payload["meta.llm_output_text_utf8"][0], + segment_text_utf8, + ) + assert payload["meta.tts_is_last_chunk"][0].item() is True + assert payload["meta.turn_end"][0].item() is True + assert token2wav.flow.encoder.last_chunk_calls[-1] is True + assert "duplex" not in model._states + + +def test_initial_empty_segment_marker_initializes_stream_without_audio(): + model, token2wav = _model() + boundary = _info("duplex", 0, []) + boundary["meta"].update( + { + "code_flat_numel": 0, + "tts_is_last_chunk": True, + "turn_end": False, + } + ) + + output = _forward(model, [boundary]) + + assert output.multimodal_outputs["model_outputs"][0].numel() == 0 + assert "duplex" in model._states + assert token2wav.hift.calls == [] + + resumed = _info( + "duplex", + 1, + [4218, 4218, 4218, 10, 11, 12, 13, 14], + ) + output = _forward(model, [resumed]) + + assert output.multimodal_outputs["model_outputs"][0].numel() > 0 + assert "duplex" in model._states + + +def test_shared_runtime_prompt_recreates_missing_file_before_second_owner(tmp_path, monkeypatch): + monkeypatch.setattr("tempfile.gettempdir", lambda: str(tmp_path)) + model, _ = _model() + reference = torch.tensor([0.0, 0.25, -0.25, 0.0]) + + first = _info("voice-a", 0, [10, 11]) + first["codes"]["ref"] = reference + first["meta"]["ref_audio_sr"] = 16000 + first["meta"].pop("prompt_cache_id") + _forward(model, [first], request_ids=["internal-a"]) + + prompt_key = model._request_prompt_keys["internal-a"] + prompt_path = Path(model._runtime_prompts[prompt_key].path) + prompt_path.unlink() + + second = _info("voice-b", 0, [12, 13]) + second["codes"]["ref"] = reference + second["meta"]["ref_audio_sr"] = 16000 + second["meta"].pop("prompt_cache_id") + _forward(model, [second], request_ids=["internal-b"]) + + assert prompt_path.is_file() + assert model._runtime_prompts[prompt_key].owners == {"internal-a", "internal-b"} + + model.on_requests_finished(["internal-a"]) + assert prompt_path.is_file() + assert model._runtime_prompts[prompt_key].owners == {"internal-b"} + + model.on_requests_finished(["internal-b"]) + assert not prompt_path.exists() + assert prompt_key not in model._runtime_prompts + + +def test_runtime_prompt_write_failure_does_not_publish_partial_file(tmp_path, monkeypatch): + monkeypatch.setattr("tempfile.gettempdir", lambda: str(tmp_path)) + model, _ = _model() + reference = torch.tensor([0.0, 0.25, -0.25, 0.0]) + + def fail_after_partial_write(path, *_args, **_kwargs): + Path(path).write_bytes(b"partial") + raise OSError("simulated write failure") + + monkeypatch.setattr( + "vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_code2wav.sf.write", + fail_after_partial_write, + ) + + with pytest.raises(OSError, match="simulated write failure"): + model._materialize_runtime_prompt(reference, 16000) + + assert len(model._runtime_prompts) == 1 + entry = next(iter(model._runtime_prompts.values())) + assert not Path(entry.path).exists() + assert list(Path(entry.path).parent.iterdir()) == [] + + +def test_runtime_prompt_files_are_isolated_between_model_instances(tmp_path, monkeypatch): + monkeypatch.setattr("tempfile.gettempdir", lambda: str(tmp_path)) + first_model, _ = _model() + second_model, _ = _model() + reference = torch.tensor([0.0, 0.25, -0.25, 0.0]) + + def runtime_ref_info(request_id: str): + info = _info(request_id, 0, [10, 11]) + info["codes"]["ref"] = reference + info["meta"]["ref_audio_sr"] = 16000 + info["meta"].pop("prompt_cache_id") + return info + + _forward(first_model, [runtime_ref_info("voice-a")], request_ids=["internal-a"]) + _forward(second_model, [runtime_ref_info("voice-b")], request_ids=["internal-b"]) + + first_key = first_model._request_prompt_keys["internal-a"] + second_key = second_model._request_prompt_keys["internal-b"] + first_path = Path(first_model._runtime_prompts[first_key].path) + second_path = Path(second_model._runtime_prompts[second_key].path) + assert first_key == second_key + assert first_path != second_path + assert first_path.is_file() + assert second_path.is_file() + + first_model.on_requests_finished(["internal-a"]) + assert not first_path.exists() + assert second_path.is_file() + + second_model.on_requests_finished(["internal-b"]) + assert not second_path.exists() + + def test_mixed_final_exact_buckets_keep_order_and_release_only_final_states(): model, _ = _model() _forward( @@ -485,27 +688,37 @@ def test_reference_voice_and_duplex_metadata_follow_request_lifecycle(): model, _ = _model() first = _info("voice-a", 0, [1, 2]) first["codes"]["ref"] = torch.linspace(-0.1, 0.1, 160) + segment_text_utf8 = torch.tensor(list(b"hello"), dtype=torch.uint8) first["meta"].update( ref_audio_sr=16000, - native_duplex_segment_text="hello", + llm_output_text_utf8=segment_text_utf8, duplex_turn_id=7, duplex_epoch=3, ) first["meta"].pop("prompt_cache_id") output = _forward(model, [first]) - prompt_cache_id, prompt_wav = model._owned_prompt_wavs["voice-a"] - assert prompt_cache_id.startswith("voice-a:") + prompt_key = model._request_prompt_keys["voice-a"] + prompt = model._runtime_prompts[prompt_key] + prompt_cache_id, prompt_wav = prompt.cache_id, prompt.path + assert prompt_cache_id.startswith("runtime-ref-") assert Path(prompt_wav).is_file() - assert bytes(output.multimodal_outputs["meta.llm_output_text_utf8"][0].tolist()).decode() == "hello" + torch.testing.assert_close( + output.multimodal_outputs["meta.llm_output_text_utf8"][0], + segment_text_utf8, + ) assert output.multimodal_outputs["meta.duplex_turn_id"][0].item() == 7 assert output.multimodal_outputs["meta.duplex_epoch"][0].item() == 3 final = _info("voice-a", 1, [3, 4], last_chunk=True) final["meta"].pop("prompt_cache_id") + final["meta"]["tts_is_last_chunk"] = True output = _forward(model, [final]) assert output.multimodal_outputs["meta.tts_is_last_chunk"][0].item() is True - assert "voice-a" not in model._owned_prompt_wavs + assert model._request_prompt_keys["voice-a"] == prompt_key + model.on_requests_finished(["voice-a"]) + assert "voice-a" not in model._request_prompt_keys + assert prompt_key not in model._runtime_prompts assert not Path(prompt_wav).exists() assert (prompt_cache_id, prompt_wav) not in model.backend._prompt_features diff --git a/tests/model_executor/models/minicpmo_4_5/test_talker_batching.py b/tests/model_executor/models/minicpmo_4_5/test_talker_batching.py index 2e10041d5..975f2cdaa 100644 --- a/tests/model_executor/models/minicpmo_4_5/test_talker_batching.py +++ b/tests/model_executor/models/minicpmo_4_5/test_talker_batching.py @@ -14,6 +14,7 @@ from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni import ( from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni_tts import ( MiniCPMO45OmniTTSForConditionalGeneration, _max_audio_tokens, + _restore_weight_norm_weight, ) from vllm_omni.utils.mm_outputs import to_payload_element @@ -56,6 +57,8 @@ def _make_talker() -> MiniCPMO45OmniTTSForConditionalGeneration: talker._request_generators = {} talker._request_audio_states = {} talker._deferred_cleanup_ids = set() + talker._codec_min_tokens = 50 + talker._codec_seed = 42 return talker @@ -81,6 +84,23 @@ def test_audio_token_limit_scales_with_condition_length( assert _max_audio_tokens(condition_tokens) == expected +def test_weight_norm_restore_matches_checkpoint_parametrization_in_bfloat16() -> None: + generator = torch.Generator().manual_seed(42) + weight_v = torch.randn(8, 16, generator=generator, dtype=torch.bfloat16) + weight_g = torch.rand(8, 1, generator=generator, dtype=torch.bfloat16) + linear = nn.utils.parametrizations.weight_norm( + nn.Linear(16, 8, bias=False, dtype=torch.bfloat16), + dim=0, + ) + with torch.no_grad(): + linear.parametrizations.weight.original0.copy_(weight_g) + linear.parametrizations.weight.original1.copy_(weight_v) + + restored = _restore_weight_norm_weight(weight_g, weight_v) + + assert torch.equal(restored, linear.weight) + + def test_talker_emits_request_aligned_codec_deltas_after_compaction(mocker) -> None: talker = _make_talker() seen: list[tuple[str, list[float], list[int]]] = [] @@ -114,9 +134,73 @@ def test_talker_emits_request_aligned_codec_deltas_after_compaction(mocker) -> N assert _routed(output, 0)["codes"]["audio"].tolist() == [[2]] assert _routed(output, 1)["codes"]["audio"].tolist() == [[3]] assert _routed(output, 0)["meta"]["finished"].item() is False + assert set(output.multimodal_outputs["meta"]) == {"finished"} assert talker.compute_logits(output.text_hidden_states).argmax(dim=-1).tolist() == [0, 0] +def test_talker_projects_request_aligned_duplex_metadata(mocker) -> None: + talker = _make_talker() + mocker.patch.object(talker, "_sample_audio_code", return_value=torch.tensor(2)) + infos = [ + { + "request_id": "req-a", + "native_duplex": True, + "duplex": {"epoch": 3, "turn_id": 7}, + "ids": {"tts": [41]}, + "meta": { + "native_duplex_segment_text": "first", + "turn_eos_token_id": 99, + }, + "audio_codes": {"accumulated": torch.empty(0, dtype=torch.long)}, + }, + { + "request_id": "req-b", + "native_duplex": True, + "duplex": {"epoch": 4, "turn_id": 8}, + "ids": {"tts": [42, 99]}, + "meta": { + "native_duplex_segment_text": "second", + "turn_eos_token_id": 99, + }, + "audio_codes": {"accumulated": torch.empty(0, dtype=torch.long)}, + }, + ] + + output = talker.make_omni_output( + torch.ones(2, 2), + model_intermediate_buffer=infos, + request_token_spans=[(0, 1), (1, 2)], + ) + + meta = output.multimodal_outputs["meta"] + assert [value.item() for value in meta["native_duplex"]] == [True, True] + assert [value.item() for value in meta["duplex_epoch"]] == [3, 4] + assert [value.item() for value in meta["duplex_turn_id"]] == [7, 8] + assert "native_duplex_segment_text" not in meta + assert [bytes(value.tolist()).decode("utf-8") for value in meta["llm_output_text_utf8"]] == [ + "first", + "second", + ] + assert [value.item() for value in meta["turn_end"]] == [False, True] + + +def test_talker_rejects_native_duplex_without_fence_identity(mocker) -> None: + talker = _make_talker() + mocker.patch.object(talker, "_sample_audio_code", return_value=torch.tensor(2)) + info = { + "request_id": "req-missing-fence", + "native_duplex": True, + "audio_codes": {"accumulated": torch.empty(0, dtype=torch.long)}, + } + + with pytest.raises(RuntimeError, match="requires non-negative integer epoch and turn_id"): + talker.make_omni_output( + torch.ones(1, 2), + model_intermediate_buffer=[info], + request_token_spans=[(0, 1)], + ) + + def test_incomplete_prefill_emits_no_code_and_does_not_advance_state(mocker) -> None: talker = _make_talker() sample = mocker.patch.object(talker, "_sample_audio_code", return_value=torch.tensor(2)) @@ -179,7 +263,7 @@ def test_eos_is_terminal_once_and_never_enters_codec_history(mocker) -> None: assert talker.compute_logits(second.text_hidden_states).argmax(dim=-1).tolist() == [1] -def test_max_token_terminal_includes_only_new_codec_delta(mocker) -> None: +def test_max_token_terminal_drops_unconsumed_codec_delta(mocker) -> None: talker = _make_talker() mocker.patch.object(talker, "_sample_audio_code", return_value=torch.tensor(3)) info = { @@ -194,8 +278,10 @@ def test_max_token_terminal_includes_only_new_codec_delta(mocker) -> None: request_token_spans=[(0, 1)], ) - assert info["audio_codes"]["accumulated"].tolist() == [4, 5, 3] - assert output.multimodal_outputs["codes"]["audio"][0].tolist() == [[3]] + # MiniCPMTTS.generate_chunk samples once at the max-token boundary to + # advance RNG state, but the sampled code is not fed into KV or returned. + assert info["audio_codes"]["accumulated"].tolist() == [4, 5] + assert output.multimodal_outputs["codes"]["audio"][0].shape == (0, 1) assert output.multimodal_outputs["meta"]["finished"][0].item() is True assert talker.compute_logits(output.text_hidden_states).argmax(dim=-1).tolist() == [1] @@ -268,6 +354,94 @@ def test_empty_speech_segment_finishes_without_sampling_codes() -> None: assert decode_embeds.shape == (1, 4) +def test_chunked_prefill_tail_aligns_condition_with_prompt_length(mocker) -> None: + talker = _make_talker() + talker.emb_text = nn.Embedding(1, 2) + condition = torch.arange(18, dtype=torch.float32).reshape(9, 2) + mocker.patch.object(talker, "_build_condition_embeddings", return_value=condition) + + _, embeds, _ = talker.preprocess( + torch.zeros(9, dtype=torch.long), + None, + _omni_is_prefill=True, + _omni_num_computed_tokens=59, + _omni_prompt_len=68, + request_id="req-chunked-prefill", + tts_token_ids=torch.tensor([1]), + tts_hidden_states=torch.ones(1, 2), + ) + + assert torch.equal(embeds, condition) + state = talker._request_audio_states["req-chunked-prefill"] + assert state["min_tokens"] == 50 + assert state["max_tokens"] == 64 + + +@pytest.mark.parametrize( + ("meta", "expected_min_tokens"), + [ + ({"turn_start": True}, 0), + ({}, 26), + ({"turn_end": True}, 0), + ], +) +def test_native_duplex_prefill_uses_official_chunk_limits( + mocker, + meta, + expected_min_tokens, +) -> None: + talker = _make_talker() + talker.emb_text = nn.Embedding(1, 2) + mocker.patch.object( + talker, + "_build_condition_embeddings", + return_value=torch.ones(3, 2), + ) + + talker.preprocess( + torch.zeros(3, dtype=torch.long), + None, + _omni_is_prefill=True, + request_id="req-duplex-chunk", + native_duplex=True, + meta=meta, + tts_token_ids=torch.tensor([1]), + tts_hidden_states=torch.ones(1, 2), + ) + + state = talker._request_audio_states["req-duplex-chunk"] + assert state["min_tokens"] == expected_min_tokens + assert state["max_tokens"] == 26 + + +def test_native_duplex_condition_matches_official_text_plus_audio_bos() -> None: + talker = _make_talker() + talker.emb_text = nn.Embedding(16, 2) + talker.projector_semantic = nn.Identity() + talker._normalize = False + talker._text_eos_id = 14 + talker._tts_bos_id = 15 + with torch.no_grad(): + talker.emb_text.weight.copy_(torch.arange(32, dtype=torch.float32).reshape(16, 2)) + + token_ids = torch.tensor([2, 3]) + hidden_states = torch.tensor([[0.5, 1.0], [1.5, 2.0]]) + + condition = talker._build_condition_embeddings( + token_ids, + hidden_states, + native_duplex=True, + ) + + expected_text = talker.emb_text(token_ids) + hidden_states + expected = torch.cat( + [expected_text, talker.emb_text(torch.tensor([talker._tts_bos_id]))], + dim=0, + ) + assert torch.equal(condition, expected) + assert condition.shape[0] == token_ids.shape[0] + 1 + + def test_request_cleanup_evicts_ar_rng_and_decode_state() -> None: talker = _make_talker() talker._request_generators["req-done"] = torch.Generator() diff --git a/tests/model_executor/stage_input_processors/test_minicpmo_4_5_async_chunk.py b/tests/model_executor/stage_input_processors/test_minicpmo_4_5_async_chunk.py index 25124ed6f..f0da872b9 100644 --- a/tests/model_executor/stage_input_processors/test_minicpmo_4_5_async_chunk.py +++ b/tests/model_executor/stage_input_processors/test_minicpmo_4_5_async_chunk.py @@ -43,6 +43,27 @@ def _delta(*codes: int): } +def _duplex_delta( + *codes: int, + epoch: int = 3, + turn_id: int = 7, + text: str = "segment", + turn_end: bool = False, +): + text_utf8 = torch.tensor(list(text.encode("utf-8")), dtype=torch.uint8) + return { + "codes": {"audio": torch.tensor(codes, dtype=torch.long).reshape(-1, 1)}, + "meta": { + "finished": torch.tensor(False), + "native_duplex": torch.tensor(True), + "duplex_epoch": torch.tensor(epoch), + "duplex_turn_id": torch.tensor(turn_id), + "llm_output_text_utf8": text_utf8, + "turn_end": torch.tensor(turn_end), + }, + } + + def _codes(payload) -> list[int]: assert payload.codes is not None assert isinstance(payload.codes.audio, torch.Tensor) @@ -108,29 +129,57 @@ def test_short_final_flushes_silence_prefix_and_tail() -> None: assert final.meta.finished.item() is True +def test_duplex_turn_end_waits_for_terminal_codec_flush() -> None: + manager = _manager() + request = _request("req-duplex") + + body = tts2code2wav_async_chunk( + manager, + _duplex_delta(*range(25), turn_end=True), + request, + False, + ) + final = tts2code2wav_async_chunk( + manager, + _duplex_delta(turn_end=True), + request, + True, + ) + + assert body is not None + assert body.meta.last_chunk is False + assert body.meta.turn_end is False + assert final is not None + assert _codes(final) == [22, 23, 24] + assert final.meta.last_chunk is True + assert final.meta.turn_end is True + + def test_first_chunk_forwards_reference_voice_and_duplex_identity() -> None: manager = _manager() request = _request("req") request.additional_information = { "codes": {"ref": [0.1, -0.1]}, - "meta": { - "ref_audio_sr": 16000, - "native_duplex_segment_text": "hello", - "segment_end": True, - "turn_end": True, - }, - "duplex": {"epoch": 3, "model_turn_id": 7}, + "meta": {"ref_audio_sr": 16000}, } - payload = tts2code2wav_async_chunk(manager, _delta(*range(7)), request, True) + payload = tts2code2wav_async_chunk( + manager, + _duplex_delta(*range(7), text="hello", turn_end=True), + request, + True, + ) assert payload is not None assert payload.codes.ref.tolist() == pytest.approx([0.1, -0.1]) assert payload.meta.ref_audio_sr == 16000 - assert payload.meta.native_duplex_segment_text == "hello" + torch.testing.assert_close( + payload.meta.llm_output_text_utf8, + torch.tensor(list(b"hello"), dtype=torch.uint8), + ) assert payload.meta.duplex_epoch == 3 assert payload.meta.duplex_turn_id == 7 - assert payload.meta.segment_end is True + assert payload.meta.tts_is_last_chunk is True assert payload.meta.turn_end is True @@ -211,6 +260,186 @@ def test_empty_final_releases_wait_gate_once() -> None: assert duplicate is None +def test_empty_duplex_boundary_uses_zero_length_transport_placeholder() -> None: + manager = _manager() + + boundary = tts2code2wav_async_chunk( + manager, + _duplex_delta(text="boundary"), + _request("req-duplex"), + True, + ) + + assert boundary is not None + assert _codes(boundary) == [0] + assert boundary.meta.code_flat_numel == 0 + assert boundary.meta.last_chunk is False + assert boundary.meta.is_segment_finished.item() is False + torch.testing.assert_close( + boundary.meta.llm_output_text_utf8, + torch.tensor(list(b"boundary"), dtype=torch.uint8), + ) + + +def test_duplex_segments_preserve_stream_state_without_closing_turn() -> None: + manager = _manager() + request = _request("req-duplex") + request.additional_information = { + "codes": {"ref": [0.1, 0.2, 0.3]}, + "meta": {"ref_audio_sr": 16000}, + } + + first = tts2code2wav_async_chunk( + manager, + _duplex_delta(10, 11, text="first"), + request, + True, + ) + second = tts2code2wav_async_chunk( + manager, + _duplex_delta(12, 13, text="second"), + request, + True, + ) + + assert first is not None + assert second is not None + assert first.meta.last_chunk is False + assert second.meta.last_chunk is False + assert first.codes is not None + assert torch.allclose(first.codes.ref, torch.tensor([0.1, 0.2, 0.3])) + assert first.meta.ref_audio_sr == 16000 + assert second.codes is not None + assert second.codes.ref is None + assert second.meta.cache_epoch == first.meta.cache_epoch + assert second.meta.chunk_seq == first.meta.chunk_seq + 1 + assert second.meta.duplex_epoch == 3 + assert second.meta.duplex_turn_id == 7 + torch.testing.assert_close( + second.meta.llm_output_text_utf8, + torch.tensor(list(b"second"), dtype=torch.uint8), + ) + assert second.meta.tts_is_last_chunk is True + assert second.meta.turn_end is False + assert first.meta.is_segment_finished.item() is False + assert second.meta.is_segment_finished.item() is False + + +def test_duplex_short_units_wait_for_minimum_stream_body() -> None: + manager = _manager() + request = _request("req-duplex") + + first = tts2code2wav_async_chunk( + manager, + _duplex_delta(10, 11, 12, text="first"), + request, + True, + ) + second = tts2code2wav_async_chunk( + manager, + _duplex_delta(13, 14, text="first"), + request, + True, + ) + + assert first is not None + assert _codes(first) == [0] + assert first.meta.code_flat_numel == 0 + assert first.meta.last_chunk is False + assert first.meta.tts_is_last_chunk is True + assert second is not None + assert _codes(second) == [4218, 4218, 4218, 10, 11, 12, 13, 14] + assert second.meta.code_flat_numel == 8 + torch.testing.assert_close( + second.meta.llm_output_text_utf8, + torch.tensor(list(b"firstfirst"), dtype=torch.uint8), + ) + + +def test_duplex_empty_finish_callback_does_not_replay_previous_text() -> None: + manager = _manager() + request = _request("req-duplex") + + first = tts2code2wav_async_chunk( + manager, + _duplex_delta(*range(25), text="first"), + request, + False, + ) + boundary = tts2code2wav_async_chunk( + manager, + _duplex_delta(text="first"), + request, + True, + ) + second = tts2code2wav_async_chunk( + manager, + _duplex_delta(*range(25, 50), text="second"), + request, + False, + ) + + assert first is not None + assert boundary is not None + assert boundary.meta.code_flat_numel == 0 + assert second is not None + torch.testing.assert_close( + second.meta.llm_output_text_utf8, + torch.tensor(list(b"second"), dtype=torch.uint8), + ) + + +def test_duplex_short_tail_does_not_replay_previous_segment_text() -> None: + manager = _manager() + request = _request("req-duplex") + + def chunk(codes, text: str, finished: bool): + return tts2code2wav_async_chunk( + manager, + _duplex_delta(*codes, text=text), + request, + finished, + ) + + first = chunk(range(25), "和上海之间", False) + first_tail = chunk([25, 26], "和上海之间", True) + assert first is not None + assert first.meta.llm_output_text_utf8.tolist() == list("和上海之间".encode()) + assert first_tail is not None + assert first_tail.meta.code_flat_numel == 0 + assert chunk([27, 28, 29], "的距离大约是", False) is None + next_flush = chunk([], "的距离大约是", True) + assert next_flush is not None + assert next_flush.meta.llm_output_text_utf8.tolist() == list("的距离大约是".encode()) + + +def test_duplex_turn_end_closes_epoch_and_next_turn_restarts_sequence() -> None: + manager = _manager() + request = _request("req-duplex") + + turn_end = tts2code2wav_async_chunk( + manager, + _duplex_delta(14, turn_id=7, turn_end=True), + request, + True, + ) + next_turn = tts2code2wav_async_chunk( + manager, + _duplex_delta(20, turn_id=8), + request, + True, + ) + + assert turn_end is not None + assert next_turn is not None + assert turn_end.meta.last_chunk is True + assert turn_end.meta.turn_end is True + assert turn_end.meta.is_segment_finished.item() is True + assert next_turn.meta.cache_epoch == turn_end.meta.cache_epoch + 1 + assert next_turn.meta.chunk_seq == 0 + assert next_turn.meta.last_chunk is False + + def test_staggered_requests_keep_accumulators_isolated() -> None: manager = _manager() req_a = _request("a") diff --git a/tests/model_executor/stage_input_processors/test_minicpmo_4_5_omni.py b/tests/model_executor/stage_input_processors/test_minicpmo_4_5_omni.py index c8381d881..652215a08 100644 --- a/tests/model_executor/stage_input_processors/test_minicpmo_4_5_omni.py +++ b/tests/model_executor/stage_input_processors/test_minicpmo_4_5_omni.py @@ -128,13 +128,125 @@ def test_native_duplex_speak_segment_reaches_split_talker() -> None: info = converted["model_intermediate_buffer"] assert info["native_duplex"] is True assert info["ids"]["tts"] == [21, 22] - assert converted["prompt_token_ids"] == [0, 0, 0, 0] + assert converted["prompt_token_ids"] == [0, 0, 0] assert info["meta"]["replace_streaming_prompt"] is True + assert info["meta"]["next_stage_prompt_len"] == 3 + assert info["meta"]["turn_start"] is True assert info["meta"]["segment_end"] is True assert info["duplex"]["epoch"] == 3 assert info["duplex"]["turn_id"] == 7 +def test_native_duplex_continuation_appends_only_new_talker_condition() -> None: + prompt_ids = [101, 102] + token_ids = { + "tts_bos_token_id": 9301, + "tts_eos_token_id": 9302, + "listen_token_id": 9303, + "speak_token_id": 9304, + "chunk_eos_token_id": 9308, + "chunk_tts_eos_token_id": 9309, + "turn_eos_token_id": 9310, + } + context = SimpleNamespace( + bridge_states={ + "duplex": { + "epoch": 3, + "model_turn_id": 7, + } + } + ) + + first_ids = [9304, 21, 22, 9308] + first = _output( + prompt_ids=prompt_ids, + output_ids=first_ids, + latent=torch.arange(24, dtype=torch.float32).reshape(6, 4), + multimodal_output={ + "duplex_prompt_token_ids": prompt_ids, + "meta": token_ids, + }, + ) + second_ids = [*first_ids, 9304, 23, 24, 9308] + second = _output( + prompt_ids=prompt_ids, + output_ids=second_ids, + latent=torch.arange(40, dtype=torch.float32).reshape(10, 4), + multimodal_output={ + "duplex_prompt_token_ids": prompt_ids, + "meta": token_ids, + }, + ) + third_ids = [*second_ids, 9304, 25, 26, 9308] + third = _output( + prompt_ids=prompt_ids, + output_ids=third_ids, + latent=torch.arange(56, dtype=torch.float32).reshape(14, 4), + multimodal_output={ + "duplex_prompt_token_ids": prompt_ids, + "meta": token_ids, + }, + ) + + first_input = llm2tts([first], prompt=[{}], _streaming_context=context)[0] + second_input = llm2tts([second], prompt=[{}], _streaming_context=context)[0] + context.bridge_states["duplex"]["model_turn_id"] = 8 + third_input = llm2tts([third], prompt=[{}], _streaming_context=context)[0] + + assert first_input["model_intermediate_buffer"]["ids"]["tts"] == [21, 22] + assert second_input["model_intermediate_buffer"]["ids"]["tts"] == [23, 24] + assert third_input["model_intermediate_buffer"]["ids"]["tts"] == [25, 26] + assert first_input["model_intermediate_buffer"]["meta"]["turn_start"] is True + assert second_input["model_intermediate_buffer"]["meta"]["turn_start"] is False + assert third_input["model_intermediate_buffer"]["meta"]["turn_start"] is True + assert first_input["model_intermediate_buffer"]["meta"]["replace_streaming_prompt"] is True + assert "replace_streaming_prompt" not in second_input["model_intermediate_buffer"]["meta"] + assert third_input["model_intermediate_buffer"]["meta"]["replace_streaming_prompt"] is True + assert second_input["model_intermediate_buffer"]["meta"]["next_stage_prompt_len"] == 3 + assert second_input["prompt_token_ids"] == [0, 0, 0] + + +def test_native_duplex_transcript_decodes_the_talker_condition_slice() -> None: + prompt_ids = [101, 102] + metadata = { + "tts_bos_token_id": 9301, + "tts_eos_token_id": 9302, + "listen_token_id": 9303, + "speak_token_id": 9304, + "chunk_eos_token_id": 9308, + "chunk_tts_eos_token_id": 9309, + "turn_eos_token_id": 9310, + } + token_text = {21: "杭州", 22: "和", 23: "州和", 24: "上海之间", 25: "大"} + + def output(token_ids: list[int]): + return _output( + prompt_ids=prompt_ids, + output_ids=token_ids, + latent=torch.zeros((len(prompt_ids) + len(token_ids), 1)), + multimodal_output={ + "duplex_prompt_token_ids": prompt_ids, + "meta": metadata, + }, + ) + + context = SimpleNamespace( + bridge_states={"duplex": {"epoch": 3, "model_turn_id": 7}}, + source_token_decoder=lambda ids, **_: "".join(token_text.get(int(token_id), "") for token_id in ids), + ) + first_ids = [9304, 21, 22, 9308] + first_info = llm2tts([output(first_ids)], prompt=[{}], _streaming_context=context)[0]["model_intermediate_buffer"] + second_info = llm2tts( + [output([*first_ids, 23, 24, 25, 9308])], + prompt=[{}], + _streaming_context=context, + )[0]["model_intermediate_buffer"] + + assert first_info["meta"]["native_duplex_segment_text"] == "杭州和" + assert second_info["ids"]["tts"] == [24, 25] + assert second_info["meta"]["native_duplex_segment_text"] == "上海之间大" + + def test_native_duplex_requires_tokenizer_boundary_metadata() -> None: latent = torch.zeros((3, 4)) source = _output( diff --git a/tests/model_executor/stage_input_processors/test_qwen3_omni_streaming_helpers.py b/tests/model_executor/stage_input_processors/test_qwen3_omni_streaming_helpers.py index 8a6dfbe94..96a938513 100644 --- a/tests/model_executor/stage_input_processors/test_qwen3_omni_streaming_helpers.py +++ b/tests/model_executor/stage_input_processors/test_qwen3_omni_streaming_helpers.py @@ -4,6 +4,7 @@ from __future__ import annotations +from collections import defaultdict from types import SimpleNamespace import pytest @@ -227,6 +228,47 @@ def test_talker2code2wav_full_payload_keeps_all_zero_codec_rows() -> None: assert "code_predictor_codes" not in payload +def test_talker2code2wav_async_chunk_flushes_cached_tail_on_stop_token() -> None: + request_id = "codec_tail" + stop_token_id = 999 + transfer_manager = SimpleNamespace( + code_prompt_token_ids=defaultdict(list), + put_req_chunk=defaultdict(int, {request_id: 2}), + connector=SimpleNamespace( + config={ + "extra": { + "initial_codec_chunk_frames": 4, + "codec_chunk_frames": 25, + "codec_left_context_frames": 25, + } + } + ), + ) + transfer_manager.code_prompt_token_ids[request_id] = [ + torch.tensor([[frame, frame + 100]], dtype=torch.long) for frame in range(50) + ] + request = SimpleNamespace( + external_req_id=request_id, + sampling_params=SimpleNamespace( + stop_token_ids=[stop_token_id], + stop_token_id=None, + ), + ) + + payload = q3.talker2code2wav_async_chunk( + transfer_manager, + {"codes": {"audio": torch.tensor([[stop_token_id, 0]], dtype=torch.long)}}, + request, + is_finished=True, + ) + + assert payload is not None + assert payload.meta.finished.item() is True + # 4 initial frames and one 25-frame chunk were already emitted. The final + # payload contains 25 frames of left context plus the remaining 21 frames. + assert payload.codes.audio.numel() == (25 + 21) * 2 + + def test_thinker2talker_full_payload_packs_complete_tensors() -> None: """Full-payload path drops the terminal thinker row before talker prefill.""" request = SimpleNamespace( diff --git a/tests/test_config_factory.py b/tests/test_config_factory.py index aa3ab604e..61d870e98 100644 --- a/tests/test_config_factory.py +++ b/tests/test_config_factory.py @@ -1160,8 +1160,10 @@ class TestDeployConfigLoading: assert [stage.to_omegaconf().session_mode for stage in stages] == ["duplex", "duplex", "duplex"] assert [stage.yaml_engine_args["async_scheduling"] for stage in stages] == [False, False, False] assert all("Async" not in (stage.scheduler_cls or "") for stage in stages) + assert [stage.devices for stage in deploy.stages] == ["0", "0", "0"] assert deploy.stages[1].enforce_eager is False assert stages[1].yaml_extras["default_sampling_params"]["max_tokens"] == 2048 + assert stages[1].yaml_extras["default_sampling_params"]["min_tokens"] == 0 assert stages[1].yaml_extras["default_sampling_params"]["stop_token_ids"] == [1] @pytest.mark.parametrize( diff --git a/tests/worker/test_native_duplex_hooks.py b/tests/worker/test_native_duplex_hooks.py index 661656b7e..383379202 100644 --- a/tests/worker/test_native_duplex_hooks.py +++ b/tests/worker/test_native_duplex_hooks.py @@ -276,16 +276,16 @@ def test_minicpmo_model_hook_same_speech_row_second_step_does_not_rearm_pending_ next_logits[0, 7] = 30.0 next_logits[0, 8] = -2.0 next_logits[0, 10] = 20.0 + original_next_logits = next_logits.clone() model.prepare_duplex_sampling(next_logits, SimpleNamespace(), (row,)) assert state.pending_speech_context is False assert state.current_turn_ended is False - assert torch.isneginf(next_logits[0, 7]) - assert next_logits[0, 8].item() == 30.0 + assert torch.equal(next_logits, original_next_logits) -def test_minicpmo_model_hook_mid_turn_speech_redirects_listen_to_tts_bos(): +def test_minicpmo_model_hook_mid_turn_speech_preserves_logits_for_post_sample_redirect(): state = SimpleNamespace( current_turn_ended=False, last_terminator_token=None, @@ -296,12 +296,11 @@ def test_minicpmo_model_hook_mid_turn_speech_redirects_listen_to_tts_bos(): logits[0, 7] = 30.0 logits[0, 8] = -2.0 logits[0, 10] = 20.0 + original_logits = logits.clone() model.prepare_duplex_sampling(logits, SimpleNamespace(), (row,)) - assert torch.isneginf(logits[0, 7]) - assert logits[0, 8].item() == 30.0 - assert logits[0, 10].item() == 20.0 + assert torch.equal(logits, original_logits) def test_minicpmo_model_hook_ignores_serving_new_user_turn_marker(): @@ -317,12 +316,12 @@ def test_minicpmo_model_hook_ignores_serving_new_user_turn_marker(): logits = torch.zeros((1, 16), dtype=torch.float32) logits[0, 7] = 5.0 logits[0, 10] = 20.0 + original_logits = logits.clone() model.prepare_duplex_sampling(logits, SimpleNamespace(), (row,)) assert state.current_turn_ended is False assert state.last_terminator_token is None - assert torch.isneginf(logits[0, 7]) - assert logits[0, 8].item() == 5.0 + assert torch.equal(logits, original_logits) def test_generic_ar_runner_builds_typed_duplex_sampling_rows(): @@ -613,6 +612,180 @@ def test_minicpmo_stage0_rejects_unknown_special_token_fallbacks(): runtime._require_special_token_ids() +def _minicpmo45_tokenizer_stub(): + token_ids = { + token: index + for index, token in enumerate( + ( + "", + "", + "<|listen|>", + "<|speak|>", + "<|tts_bos|>", + "<|tts_eos|>", + "<|tts_pad|>", + "<|chunk_eos|>", + "<|chunk_tts_eos|>", + "<|turn_eos|>", + ), + start=101, + ) + } + return SimpleNamespace( + unk_token_id=0, + convert_tokens_to_ids=lambda token: token_ids.get(token, 0), + encode=lambda token, add_special_tokens=False: ( + [token_ids[token]] if not add_special_tokens and token in token_ids else [0] + ), + ) + + +def _transformers_processor_stub(auto_processor): + class _AutoImageProcessor: + @staticmethod + def register(*_args, **_kwargs): + return None + + return SimpleNamespace( + AutoImageProcessor=_AutoImageProcessor, + AutoProcessor=auto_processor, + ) + + +def test_minicpmo_stage0_loads_processor_from_hf_id(monkeypatch): + import sys + + from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import ( + MiniCPMO45Stage0DuplexRuntime, + ) + + model_id = "openbmb/MiniCPM-o-4_5" + processor = SimpleNamespace(tokenizer=_minicpmo45_tokenizer_stub()) + load_calls = [] + + class _AutoImageProcessor: + @staticmethod + def register(config_class, image_processor_class): + del image_processor_class + if isinstance(config_class, str): + raise AttributeError("'str' object has no attribute '__module__'") + + class _AutoProcessor: + @classmethod + def from_pretrained(cls, model_path, *, trust_remote_code): + load_calls.append((model_path, trust_remote_code)) + _AutoImageProcessor.register("MiniCPMVImageProcessor", object) + return processor + + monkeypatch.setitem( + sys.modules, + "transformers", + SimpleNamespace( + AutoImageProcessor=_AutoImageProcessor, + AutoProcessor=_AutoProcessor, + ), + ) + + loaded = MiniCPMO45Stage0DuplexRuntime._load_processor_from_path(model_id) + + assert loaded is processor + assert load_calls == [(model_id, True)] + with pytest.raises(AttributeError, match="__module__"): + _AutoImageProcessor.register("MiniCPMVImageProcessor", object) + + +def test_minicpmo_stage0_processor_load_preserves_original_exception(monkeypatch): + import sys + + from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import ( + MiniCPMO45Stage0DuplexRuntime, + ) + + original = ValueError("invalid processor config") + + class _AutoProcessor: + @classmethod + def from_pretrained(cls, model_path, *, trust_remote_code): + del cls, model_path, trust_remote_code + raise original + + monkeypatch.setitem( + sys.modules, + "transformers", + _transformers_processor_stub(_AutoProcessor), + ) + + with pytest.raises(RuntimeError, match="Failed to load MiniCPM-o duplex processor") as exc_info: + MiniCPMO45Stage0DuplexRuntime._load_processor_from_path("openbmb/MiniCPM-o-4_5") + + assert exc_info.value.__cause__ is original + + +def test_minicpmo_stage0_processor_requires_tokenizer(monkeypatch): + import sys + + from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import ( + MiniCPMO45Stage0DuplexRuntime, + ) + + class _AutoProcessor: + @classmethod + def from_pretrained(cls, model_path, *, trust_remote_code): + del cls, model_path, trust_remote_code + return object() + + monkeypatch.setitem( + sys.modules, + "transformers", + _transformers_processor_stub(_AutoProcessor), + ) + + with pytest.raises(RuntimeError, match="does not expose a tokenizer"): + MiniCPMO45Stage0DuplexRuntime._load_processor_from_path("openbmb/MiniCPM-o-4_5") + + +def test_minicpmo_stage0_loaded_processor_validates_special_tokens(monkeypatch): + import sys + + from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import ( + MiniCPMO45Stage0DuplexRuntime, + ) + + processor = SimpleNamespace(tokenizer=_minicpmo45_tokenizer_stub()) + + class _AutoProcessor: + @classmethod + def from_pretrained(cls, model_path, *, trust_remote_code): + del cls, model_path, trust_remote_code + return processor + + monkeypatch.setitem( + sys.modules, + "transformers", + _transformers_processor_stub(_AutoProcessor), + ) + + runtime = MiniCPMO45Stage0DuplexRuntime( + SimpleNamespace(), + model_path="openbmb/MiniCPM-o-4_5", + device="cpu", + ) + + assert runtime.processor is processor + assert set(runtime._special_token_ids()) == { + "unit_token_id", + "unit_end_token_id", + "listen_token_id", + "speak_token_id", + "tts_bos_token_id", + "tts_eos_token_id", + "tts_pad_token_id", + "chunk_eos_token_id", + "chunk_tts_eos_token_id", + "turn_eos_token_id", + } + + def test_minicpmo_stage0_data_plane_prefill_matches_official_unit_format(): import torch @@ -1150,6 +1323,88 @@ def test_minicpmo_stage0_data_plane_final_first_chunk_does_not_add_silence_unit( ) +def test_minicpmo_stage0_final_does_not_promote_first_chunk_alignment_tail_to_unit(): + import torch + + from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import ( + MiniCPMO45Stage0DuplexRuntime, + _MiniCPMO45Stage0SessionState, + ) + + class _StageModel(torch.nn.Module): + first_chunk_ms = 10 + chunk_ms = 8 + sample_rate = 1000 + + def __init__(self): + super().__init__() + self.embed = torch.nn.Embedding(256, 2) + + def get_input_embeddings(self): + return self.embed + + def get_audio_hidden_states(self, _data): + return [torch.tensor([[0.5, 0.5]], dtype=torch.float32)] + + class _MelProcessor: + sample_rate = 1000 + is_first = True + + def get_config(self): + return {"effective_first_chunk_ms": 9} + + class _Processor: + def __init__(self): + self._streaming_mel_processor = _MelProcessor() + + def get_streaming_chunk_size(self): + return 9 if self._streaming_mel_processor.is_first else 8 + + def process_audio_streaming(self, audio, **_kwargs): + self._streaming_mel_processor.is_first = False + return {"audio_features": audio, "audio_feature_lens": [[len(audio)]]} + + runtime = MiniCPMO45Stage0DuplexRuntime.__new__(MiniCPMO45Stage0DuplexRuntime) + runtime.stage_model = _StageModel() + runtime.thinker = runtime.stage_model + runtime.tokenizer = SimpleNamespace( + unk_token_id=0, + convert_tokens_to_ids=lambda token: { + "": 1, + "": 2, + "<|listen|>": 3, + "<|speak|>": 4, + "<|tts_bos|>": 5, + "<|tts_eos|>": 6, + "<|tts_pad|>": 7, + "<|chunk_eos|>": 8, + "<|chunk_tts_eos|>": 9, + "<|turn_eos|>": 10, + "<|audio|>": 11, + }.get(token, 0), + encode=lambda text, add_special_tokens=False: [], + ) + runtime.processor = _Processor() + runtime.device = "cpu" + runtime._init_token_ids() + state = _MiniCPMO45Stage0SessionState(session_id="sid-first-chunk-alignment-tail") + + first = runtime._stage_prefill_embeddings_only(state, np.zeros(8, dtype=np.float32), seq=1) + second = runtime._stage_prefill_embeddings_only(state, np.zeros(8, dtype=np.float32), seq=2) + final = runtime._stage_prefill_embeddings_only( + state, + np.zeros(8, dtype=np.float32), + seq=3, + final=True, + ) + + assert first["input_token_ids"].count(1) == 1 + assert second["input_token_ids"].count(1) == 1 + assert final["input_token_ids"].count(1) == 1 + assert state.audio_chunk_idx == 3 + assert len(state.audio_buffer) == 1 + + def test_minicpmo_stage0_runtime_uses_loaded_vllm_embed_tokens_when_get_input_embeddings_is_broken(): import torch @@ -1319,7 +1574,7 @@ def test_minicpmo_stage0_native_sampler_penalizes_text_from_prior_chunk(): model.thinker = SimpleNamespace(get_tokenizer=lambda: _Tokenizer()) session_key = ("sid-cross-chunk-repetition", 0) state = _MiniCPMO45Stage0SessionState(session_id=session_key[0]) - state.generated_text_tokens = [198] * 8 + state.generated_tokens = [198] * 8 model._minicpmo45_duplex_data_plane_helper = SimpleNamespace(sessions={session_key: state}) model._minicpmo45_duplex_row_sessions = {0: session_key} @@ -1381,7 +1636,7 @@ def test_minicpmo_stage0_native_sampler_matches_official_negative_logit_penalty( state = _MiniCPMO45Stage0SessionState(session_id=session_key[0]) repeated = 198 alternative = 1234 - state.generated_text_tokens = [repeated] + state.generated_tokens = [repeated] model._minicpmo45_duplex_data_plane_helper = SimpleNamespace(sessions={session_key: state}) model._minicpmo45_duplex_row_sessions = {0: session_key} @@ -1405,7 +1660,7 @@ def test_minicpmo_stage0_native_sampler_matches_official_negative_logit_penalty( assert sampled.sampled_token_ids.tolist() == [[repeated]] -def test_minicpmo_stage0_records_only_bounded_text_repetition_history(): +def test_minicpmo_stage0_records_bounded_model_policy_history(): from vllm_omni.experimental.fullduplex.minicpmo45.stage0 import ( _MiniCPMO45Stage0SessionState, ) @@ -1432,15 +1687,15 @@ def test_minicpmo_stage0_records_only_bounded_text_repetition_history(): } for sampled in range(1000, 1520): - model._record_minicpmo45_duplex_terminator(0, sampled, token_ids) + model._record_minicpmo45_duplex_generation_token(0, sampled) expected_history = list(range(1008, 1520)) - assert state.generated_text_tokens == expected_history + assert state.generated_tokens == expected_history for sampled in token_ids.values(): - model._record_minicpmo45_duplex_terminator(0, sampled, token_ids) + model._record_minicpmo45_duplex_generation_token(0, sampled) - assert state.generated_text_tokens == expected_history + assert state.generated_tokens == [*expected_history[len(token_ids) :], *token_ids.values()] def test_minicpmo_stage0_native_sampler_does_not_override_model_at_punctuation(): diff --git a/vllm_omni/core/sched/omni_ar_scheduler.py b/vllm_omni/core/sched/omni_ar_scheduler.py index 3892bd389..e209ee0df 100644 --- a/vllm_omni/core/sched/omni_ar_scheduler.py +++ b/vllm_omni/core/sched/omni_ar_scheduler.py @@ -731,9 +731,17 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler): if self.log_stats: session.record_event(EngineCoreEventType.QUEUED) return - update_info = getattr(update, "additional_information", None) - update_meta = update_info.get("meta") if isinstance(update_info, dict) else None - if isinstance(update_meta, dict) and update_meta.get("replace_streaming_prompt") is True: + update_infos = ( + getattr(update, "model_intermediate_buffer", None), + getattr(update, "additional_information", None), + ) + replace_streaming_prompt = any( + isinstance(info, dict) + and isinstance(info.get("meta"), dict) + and info["meta"].get("replace_streaming_prompt") is True + for info in update_infos + ) + if replace_streaming_prompt: self._replace_streaming_session(session, update) return super()._update_request_as_session(session, update) diff --git a/vllm_omni/core/sched/omni_generation_scheduler.py b/vllm_omni/core/sched/omni_generation_scheduler.py index 534019513..07b06d382 100644 --- a/vllm_omni/core/sched/omni_generation_scheduler.py +++ b/vllm_omni/core/sched/omni_generation_scheduler.py @@ -54,6 +54,21 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler): ) self._latest_omni_connector_output: OmniConnectorOutput | None = None + def _handle_stopped_request(self, request: Request) -> bool: + if ( + request.resumable + and not request.streaming_queue + and self.chunk_transfer_adapter is not None + and self.chunk_transfer_adapter.receives_chunks + ): + # Downstream async-chunk stages receive the next segment from the + # connector, not from an API StreamingUpdate. Enqueue them as + # schedulable before the base class can park them in skipped_waiting. + request.status = RequestStatus.WAITING + self._enqueue_waiting_request(request) + return False + return super()._handle_stopped_request(request) + def schedule(self, throttle_prefills: bool = False) -> SchedulerOutput: """One-shot generation fast path: - Feed all input tokens of the request at once diff --git a/vllm_omni/core/sched/omni_scheduler_mixin.py b/vllm_omni/core/sched/omni_scheduler_mixin.py index 3df47a357..684b1f260 100644 --- a/vllm_omni/core/sched/omni_scheduler_mixin.py +++ b/vllm_omni/core/sched/omni_scheduler_mixin.py @@ -63,6 +63,11 @@ class OmniSchedulerMixin: session.num_computed_tokens = 0 session.prompt_token_ids = new_prompt session.additional_information = update.additional_information or None + session.model_intermediate_buffer = getattr( + update, + "model_intermediate_buffer", + None, + ) session.update_block_hashes() session.num_prompt_tokens = len(new_prompt) session.arrival_time = update.arrival_time diff --git a/vllm_omni/deploy/minicpmo_4_5_duplex.yaml b/vllm_omni/deploy/minicpmo_4_5_duplex.yaml index e58c515c7..d8ae8aebe 100644 --- a/vllm_omni/deploy/minicpmo_4_5_duplex.yaml +++ b/vllm_omni/deploy/minicpmo_4_5_duplex.yaml @@ -3,7 +3,7 @@ # The registered minicpmo_4_5 pipeline owns the fixed # Thinker -> Talker -> Code2Wav topology. This overlay changes session # lifetime and bounds all three stages to two live duplex sessions. -base_config: minicpmo_4_5_batching.yaml +base_config: minicpmo_4_5.yaml pipeline: minicpmo_4_5 session_mode: duplex active_stream_window: 1 @@ -31,6 +31,7 @@ stages: # segment and turn boundaries travel as explicit output metadata. default_sampling_params: max_tokens: 2048 + min_tokens: 0 stop_token_ids: [1] - stage_id: 2 diff --git a/vllm_omni/distributed/omni_connectors/adapter.py b/vllm_omni/distributed/omni_connectors/adapter.py index c124e8e43..f1e224b15 100644 --- a/vllm_omni/distributed/omni_connectors/adapter.py +++ b/vllm_omni/distributed/omni_connectors/adapter.py @@ -231,18 +231,18 @@ def construct_next_stage_streaming_input_prompt(payload_data: dict[str, Any], re ids = payload_data.get("ids", {}) meta = payload_data.get("meta", {}) replace_prompt = isinstance(meta, dict) and meta.get("replace_streaming_prompt") is True - condition_len = meta.get("next_stage_prompt_len") if replace_prompt else None - if isinstance(condition_len, int) and condition_len > 0: + next_stage_prompt_len = meta.get("next_stage_prompt_len") if isinstance(meta, dict) else None + if replace_prompt and isinstance(next_stage_prompt_len, int) and next_stage_prompt_len > 0: # Some downstream stages consume complete, independently conditioned # segments instead of extending an existing KV prefix. The producer # declares that transport behavior explicitly in payload metadata. - new_prompt = [0] * condition_len + new_prompt = [0] * next_stage_prompt_len request._output_token_ids.clear() request._all_token_ids.clear() request._all_token_ids.extend(new_prompt) request.prompt_token_ids = new_prompt request.num_computed_tokens = 0 - request.num_prompt_tokens = condition_len + request.num_prompt_tokens = next_stage_prompt_len request.update_block_hashes() return prompt_token_ids = ids.get("prompt", None) @@ -255,7 +255,10 @@ def construct_next_stage_streaming_input_prompt(payload_data: dict[str, Any], re assert request.prompt_token_ids is not None # Extend prompt with kept output tokens. request.prompt_token_ids.extend(kept_output_tokens) - next_prompt_len = max(1, compute_talker_prompt_ids_length(prompt_token_ids)) + if isinstance(next_stage_prompt_len, int) and next_stage_prompt_len > 0: + next_prompt_len = next_stage_prompt_len + else: + next_prompt_len = max(1, compute_talker_prompt_ids_length(prompt_token_ids)) new_prompt = [0] * next_prompt_len request._all_token_ids.extend(new_prompt or ()) request.prompt_token_ids.extend(new_prompt or ()) diff --git a/vllm_omni/distributed/omni_connectors/transfer_adapter/chunk_transfer_adapter.py b/vllm_omni/distributed/omni_connectors/transfer_adapter/chunk_transfer_adapter.py index 1a41372a8..f2da845a3 100644 --- a/vllm_omni/distributed/omni_connectors/transfer_adapter/chunk_transfer_adapter.py +++ b/vllm_omni/distributed/omni_connectors/transfer_adapter/chunk_transfer_adapter.py @@ -283,7 +283,11 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase): # Empty chunk with more data expected: keep polling. has_new_ids = bool(new_ids.numel()) if use_tensor_codes else bool(new_ids) - if not has_new_ids and not payload_finished: + if not has_new_ids and payload_segment_finished: + # Preserve an explicit scheduler boundary even when it + # contains no new codec frames. + request.prompt_token_ids = [0] + if not has_new_ids and not payload_finished and not payload_segment_finished: # The base recv loop treats False as "not ready yet" and # requeues the request. Do not mark an empty non-terminal # chunk as ready, otherwise Stage1 can consume before the @@ -317,7 +321,13 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase): multimodal_output=multimodal_output, request=request, # Existing processors use is_finished as a flush signal. - is_finished=is_segment_finished, + # Terminal stops no longer count as segment boundaries + # (is_segment_finished is False when the request finishes, + # see #5383), but the processor must still flush its + # accumulated tail on the terminal chunk — otherwise the + # downstream stage receives the finished marker without + # the final payload (#5413). + is_finished=is_segment_finished or is_finished, ) except Exception as e: @@ -332,7 +342,8 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase): if payload_data.meta is None: payload_data.meta = MetaStruct() payload_data.meta.finished = torch.tensor(is_finished, dtype=torch.bool) - payload_data.meta.is_segment_finished = torch.tensor(is_segment_finished, dtype=torch.bool) + if payload_data.meta.is_segment_finished is None: + payload_data.meta.is_segment_finished = torch.tensor(is_segment_finished, dtype=torch.bool) success, size, metadata = self.connector.put( from_stage=str(stage_id), @@ -725,6 +736,14 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase): queue_snapshot = list(queue) for request in queue_snapshot: if not self._ensure_active_stream(request): + if target_status == RequestStatus.WAITING: + # A non-active placeholder must not remain visible to the + # scheduler: it has no connector payload yet, so running + # it would execute the downstream model with empty + # additional_information. Park it until restore_queues() + # and retry admission on the next scheduler tick. + queue.remove(request) + waiting_for_chunk_list.append(request) continue if request.status != RequestStatus.WAITING_FOR_CHUNK: if request.request_id in self.requests_with_ready_chunks: diff --git a/vllm_omni/engine/async_omni_engine.py b/vllm_omni/engine/async_omni_engine.py index 162f3657f..cdf7890b1 100644 --- a/vllm_omni/engine/async_omni_engine.py +++ b/vllm_omni/engine/async_omni_engine.py @@ -209,10 +209,10 @@ class AsyncOmniEngine: self._omni_master_port, ) - # Stage resolution pops deploy_config, so get the pipeline endpoint - # restriction beforehand. TODO (Alex) make this cleaner and refactor - # stage config resolution to remove kwargs hacks. - deploy_config_path = kwargs.get("deploy_config") + # Stage resolution pops deploy_config, so get pipeline-wide settings + # beforehand. The stage CLI exposes the same deploy YAML through + # stage_configs_path. + deploy_config_path = kwargs.get("deploy_config") or kwargs.get("stage_configs_path") pipeline_config = StageConfigFactory.get_pipeline_config( model=model, trust_remote_code=trust_remote_code, diff --git a/vllm_omni/engine/orchestrator.py b/vllm_omni/engine/orchestrator.py index affd0e452..53f30ea48 100644 --- a/vllm_omni/engine/orchestrator.py +++ b/vllm_omni/engine/orchestrator.py @@ -13,7 +13,7 @@ from __future__ import annotations import asyncio import time as _time -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any @@ -218,11 +218,18 @@ class _OrchestratorDuplexStagePort: request_states: dict[str, OrchestratorRequestState], running_counter: OmniRequestCounter | None, cleanup_request_ids: Callable[..., Any], + async_chunk: bool, + prewarm_async_chunk_stages: Callable[ + [str, Any, OrchestratorRequestState], + Awaitable[None], + ], ) -> None: self._stage_pools = stage_pools self._request_states = request_states self._running_counter = running_counter self._cleanup_request_ids = cleanup_request_ids + self._async_chunk = async_chunk + self._prewarm_async_chunk_stages = prewarm_async_chunk_stages @property def stage_count(self) -> int: @@ -299,6 +306,12 @@ class _OrchestratorDuplexStagePort: replica_id = await pool.submit_update(context.request_id, request_state, request) else: replica_id = await pool.submit_initial(context.request_id, request_state, request, prompt_text=None) + if self._async_chunk and context.stage_id == 0: + await self._prewarm_async_chunk_stages( + context.request_id, + request, + request_state, + ) request_state.duplex_stage_fences[context.stage_id] = context.fence request_state.stage_submit_ts[context.stage_id] = _time.time() if not request_state.running_counter_registered and self._running_counter is not None: @@ -388,6 +401,8 @@ class Orchestrator: request_states=self.request_states, running_counter=self._running_counter, cleanup_request_ids=self._cleanup_request_ids, + async_chunk=self.async_chunk, + prewarm_async_chunk_stages=self._prewarm_async_chunk_stages, ), result_sink=self.rpc_async_queue, lifecycle_sink=self.output_async_queue, @@ -681,24 +696,16 @@ class Orchestrator: final_stage_id = msg.final_stage_id req_state = self.request_states.get(request_id) if req_state is None: + # Streaming updates always follow the first-chunk add_request + # through the same ordered submission queue, so an unknown id + # here can only mean the request already finished or was + # aborted (e.g. the client disconnected). Re-adding it would + # resurrect a headless session that keeps cycling through the + # stages with nobody consuming its outputs (issue #4271). logger.warning( - "[Orchestrator] streaming_update for unknown req=%s, falling back to add_request", + "[Orchestrator] streaming_update for unknown req=%s; dropping (request finished or aborted)", request_id, ) - fallback_msg = StageSubmissionMessage( - type="add_request", - request_id=msg.request_id, - prompt=msg.prompt, - original_prompt=msg.original_prompt, - output_prompt_text=msg.output_prompt_text, - sampling_params_list=msg.sampling_params_list, - final_stage_id=msg.final_stage_id, - final_output_stage_ids=msg.final_output_stage_ids, - preprocess_ms=msg.preprocess_ms, - request_timestamp=msg.request_timestamp, - enqueue_ts=msg.enqueue_ts, - ) - await self._handle_add_request(fallback_msg) return if msg.sampling_params_list: @@ -2025,7 +2032,7 @@ class Orchestrator: _t_submit_start = _time.perf_counter() if next_pool.stage_type == "diffusion": - await next_pool.submit_initial( + replica_id = await next_pool.submit_initial( request_id, req_state, req_state.prompt, @@ -2064,12 +2071,18 @@ class Orchestrator: resumable=downstream_resumable, ) request.external_req_id = request.request_id - await next_pool.submit_initial( + replica_id = await next_pool.submit_initial( request_id, req_state, request, prompt_text=None, ) + self._record_duplex_stage_submission( + next_stage_id, + request_id, + replica_id, + req_state, + ) # async_chunk pre-submit fires per stage edge (N-1 -> N). Source # replica is stage 0's bound replica (single-replica thinker in diff --git a/vllm_omni/engine/stage_engine_startup.py b/vllm_omni/engine/stage_engine_startup.py index d23d9f3e0..cb80c82ac 100644 --- a/vllm_omni/engine/stage_engine_startup.py +++ b/vllm_omni/engine/stage_engine_startup.py @@ -183,6 +183,10 @@ class OmniMasterServer: # seeded with the registration port (and coordinator ROUTER port) that # are already bound on the same host. self._allocated_ports: set[int] = set() + # Keep route ports bound until their ZMQ owners are ready to bind them. + # Recording only the integer leaves a race with unrelated startup + # consumers (notably NCCL's own ephemeral-port selection). + self._port_reservations: dict[int, socket.socket] = {} if master_port: self._allocated_ports.add(int(master_port)) # Coordinator ROUTER address echoed back in every registration reply @@ -283,9 +287,11 @@ class OmniMasterServer: ``get_open_ports_list`` dedups only within its own call, so we redraw any port that collides with one already recorded in ``self._allocated_ports`` (registration/coordinator ports plus every previously-allocated route - port) and register the winners before returning. Callers already hold the - relevant allocation context (``__init__`` is single-threaded; - registration holds ``self._alloc_lock``). + port). Each winner remains OS-bound until its ZMQ owner is ready, so + another startup subsystem cannot claim it in the allocation-to-bind + window. Callers already hold the relevant allocation context + (``__init__`` is single-threaded; registration holds + ``self._alloc_lock``). """ picked: list[int] = [] # Bounded retry budget so a pathological host (near port exhaustion) @@ -294,7 +300,17 @@ class OmniMasterServer: for port in get_open_ports_list(count=count - len(picked)): if port in self._allocated_ports: continue + reservation = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + # Reserve on every local interface. The advertised address + # can be a routable interface IP, but wildcard binding also + # protects the route from local NCCL/socket allocations. + reservation.bind(("", port)) + except OSError: + reservation.close() + continue self._allocated_ports.add(port) + self._port_reservations[port] = reservation picked.append(port) if len(picked) == count: return picked @@ -303,6 +319,33 @@ class OmniMasterServer: f"(host={self._address}); {len(self._allocated_ports)} already in use." ) + def release_route_port_reservations( + self, + stage_id: int, + replica_id: int = 0, + *, + handshake: bool = True, + data: bool = True, + ) -> None: + """Release reserved route ports immediately before their ZMQ binds.""" + alloc = self.get_allocation(stage_id, replica_id) + ports: list[int] = [] + if handshake: + handshake_port = _port_from_zmq_address(alloc.handshake_bind_address) + if handshake_port is not None: + ports.append(handshake_port) + if data: + for address in (alloc.input_bind_address, alloc.output_bind_address): + port = _port_from_zmq_address(address) + if port is not None: + ports.append(port) + + with self._alloc_lock: + for port in ports: + reservation = self._port_reservations.pop(port, None) + if reservation is not None: + reservation.close() + def _next_free_replica_id(self, stage_id: int) -> int: """Return the next replica id to assign for an auto-assign registration. @@ -437,6 +480,11 @@ class OmniMasterServer: self._stop_event.set() if self._thread is not None: self._thread.join(timeout=10) + with self._alloc_lock: + reservations = list(self._port_reservations.values()) + self._port_reservations.clear() + for reservation in reservations: + reservation.close() # ------------------------------------------------------------------ # Internal server logic @@ -771,20 +819,34 @@ def connect_remote_engine_cores( handshake_bind_address = omni_master_server.get_allocation(stage_id, replica_id=replica_id).handshake_bind_address - with zmq_socket_ctx(handshake_bind_address, zmq.ROUTER, bind=True) as handshake_socket: - yield StageReplicaResources( - coordinator=coordinator, - addresses=addresses, - ) - wait_for_engine_startup( - handshake_socket, - addresses, - engines_to_handshake, - vllm_config.parallel_config, - False, # coordinated_dp - vllm_config.cache_config, - None, # proc_manager (remote — no local procs) - None, # coord_process + omni_master_server.release_route_port_reservations( + stage_id, + replica_id, + handshake=True, + data=False, + ) + try: + with zmq_socket_ctx(handshake_bind_address, zmq.ROUTER, bind=True) as handshake_socket: + yield StageReplicaResources( + coordinator=coordinator, + addresses=addresses, + ) + wait_for_engine_startup( + handshake_socket, + addresses, + engines_to_handshake, + vllm_config.parallel_config, + False, # coordinated_dp + vllm_config.cache_config, + None, # proc_manager (remote — no local procs) + None, # coord_process + ) + finally: + omni_master_server.release_route_port_reservations( + stage_id, + replica_id, + handshake=False, + data=True, ) @@ -802,17 +864,31 @@ def connect_remote_diffusion_proc( ).handshake_bind_address logger.info("Waiting for remote diffusion proc for stage %d replica %d", stage_id, replica_id) - with zmq_socket_ctx(handshake_bind_address, zmq.ROUTER, bind=True) as handshake_socket: - yield StageReplicaResources(addresses=addresses) - wait_for_engine_startup( - handshake_socket, - addresses, - [CoreEngine(index=0, local=False)], - _single_diffusion_parallel_config(local_client=False), - False, - None, - None, - None, + omni_master_server.release_route_port_reservations( + stage_id, + replica_id, + handshake=True, + data=False, + ) + try: + with zmq_socket_ctx(handshake_bind_address, zmq.ROUTER, bind=True) as handshake_socket: + yield StageReplicaResources(addresses=addresses) + wait_for_engine_startup( + handshake_socket, + addresses, + [CoreEngine(index=0, local=False)], + _single_diffusion_parallel_config(local_client=False), + False, + None, + None, + None, + ) + finally: + omni_master_server.release_route_port_reservations( + stage_id, + replica_id, + handshake=False, + data=True, ) @@ -918,49 +994,63 @@ def _launch_omni_core_engines( # Bind the pre-allocated handshake socket for this stage. handshake_bind_address = omni_master_server.get_allocation(stage_id, replica_id=replica_id).handshake_bind_address - with zmq_socket_ctx(handshake_bind_address, zmq.ROUTER, bind=True) as handshake_socket: - if omni_coordinator_address is not None: - # Use the omni subclass so each spawned subprocess instantiates - # an OmniCoordClientForStage and heartbeats to the coordinator. - from vllm_omni.engine.stage_engine_core_proc_manager import StageEngineCoreProcManager + omni_master_server.release_route_port_reservations( + stage_id, + replica_id, + handshake=True, + data=False, + ) + try: + with zmq_socket_ctx(handshake_bind_address, zmq.ROUTER, bind=True) as handshake_socket: + if omni_coordinator_address is not None: + # Use the omni subclass so each spawned subprocess instantiates + # an OmniCoordClientForStage and heartbeats to the coordinator. + from vllm_omni.engine.stage_engine_core_proc_manager import StageEngineCoreProcManager - with scoped_spawn_device_env(stage_visible_devices, spawn_device_lock): - local_engine_manager: CoreEngineProcManager = StageEngineCoreProcManager( - local_engine_count=local_engine_count, - start_index=start_index, - local_start_index=local_start_index, - vllm_config=vllm_config, - local_client=True, - handshake_address=handshake_address, - executor_class=executor_class, - log_stats=log_stats, - omni_stage_id=stage_id, - omni_coordinator_address=omni_coordinator_address, - omni_replica_base_id=replica_id, - ) - else: - with scoped_spawn_device_env(stage_visible_devices, spawn_device_lock): - local_engine_manager = CoreEngineProcManager( - local_engine_count=local_engine_count, - start_index=start_index, - local_start_index=local_start_index, - vllm_config=vllm_config, - local_client=True, - handshake_address=handshake_address, - executor_class=executor_class, - log_stats=log_stats, - ) + with scoped_spawn_device_env(stage_visible_devices, spawn_device_lock): + local_engine_manager: CoreEngineProcManager = StageEngineCoreProcManager( + local_engine_count=local_engine_count, + start_index=start_index, + local_start_index=local_start_index, + vllm_config=vllm_config, + local_client=True, + handshake_address=handshake_address, + executor_class=executor_class, + log_stats=log_stats, + omni_stage_id=stage_id, + omni_coordinator_address=omni_coordinator_address, + omni_replica_base_id=replica_id, + ) + else: + with scoped_spawn_device_env(stage_visible_devices, spawn_device_lock): + local_engine_manager = CoreEngineProcManager( + local_engine_count=local_engine_count, + start_index=start_index, + local_start_index=local_start_index, + vllm_config=vllm_config, + local_client=True, + handshake_address=handshake_address, + executor_class=executor_class, + log_stats=log_stats, + ) - yield local_engine_manager, coordinator, addresses - wait_for_engine_startup( - handshake_socket, - addresses, - engines_to_handshake, - parallel_config, - parallel_config.data_parallel_size > 1 and vllm_config.model_config.is_moe, - vllm_config.cache_config, - local_engine_manager, - coordinator.proc if coordinator else None, + yield local_engine_manager, coordinator, addresses + wait_for_engine_startup( + handshake_socket, + addresses, + engines_to_handshake, + parallel_config, + parallel_config.data_parallel_size > 1 and vllm_config.model_config.is_moe, + vllm_config.cache_config, + local_engine_manager, + coordinator.proc if coordinator else None, + ) + finally: + omni_master_server.release_route_port_reservations( + stage_id, + replica_id, + handshake=False, + data=True, ) @@ -1448,6 +1538,12 @@ def launch_diffusion_stage_replica( omni_stage_config=stage_config, replica_id=replica_id, ) + omni_master_server.release_route_port_reservations( + metadata.stage_id, + replica_id, + handshake=True, + data=False, + ) proc_manager = stage_diffusion_proc.StageDiffusionProcManager( model=model, od_config=od_config, @@ -1461,6 +1557,12 @@ def launch_diffusion_stage_replica( omni_stage_id=metadata.stage_id, omni_replica_id=replica_id, ) + omni_master_server.release_route_port_reservations( + metadata.stage_id, + replica_id, + handshake=False, + data=True, + ) client = StageDiffusionClient.from_addresses( metadata, request_address=proc_manager.addresses.inputs[0], @@ -1474,6 +1576,10 @@ def launch_diffusion_stage_replica( lock_fds=lock_fds, ) except Exception: + omni_master_server.release_route_port_reservations( + metadata.stage_id, + replica_id, + ) if proc_manager is not None: proc_manager.shutdown() if lock_fds: diff --git a/vllm_omni/entrypoints/async_omni.py b/vllm_omni/entrypoints/async_omni.py index 332e5b921..1437cc680 100644 --- a/vllm_omni/entrypoints/async_omni.py +++ b/vllm_omni/entrypoints/async_omni.py @@ -628,6 +628,13 @@ class AsyncOmni(EngineClient, OmniBase): logger.debug(f"[AsyncOmni] Request {request_id} completed") + # The input pump can outlive a normally-completed generation + # (e.g. a realtime client that keeps streaming after the final + # output); a live pump keeps issuing streaming updates for a + # request the orchestrator no longer tracks. + if input_stream_task is not None and not input_stream_task.done(): + input_stream_task.cancel() + self._log_summary_and_cleanup(request_id) except (asyncio.CancelledError, GeneratorExit): @@ -1059,7 +1066,10 @@ class AsyncOmni(EngineClient, OmniBase): """Submit request IDs to be aborted to the engine.""" await self.engine.abort_async(request_ids) for rid in request_ids: - self.request_states.pop(rid, None) + state = self.request_states.pop(rid, None) + input_stream_task = getattr(state, "input_stream_task", None) + if input_stream_task is not None and not input_stream_task.done(): + input_stream_task.cancel() if self.log_stats: logger.info("[AsyncOmni] Aborted request(s) %s", ",".join(request_ids)) diff --git a/vllm_omni/entrypoints/cli/serve.py b/vllm_omni/entrypoints/cli/serve.py index ce0549597..097eea9d0 100644 --- a/vllm_omni/entrypoints/cli/serve.py +++ b/vllm_omni/entrypoints/cli/serve.py @@ -892,7 +892,7 @@ def run_headless(args: TrackingNamespace) -> None: stage_connector_spec = get_stage_connector_spec( omni_transfer_config=omni_transfer_config, stage_id=stage_id, - async_chunk=False, + async_chunk=bool(stage_cfg.engine_args.get("async_chunk", False)), ) # ``runtime_cfg`` is mostly inherited from the parent's diff --git a/vllm_omni/entrypoints/openai/realtime_connection.py b/vllm_omni/entrypoints/openai/realtime_connection.py index 0f0efbef8..aad7ea658 100644 --- a/vllm_omni/entrypoints/openai/realtime_connection.py +++ b/vllm_omni/entrypoints/openai/realtime_connection.py @@ -138,6 +138,7 @@ class RealtimeConnection(VllmRealtimeConnection): is_streaming=True, ) + result_gen = None try: result_gen = self.engine.generate( prompt=streaming_input_gen, @@ -196,6 +197,16 @@ class RealtimeConnection(VllmRealtimeConnection): logger.exception("Error in generation: %s", e) await self.send_error(str(e), "processing_error") finally: + # Close the generator explicitly so AsyncOmni.generate's cleanup + # (input-pump cancellation and engine-side abort) runs now rather + # than whenever the event loop garbage-collects the async + # generator; the delay window is where a disconnected session + # keeps cycling through the stages (issue #4271). + if result_gen is not None: + try: + await result_gen.aclose() + except Exception: + logger.exception("Failed to close realtime result generator") # Always send terminal event so clients don't hang forever. if self._is_connected and not audio_done_sent: try: @@ -206,4 +217,10 @@ class RealtimeConnection(VllmRealtimeConnection): self.audio_queue.get_nowait() async def send_json(self, payload: dict): - await self.websocket.send_text(json.dumps(payload)) + try: + await self.websocket.send_text(json.dumps(payload)) + except Exception: + # A failed send means the client is gone; flag it so the + # generation loop stops instead of retrying into a dead socket. + self._is_connected = False + raise diff --git a/vllm_omni/experimental/fullduplex/minicpmo45/data_plane.py b/vllm_omni/experimental/fullduplex/minicpmo45/data_plane.py index d3b082ffc..d18c0f4a9 100644 --- a/vllm_omni/experimental/fullduplex/minicpmo45/data_plane.py +++ b/vllm_omni/experimental/fullduplex/minicpmo45/data_plane.py @@ -65,12 +65,11 @@ class MiniCPMO45DataPlaneSession: def begin_request(self, request_id: str) -> None: state = self._requests.setdefault(request_id, _RequestState()) state.terminal = False - # Preserve turn-scoped cursors for resumable requests. The legacy path - # only reset request-scoped terminal markers on append. - request_turn = state.turns.get(None) - if request_turn is not None: - request_turn.tts_eos_done = False - request_turn.turn_eos_done = False + # A resumable append starts a new Talker segment, even when it + # continues the same model turn. Re-arm only the segment boundary; + # turn EOS remains deduplicated until the model advances the turn. + for turn_state in state.turns.values(): + turn_state.tts_eos_done = False def is_terminal(self, request_id: str | None) -> bool: if request_id is None: diff --git a/vllm_omni/experimental/fullduplex/minicpmo45/input.py b/vllm_omni/experimental/fullduplex/minicpmo45/input.py index 2a4eac3ea..248e857e8 100644 --- a/vllm_omni/experimental/fullduplex/minicpmo45/input.py +++ b/vllm_omni/experimental/fullduplex/minicpmo45/input.py @@ -2,11 +2,19 @@ from __future__ import annotations import base64 import binascii +from dataclasses import dataclass from typing import Any import numpy as np +@dataclass(frozen=True, slots=True) +class _PcmSpan: + byte_count: int + force_listen: bool + is_speech: bool + + class MiniCPMO45PcmAppendReservation: __slots__ = ( "_active", @@ -15,6 +23,7 @@ class MiniCPMO45PcmAppendReservation: "_owner", "_raw", "_sample_rate_hz", + "_spans", "_turn_had_speech", "_video_frames", "operation_id", @@ -31,6 +40,7 @@ class MiniCPMO45PcmAppendReservation: sample_rate_hz: int, force_listen: bool, is_speech: bool, + spans: list[_PcmSpan] | None = None, turn_had_speech: bool = False, video_frames: list[str] | None = None, ) -> None: @@ -41,6 +51,7 @@ class MiniCPMO45PcmAppendReservation: self._sample_rate_hz = sample_rate_hz self._force_listen = force_listen self._is_speech = is_speech + self._spans = list(spans or []) self._turn_had_speech = turn_had_speech self._video_frames = list(video_frames or []) self._active = True @@ -97,9 +108,8 @@ class MiniCPMO45PcmAppendBuffer: def __init__(self) -> None: self._buffer = bytearray() + self._spans: list[_PcmSpan] = [] self._sample_rate_hz: int | None = None - self._force_listen = False - self._is_speech = False self._turn_had_speech = False self._reservation_seq = 0 self._reservations: list[MiniCPMO45PcmAppendReservation] = [] @@ -112,14 +122,13 @@ class MiniCPMO45PcmAppendBuffer: reservation._active = False self._reservations.clear() self._buffer.clear() + self._spans.clear() self._frame_queue.clear() self._sample_rate_hz = None - self._force_listen = False - self._is_speech = False self._turn_had_speech = False def clear_force_listen(self) -> None: - self._force_listen = False + self._spans = [_PcmSpan(span.byte_count, False, span.is_speech) for span in self._spans] def has_pending(self) -> bool: return bool(self._buffer) @@ -131,6 +140,56 @@ class MiniCPMO45PcmAppendBuffer: def pending_byte_count(self) -> int: return len(self._buffer) + def _append_span(self, span: _PcmSpan) -> None: + if span.byte_count <= 0: + return + if ( + self._spans + and self._spans[-1].force_listen == span.force_listen + and self._spans[-1].is_speech == span.is_speech + ): + previous = self._spans[-1] + self._spans[-1] = _PcmSpan( + previous.byte_count + span.byte_count, + span.force_listen, + span.is_speech, + ) + return + self._spans.append(span) + + def _consume_spans(self, byte_count: int) -> list[_PcmSpan]: + consumed: list[_PcmSpan] = [] + remaining = byte_count + while remaining: + if not self._spans: + raise RuntimeError("MiniCPM-o PCM metadata is shorter than buffered audio") + span = self._spans.pop(0) + take = min(remaining, span.byte_count) + consumed.append(_PcmSpan(take, span.force_listen, span.is_speech)) + if take < span.byte_count: + self._spans.insert( + 0, + _PcmSpan( + span.byte_count - take, + span.force_listen, + span.is_speech, + ), + ) + remaining -= take + return consumed + + def _prepend_spans(self, spans: list[_PcmSpan]) -> None: + current = self._spans + self._spans = [] + for span in [*spans, *current]: + self._append_span(span) + + def _buffer_force_listen(self) -> bool: + return any(span.force_listen for span in self._spans) + + def _buffer_is_speech(self) -> bool: + return any(span.is_speech for span in self._spans) + def _reserve_passthrough( self, payload: dict[str, object], @@ -180,12 +239,17 @@ class MiniCPMO45PcmAppendBuffer: raise ValueError("MiniCPM-o native duplex audio append sample_rate_hz changed within a session") self._sample_rate_hz = sample_rate_hz self._buffer.extend(raw) + self._append_span( + _PcmSpan( + len(raw), + bool(payload.get("force_listen", False)), + bool(payload.get("is_speech", False)), + ) + ) frames_in = payload.get("video_frames") if isinstance(frames_in, list): self._frame_queue.extend(frame for frame in frames_in if isinstance(frame, str) and frame) self._turn_had_speech = self._turn_had_speech or bool(payload.get("is_speech", False)) - self._force_listen = self._force_listen or bool(payload.get("force_listen", False)) - self._is_speech = self._is_speech or bool(payload.get("is_speech", False)) if not allow_emit: return None @@ -208,6 +272,7 @@ class MiniCPMO45PcmAppendBuffer: pad_samples = 0 emit_bytes = emit_samples * 4 reserved_raw = bytes(self._buffer[:emit_bytes]) + reserved_spans = self._consume_spans(emit_bytes) emit_raw = reserved_raw + b"\x00" * (pad_samples * 4) del self._buffer[:emit_bytes] @@ -227,11 +292,8 @@ class MiniCPMO45PcmAppendBuffer: if emit_samples + pad_samples >= min_samples and self._frame_queue: attached_frames = [self._frame_queue.pop(0)] out["video_frames"] = attached_frames - out["force_listen"] = self._force_listen - out["is_speech"] = self._is_speech - if not self._buffer: - self._force_listen = False - self._is_speech = False + out["force_listen"] = any(span.force_listen for span in reserved_spans) + out["is_speech"] = any(span.is_speech for span in reserved_spans) reservation = MiniCPMO45PcmAppendReservation( owner=self, operation_id=operation_id, @@ -240,6 +302,7 @@ class MiniCPMO45PcmAppendBuffer: sample_rate_hz=sample_rate_hz, force_listen=bool(out.get("force_listen", False)), is_speech=bool(out.get("is_speech", False)), + spans=reserved_spans, turn_had_speech=self._turn_had_speech, video_frames=attached_frames, ) @@ -261,8 +324,8 @@ class MiniCPMO45PcmAppendBuffer: "audio": "", "format": "pcm_f32le", "sample_rate_hz": self._sample_rate_hz or 16000, - "force_listen": self._force_listen, - "is_speech": self._is_speech, + "force_listen": self._buffer_force_listen(), + "is_speech": self._buffer_is_speech(), } reservation = self.prepare_append( payload, @@ -280,8 +343,8 @@ class MiniCPMO45PcmAppendBuffer: payload=None, raw=b"", sample_rate_hz=self._sample_rate_hz or 0, - force_listen=self._force_listen, - is_speech=self._is_speech, + force_listen=self._buffer_force_listen(), + is_speech=self._buffer_is_speech(), turn_had_speech=had_speech, ) self._reservations.append(reservation) @@ -289,8 +352,6 @@ class MiniCPMO45PcmAppendBuffer: # Open the next physical input generation without touching already # reserved appends. A rollback restores this generation's metadata. self._sample_rate_hz = None - self._force_listen = False - self._is_speech = False self._turn_had_speech = False return reservation @@ -335,12 +396,11 @@ class MiniCPMO45PcmAppendBuffer: rolled_back = self._reservations[index:] restored = b"".join(item._raw for item in rolled_back) self._buffer[:0] = restored + self._prepend_spans([span for item in rolled_back for span in item._spans]) restored_frames = [frame for item in rolled_back for frame in item._video_frames] if restored_frames: self._frame_queue[:0] = restored_frames self._sample_rate_hz = self._sample_rate_hz or reservation._sample_rate_hz - self._force_listen = self._force_listen or any(item._force_listen for item in rolled_back) - self._is_speech = self._is_speech or any(item._is_speech for item in rolled_back) self._turn_had_speech = self._turn_had_speech or any(item._turn_had_speech for item in rolled_back) for item in rolled_back: item._active = False @@ -354,8 +414,8 @@ class MiniCPMO45PcmAppendBuffer: "audio": "", "format": "pcm_f32le", "sample_rate_hz": self._sample_rate_hz or 16000, - "force_listen": self._force_listen, - "is_speech": self._is_speech, + "force_listen": self._buffer_force_listen(), + "is_speech": self._buffer_is_speech(), } return self.append(payload, chunk_period_ms=chunk_period_ms, flush=True) diff --git a/vllm_omni/experimental/fullduplex/minicpmo45/stage0.py b/vllm_omni/experimental/fullduplex/minicpmo45/stage0.py index 84aaf1cb8..8a304d101 100644 --- a/vllm_omni/experimental/fullduplex/minicpmo45/stage0.py +++ b/vllm_omni/experimental/fullduplex/minicpmo45/stage0.py @@ -2,10 +2,10 @@ from __future__ import annotations import base64 import copy -import sys import time from contextlib import suppress from dataclasses import dataclass, field +from threading import Lock from typing import Any import numpy as np @@ -14,6 +14,7 @@ from vllm_omni.experimental.fullduplex.minicpmo45.policy import MiniCPMO45Duplex _MINICPMO45_SPECIAL_TOKEN_FIELDS = MiniCPMO45DuplexPolicy.SPECIAL_TOKEN_FIELDS _MINICPMO45_OPTIONAL_TOKEN_FIELDS = MiniCPMO45DuplexPolicy.OPTIONAL_TOKEN_FIELDS +_MINICPMO45_PROCESSOR_LOAD_LOCK = Lock() @dataclass @@ -35,7 +36,7 @@ class _MiniCPMO45Stage0SessionState: pending_speech_context: bool = False pending_speech_append_identity: tuple[int | None, int] | None = None pending_speech_response_open: bool = False - generated_text_tokens: list[int] = field(default_factory=list) + generated_tokens: list[int] = field(default_factory=list) class MiniCPMO45Stage0DuplexRuntime: @@ -58,6 +59,8 @@ class MiniCPMO45Stage0DuplexRuntime: else getattr(stage_model, "tokenizer", None) ) self._init_token_ids() + if self.tokenizer is not None: + self._require_special_token_ids() def _stage_runtime_ready(self) -> bool: return self.processor is not None and self.tokenizer is not None and self.thinker is not None @@ -196,19 +199,15 @@ class MiniCPMO45Stage0DuplexRuntime: embed_parts.extend(state.context_embeds) token_ids.extend(state.context_token_ids) - # Consume every complete chunk in the buffer so the appended span and - # the scheduler's slot reservation for this append agree exactly. A - # final append may zero-pad a real residual chunk, but it must not add - # a whole silence unit after all input was already consumed: official - # duplex generation runs once per microphone unit and client commit is - # not an additional model decision. + # Consume every complete processor chunk in the buffer so the appended + # span and the scheduler's slot reservation agree exactly. The serving + # PCM buffer pads a final real residual before it reaches Stage0. Do not + # pad again here: the first processor chunk is hop-aligned below 1035ms + # and intentionally leaves a small carry that is not another model unit. units_built = 0 while True: if len(state.audio_buffer) < chunk_size: - if not final or len(state.audio_buffer) == 0: - break - pad = np.zeros(chunk_size - len(state.audio_buffer), dtype=np.float32) - state.audio_buffer = np.concatenate([state.audio_buffer, pad]) + break audio_chunk = state.audio_buffer[:chunk_size] batch_feature = self._process_streaming_audio(audio_chunk, state.audio_chunk_idx, processor=processor) for name, value in ( @@ -224,6 +223,11 @@ class MiniCPMO45Stage0DuplexRuntime: if units_built == 0: return self._stage_prefill_result(False, start_time, "streaming audio embedding returned empty") break + consumed_samples = self._consumed_audio_samples( + state.audio_chunk_idx, + chunk_size, + processor=processor, + ) if state.audio_chunk_idx > 0: # Official duplex closes every unit (finalize_unit feeds the # sampled terminator + ) before the next opens. @@ -255,9 +259,7 @@ class MiniCPMO45Stage0DuplexRuntime: token_ids.extend( [self._audio_embedding_placeholder_token_id()] * int(self._as_2d_tensor(audio_embeds).shape[0]) ) - state.audio_buffer = state.audio_buffer[ - self._consumed_audio_samples(state.audio_chunk_idx, chunk_size, processor=processor) : - ] + state.audio_buffer = state.audio_buffer[consumed_samples:] state.audio_chunk_idx += 1 units_built += 1 chunk_size = self._streaming_chunk_size(processor) @@ -610,14 +612,33 @@ class MiniCPMO45Stage0DuplexRuntime: def _load_processor_from_path(model_path: str | None) -> Any | None: if not model_path: return None - if model_path not in sys.path: - sys.path.insert(0, model_path) try: - from processing_minicpmo import MiniCPMOProcessor + from transformers import AutoImageProcessor, AutoProcessor - return MiniCPMOProcessor.from_pretrained(model_path, trust_remote_code=True) - except Exception: - return None + original_register = AutoImageProcessor.register + + def register_image_processor(config_class, *args, **kwargs): + # The checkpoint's auto_map already loads this class. Its + # legacy string registration is incompatible with some + # Transformers versions and is otherwise redundant. + if config_class == "MiniCPMVImageProcessor": + return None + return original_register(config_class, *args, **kwargs) + + with _MINICPMO45_PROCESSOR_LOAD_LOCK: + AutoImageProcessor.register = staticmethod(register_image_processor) + try: + processor = AutoProcessor.from_pretrained( + model_path, + trust_remote_code=True, + ) + finally: + AutoImageProcessor.register = staticmethod(original_register) + except Exception as exc: + raise RuntimeError(f"Failed to load MiniCPM-o duplex processor from {model_path!r}") from exc + if getattr(processor, "tokenizer", None) is None: + raise RuntimeError(f"MiniCPM-o duplex processor loaded from {model_path!r} does not expose a tokenizer") + return processor def _init_token_ids(self) -> None: if self.tokenizer is None: diff --git a/vllm_omni/experimental/fullduplex/openai/session_runner.py b/vllm_omni/experimental/fullduplex/openai/session_runner.py index 7e2d1ecaf..4b5e4f2e6 100644 --- a/vllm_omni/experimental/fullduplex/openai/session_runner.py +++ b/vllm_omni/experimental/fullduplex/openai/session_runner.py @@ -503,11 +503,45 @@ class DuplexSessionRunnerMixin: clear_completed_pending_silence() pending_silence = native.pending_silence_task if pending_silence is not None and not pending_silence.done(): + if pending_silence is asyncio.current_task(): + return False + try: + if not await pending_silence: + return False + except asyncio.CancelledError: + current = asyncio.current_task() + if current is not None and current.cancelling(): + raise + return False + except Exception: + return False + clear_completed_pending_silence() + pending_silence = native.pending_silence_task + if pending_silence is not None and not pending_silence.done(): + return False + append_tail = actor.native_append_tail + if (append_tail is None or append_tail.done()) and real_native_input_waiting(): return False - if actor.native_append_tail is not None and not actor.native_append_tail.done(): - return False - if real_native_input_waiting(): - return False + continuation_delay_s = max( + 0.0, + float(session.capabilities.chunk_period_ms or 1000) / 1000.0, + ) + if continuation_delay_s > 0: + await asyncio.sleep(continuation_delay_s) + if ( + actor.native_append_tail is not append_tail + or ((append_tail is None or append_tail.done()) and real_native_input_waiting()) + or self._native_silence_continuation_is_stale( + session, + request_id=request_id, + response_id=response_id, + response_owned=response_owned, + expected_epoch=expected_epoch, + expected_incarnation=expected_incarnation, + expected_model_turn_id=expected_model_turn_id, + ) + ): + return False def _still_valid() -> bool: return not real_native_input_waiting() and not self._native_silence_continuation_is_stale( diff --git a/vllm_omni/model_executor/models/minicpmo_4_5/batched_token2wav.py b/vllm_omni/model_executor/models/minicpmo_4_5/batched_token2wav.py index b068b7e4d..f0e07e50b 100644 --- a/vllm_omni/model_executor/models/minicpmo_4_5/batched_token2wav.py +++ b/vllm_omni/model_executor/models/minicpmo_4_5/batched_token2wav.py @@ -348,9 +348,16 @@ class BatchedToken2Wav(nn.Module): previous: torch.Tensor, window: torch.Tensor, ) -> torch.Tensor: - overlap = int(window.shape[0] // 2) + overlap = min( + int(window.shape[0] // 2), + int(speech.shape[-1]), + int(previous.shape[-1]), + ) result = speech.clone() - result[..., :overlap] = result[..., :overlap] * window[:overlap] + previous[..., -overlap:] * window[overlap:] + if overlap > 0: + result[..., :overlap] = ( + result[..., :overlap] * window[:overlap] + previous[..., -overlap:] * window[-overlap:] + ) return result def decode_batch( @@ -360,6 +367,7 @@ class BatchedToken2Wav(nn.Module): states: list[BatchedToken2WavState], *, last_chunk: bool, + flush_encoder: bool = False, ) -> tuple[list[torch.Tensor], list[BatchedToken2WavState]]: batch_size = int(tokens.shape[0]) if batch_size != len(states): @@ -382,7 +390,7 @@ class BatchedToken2Wav(nn.Module): with self._autocast(tokens.device): hidden, conformer_cnn, conformer_att = self._encode_chunk( tokens, - last_chunk=last_chunk, + last_chunk=last_chunk or flush_encoder, cnn_cache=flow_cache["conformer_cnn_cache"], att_cache=flow_cache["conformer_att_cache"], ) diff --git a/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_code2wav.py b/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_code2wav.py index a9ffa6e0a..ddc28c0f6 100644 --- a/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_code2wav.py +++ b/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_code2wav.py @@ -4,12 +4,12 @@ from __future__ import annotations -import hashlib import json import os import tempfile from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass +from hashlib import sha256 from pathlib import Path from typing import Any @@ -77,6 +77,13 @@ class _RequestState: token2wav: BatchedToken2WavState +@dataclass +class _RuntimePrompt: + cache_id: str + path: str + owners: set[str] + + @dataclass(frozen=True) class _WorkItem: output_index: int @@ -89,9 +96,11 @@ class _WorkItem: last_chunk: bool tokens: torch.Tensor previous: _RequestState | None - segment_text: str - duplex_turn_id: int | None - duplex_epoch: int | None + runtime_prompt_key: str | None + duplex_epoch: int + duplex_turn_id: int + segment_text_utf8: torch.Tensor + tts_is_last_chunk: bool segment_end: bool turn_end: bool has_payload: bool = True @@ -120,12 +129,17 @@ class MiniCPMO45Code2Wav(nn.Module): self.model_path = str(vllm_config.model_config.model) self.backend: BatchedToken2Wav | None = None self._states: dict[str, _RequestState] = {} - self._owned_prompt_wavs: dict[str, tuple[str, str]] = {} + self._runtime_prompts: dict[str, _RuntimePrompt] = {} + self._request_prompt_keys: dict[str, str] = {} + self._runtime_prompt_dir = tempfile.TemporaryDirectory( + prefix="minicpmo45-runtime-prompts-", + ) extra = self._extra_config() self._min_batch_size = int(extra.get("code2wav_min_batch_size", 1)) if self._min_batch_size < 1: raise ValueError("MiniCPM-o Code2Wav code2wav_min_batch_size must be >= 1") self._default_prompt_id = str(extra.get("prompt_cache_id", "HT_ref_audio")) + self._prompt_wav_explicit = "prompt_wav" in extra self._default_prompt_wav = str( extra.get( "prompt_wav", @@ -133,6 +147,34 @@ class MiniCPMO45Code2Wav(nn.Module): ) ) + def _resolve_model_root(self) -> Path: + model_root = Path(self.model_path) + if model_root.is_dir(): + return model_root + + from vllm_omni.model_executor.model_loader.weight_utils import ( + download_weights_from_hf_specific, + ) + + model_config = self.vllm_config.model_config + load_config = getattr(self.vllm_config, "load_config", None) + model_root = Path( + download_weights_from_hf_specific( + self.model_path, + getattr(load_config, "download_dir", None), + allow_patterns=[ + "assets/HT_ref_audio.wav", + "assets/token2wav/*", + ], + revision=getattr(model_config, "revision", None), + require_all=True, + ) + ) + self.model_path = str(model_root) + if not self._prompt_wav_explicit: + self._default_prompt_wav = str(model_root / "assets" / "HT_ref_audio.wav") + return model_root + def _extra_config(self) -> dict[str, Any]: model_config = getattr(self.vllm_config, "model_config", None) connector = getattr(model_config, "stage_connector_config", None) @@ -142,63 +184,122 @@ class MiniCPMO45Code2Wav(nn.Module): extra = getattr(connector, "extra", None) return dict(extra) if isinstance(extra, Mapping) else {} - def _release_prompt(self, state_id: str) -> None: - owned = self._owned_prompt_wavs.pop(state_id, None) - if owned is None: - return - prompt_cache_id, prompt_wav = owned - if self.backend is not None: - self.backend.evict_prompt(prompt_cache_id, prompt_wav) - try: - os.unlink(prompt_wav) - except FileNotFoundError: - pass - - def _write_reference_prompt( - self, - state_id: str, - reference: object, - sample_rate: object, - ) -> tuple[str, str]: - waveform = torch.as_tensor(reference, dtype=torch.float32).reshape(-1).cpu() - if waveform.numel() == 0: - return self._default_prompt_id, self._default_prompt_wav - sample_rate_hz = int(_scalar(sample_rate, 24000)) - if sample_rate_hz <= 0: - raise _batch_error( - "invalid_ref_audio_sample_rate", - request_id=state_id, - sample_rate=sample_rate_hz, - ) - digest = hashlib.sha256() - digest.update(str(sample_rate_hz).encode("ascii")) - digest.update(waveform.numpy().tobytes()) - prompt_cache_id = f"{state_id}:{digest.hexdigest()}" - current = self._owned_prompt_wavs.get(state_id) - if current is not None and current[0] == prompt_cache_id and os.path.exists(current[1]): - return current - - self._release_prompt(state_id) - handle = tempfile.NamedTemporaryFile(prefix="minicpmo45-ref-", suffix=".wav", delete=False) - prompt_wav = handle.name - handle.close() - try: - sf.write(prompt_wav, waveform.numpy(), sample_rate_hz, format="WAV") - except Exception: - try: - os.unlink(prompt_wav) - except FileNotFoundError: - pass - raise - self._owned_prompt_wavs[state_id] = (prompt_cache_id, prompt_wav) - return prompt_cache_id, prompt_wav - def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor: return torch.zeros((input_ids.numel(), 1), device=input_ids.device, dtype=torch.float32) def compute_logits(self, hidden_states: Any, sampling_metadata: Any = None) -> None: return None + def _materialize_runtime_prompt( + self, + ref_audio: Any, + sample_rate: Any, + ) -> tuple[str, _RuntimePrompt]: + sample_rate_hz = int(_scalar(sample_rate, 0)) + waveform = torch.as_tensor(ref_audio, dtype=torch.float32).reshape(-1).cpu().contiguous() + if sample_rate_hz <= 0: + raise _batch_error("invalid_ref_audio_sample_rate", sample_rate=sample_rate_hz) + if waveform.numel() == 0: + raise _batch_error("empty_ref_audio") + if not bool(torch.isfinite(waveform).all().item()): + raise _batch_error("non_finite_ref_audio") + + digest = sha256() + digest.update(waveform.numpy().tobytes()) + digest.update(str(sample_rate_hz).encode()) + cache_key = digest.hexdigest() + cache_id = f"runtime-ref-{cache_key[:24]}-{sample_rate_hz}" + path = str(Path(self._runtime_prompt_dir.name) / f"minicpmo45_ref_{cache_key[:24]}_{sample_rate_hz}.wav") + entry = self._runtime_prompts.get(cache_key) + if entry is None: + entry = _RuntimePrompt(cache_id=cache_id, path=path, owners=set()) + self._runtime_prompts[cache_key] = entry + prompt_path = Path(entry.path) + if not prompt_path.is_file(): + with tempfile.NamedTemporaryFile( + dir=prompt_path.parent, + prefix=f".{prompt_path.stem}-", + suffix=".wav", + delete=False, + ) as handle: + temporary_path = Path(handle.name) + try: + sf.write( + temporary_path, + waveform.numpy(), + sample_rate_hz, + format="WAV", + ) + os.replace(temporary_path, prompt_path) + finally: + temporary_path.unlink(missing_ok=True) + return cache_key, entry + + def _resolve_prompt( + self, + state_id: str, + info: Mapping[str, Any], + meta: Mapping[str, Any], + previous: _RequestState | None, + ) -> tuple[str, str, str | None]: + codes = info.get("codes") + ref_audio = codes.get("ref") if isinstance(codes, Mapping) else None + if ref_audio is not None: + cache_key, entry = self._materialize_runtime_prompt( + ref_audio, + meta.get("ref_audio_sr"), + ) + return entry.cache_id, entry.path, cache_key + + if previous is not None: + return previous.prompt_cache_id, previous.prompt_wav, self._request_prompt_keys.get(state_id) + + cache_key = self._request_prompt_keys.get(state_id) + entry = self._runtime_prompts.get(cache_key) if cache_key is not None else None + if entry is not None: + return entry.cache_id, entry.path, cache_key + + return ( + str(_scalar(meta.get("prompt_cache_id"), self._default_prompt_id)), + str(_scalar(meta.get("prompt_wav"), self._default_prompt_wav)), + None, + ) + + def _release_request_prompt(self, state_id: str) -> None: + cache_key = self._request_prompt_keys.pop(state_id, None) + entry = self._runtime_prompts.get(cache_key) if cache_key is not None else None + if entry is None: + return + entry.owners.discard(state_id) + if entry.owners: + return + if self.backend is not None: + self.backend.evict_prompt(entry.cache_id, entry.path) + Path(entry.path).unlink(missing_ok=True) + self._runtime_prompts.pop(cache_key, None) + + def _commit_runtime_prompt_owners(self, items: list[_WorkItem]) -> None: + for item in items: + cache_key = item.runtime_prompt_key + if cache_key is None: + continue + previous_key = self._request_prompt_keys.get(item.state_id) + if previous_key != cache_key: + self._release_request_prompt(item.state_id) + entry = self._runtime_prompts.get(cache_key) + if entry is not None: + entry.owners.add(item.state_id) + self._request_prompt_keys[item.state_id] = cache_key + + def _prune_unowned_runtime_prompts(self) -> None: + for cache_key, entry in list(self._runtime_prompts.items()): + if entry.owners: + continue + if self.backend is not None: + self.backend.evict_prompt(entry.cache_id, entry.path) + Path(entry.path).unlink(missing_ok=True) + self._runtime_prompts.pop(cache_key, None) + @staticmethod def _split_segments(input_ids: torch.Tensor, counts: Any) -> list[torch.Tensor]: flat = input_ids.reshape(-1) @@ -247,9 +348,11 @@ class MiniCPMO45Code2Wav(nn.Module): last_chunk=False, tokens=segment.new_empty(0, dtype=torch.long), previous=None, - segment_text="", - duplex_turn_id=None, - duplex_epoch=None, + runtime_prompt_key=None, + duplex_epoch=-1, + duplex_turn_id=-1, + segment_text_utf8=torch.empty(0, dtype=torch.uint8), + tts_is_last_chunk=False, segment_end=False, turn_end=False, has_payload=False, @@ -266,14 +369,15 @@ class MiniCPMO45Code2Wav(nn.Module): chunk_seq=chunk_seq, ) last_chunk = bool(_scalar(meta.get("last_chunk"), False)) + tts_is_last_chunk = bool(_scalar(meta.get("tts_is_last_chunk"), False)) codes = info.get("codes") audio = codes.get("audio") if isinstance(codes, Mapping) else None - reference = codes.get("ref") if isinstance(codes, Mapping) else None tokens = _codec_tensor(audio, segment) - if last_chunk and int(_scalar(meta.get("code_flat_numel"), tokens.numel())) == 0: + if int(_scalar(meta.get("code_flat_numel"), tokens.numel())) == 0: # The generation scheduler reserves one placeholder token for an - # empty terminal chunk. The producer's explicit length is the - # authority, so do not decode that placeholder as codec data. + # empty terminal or segment-boundary chunk. The producer's + # explicit length is the authority, so do not decode that + # placeholder as codec data. tokens = segment.new_empty(0, dtype=torch.long) previous = self._states.get(state_id) if previous is None: @@ -299,7 +403,6 @@ class MiniCPMO45Code2Wav(nn.Module): cache_epoch=cache_epoch, chunk_seq=chunk_seq, ) - self._release_prompt(state_id) previous = None elif chunk_seq != previous.chunk_seq + 1: raise _batch_error( @@ -308,22 +411,12 @@ class MiniCPMO45Code2Wav(nn.Module): expected=previous.chunk_seq + 1, actual=chunk_seq, ) - - explicit_prompt_id = _scalar(meta.get("prompt_cache_id")) - explicit_prompt_wav = _scalar(meta.get("prompt_wav")) - if previous is not None and explicit_prompt_id is None and explicit_prompt_wav is None: - prompt_cache_id = previous.prompt_cache_id - prompt_wav = previous.prompt_wav - elif reference is not None: - prompt_cache_id, prompt_wav = self._write_reference_prompt( - state_id, - reference, - meta.get("ref_audio_sr"), - ) - else: - prompt_cache_id = str(explicit_prompt_id or self._default_prompt_id) - prompt_wav = str(explicit_prompt_wav or self._default_prompt_wav) - + prompt_cache_id, prompt_wav, runtime_prompt_key = self._resolve_prompt( + state_id, + info, + meta, + previous, + ) if previous is not None and prompt_cache_id != previous.prompt_cache_id: raise _batch_error( "prompt_changed_midstream", @@ -338,8 +431,9 @@ class MiniCPMO45Code2Wav(nn.Module): expected=previous.prompt_wav, actual=prompt_wav, ) - duplex_turn_id = _scalar(meta.get("duplex_turn_id")) - duplex_epoch = _scalar(meta.get("duplex_epoch")) + segment_text_utf8 = meta.get("llm_output_text_utf8") + if not isinstance(segment_text_utf8, torch.Tensor): + segment_text_utf8 = torch.empty(0, dtype=torch.uint8) return _WorkItem( output_index=index, state_id=state_id, @@ -351,9 +445,11 @@ class MiniCPMO45Code2Wav(nn.Module): last_chunk=last_chunk, tokens=tokens, previous=previous, - segment_text=str(_scalar(meta.get("native_duplex_segment_text"), "")), - duplex_turn_id=int(duplex_turn_id) if duplex_turn_id is not None else None, - duplex_epoch=int(duplex_epoch) if duplex_epoch is not None else None, + runtime_prompt_key=runtime_prompt_key, + duplex_epoch=int(_scalar(meta.get("duplex_epoch"), -1)), + duplex_turn_id=int(_scalar(meta.get("duplex_turn_id"), -1)), + segment_text_utf8=segment_text_utf8, + tts_is_last_chunk=tts_is_last_chunk, segment_end=bool(_scalar(meta.get("segment_end"), False)), turn_end=bool(_scalar(meta.get("turn_end"), False)), ) @@ -371,6 +467,7 @@ class MiniCPMO45Code2Wav(nn.Module): int(item.tokens.numel()), cache_signature, item.last_chunk, + item.tts_is_last_chunk, item.cache_epoch, ) @@ -435,26 +532,37 @@ class MiniCPMO45Code2Wav(nn.Module): request_ids=len(state_ids), ) items: list[_WorkItem] = [] - for index, (state_id, segment, info) in enumerate( - zip(state_ids, segments, runtime_additional_information, strict=True) - ): - if not isinstance(info, Mapping): - raise _batch_error( - "invalid_runtime_info", - output_index=index, - value_type=type(info).__name__, - ) - items.append(self._parse_item(index, str(state_id), segment, info)) + try: + for index, (state_id, segment, info) in enumerate( + zip(state_ids, segments, runtime_additional_information, strict=True) + ): + if not isinstance(info, Mapping): + raise _batch_error( + "invalid_runtime_info", + output_index=index, + value_type=type(info).__name__, + ) + items.append(self._parse_item(index, str(state_id), segment, info)) + except Exception: + self._prune_unowned_runtime_prompts() + raise state_ids = [item.state_id for item in items] if len(state_ids) != len(set(state_ids)): + self._prune_unowned_runtime_prompts() raise _batch_error("duplicate_request_in_forward", request_ids=state_ids) outputs = [empty for _ in segments] sentinels = [item for item in items if item.last_chunk and item.tokens.numel() == 0] + segment_markers = [ + item for item in items if not item.last_chunk and item.tts_is_last_chunk and item.tokens.numel() == 0 + ] compute_items = [item for item in items if item.tokens.numel() > 0] invalid_empty = [ - item.request_id for item in items if item.has_payload and not item.last_chunk and item.tokens.numel() == 0 + item.request_id + for item in items + if item.has_payload and not item.last_chunk and not item.tts_is_last_chunk and item.tokens.numel() == 0 ] if invalid_empty: + self._prune_unowned_runtime_prompts() raise _batch_error("empty_nonfinal_chunk", request_ids=invalid_empty) buckets: dict[tuple[Any, ...], list[_WorkItem]] = {} @@ -470,6 +578,7 @@ class MiniCPMO45Code2Wav(nn.Module): if len(bucket) < self._min_batch_size ] if undersized: + self._prune_unowned_runtime_prompts() raise _batch_error( "exact_shape_bucket_below_minimum", minimum=self._min_batch_size, @@ -477,6 +586,58 @@ class MiniCPMO45Code2Wav(nn.Module): ) pending: dict[str, _RequestState | None] = {item.state_id: None for item in sentinels} + pending.update( + { + item.state_id: _RequestState( + cache_epoch=item.cache_epoch, + chunk_seq=item.chunk_seq, + prompt_cache_id=item.prompt_cache_id, + prompt_wav=item.prompt_wav, + token2wav=item.previous.token2wav, + ) + for item in segment_markers + if item.previous is not None + } + ) + initial_marker_buckets: dict[tuple[str, str], list[_WorkItem]] = {} + for item in segment_markers: + if item.previous is None: + initial_marker_buckets.setdefault( + (item.prompt_cache_id, item.prompt_wav), + [], + ).append(item) + for bucket in initial_marker_buckets.values(): + try: + features = self.backend.prepare_prompt( + bucket[0].prompt_cache_id, + bucket[0].prompt_wav, + ) + states = self.backend.setup_batch(features, len(bucket)) + except Exception as exc: + self._prune_unowned_runtime_prompts() + if isinstance(exc, RuntimeError) and str(exc).startswith("MiniCPMO45Code2WavBatchError "): + raise + raise _batch_error( + "backend_unsupported_or_failed", + request_ids=[item.request_id for item in bucket], + error_type=type(exc).__name__, + error=str(exc), + ) from exc + if len(states) != len(bucket): + self._prune_unowned_runtime_prompts() + raise _batch_error( + "backend_result_size_mismatch", + expected=len(bucket), + states=len(states), + ) + for item, state in zip(bucket, states, strict=True): + pending[item.state_id] = _RequestState( + cache_epoch=item.cache_epoch, + chunk_seq=item.chunk_seq, + prompt_cache_id=item.prompt_cache_id, + prompt_wav=item.prompt_wav, + token2wav=state, + ) for bucket in buckets.values(): batch_size = len(bucket) try: @@ -496,6 +657,7 @@ class MiniCPMO45Code2Wav(nn.Module): last_chunk=bucket[0].last_chunk, ) except Exception as exc: + self._prune_unowned_runtime_prompts() if isinstance(exc, RuntimeError) and str(exc).startswith("MiniCPMO45Code2WavBatchError "): raise raise _batch_error( @@ -505,6 +667,7 @@ class MiniCPMO45Code2Wav(nn.Module): error=str(exc), ) from exc if len(audios) != batch_size or len(next_states) != batch_size: + self._prune_unowned_runtime_prompts() raise _batch_error( "backend_result_size_mismatch", expected=batch_size, @@ -525,10 +688,10 @@ class MiniCPMO45Code2Wav(nn.Module): ) ) + self._commit_runtime_prompt_owners(items) for request_id, state in pending.items(): if state is None: self._states.pop(request_id, None) - self._release_prompt(request_id) else: self._states[request_id] = state sample_rate_tensor = torch.as_tensor(sample_rate, dtype=torch.int32) @@ -537,24 +700,15 @@ class MiniCPMO45Code2Wav(nn.Module): multimodal_outputs={ "model_outputs": outputs, "sr": [sample_rate_tensor.clone() for _ in outputs], - "meta.llm_output_text_utf8": [ - torch.tensor(list(item.segment_text.encode("utf-8")), dtype=torch.uint8) for item in items - ], - "meta.tts_is_last_chunk": [torch.tensor(item.last_chunk, dtype=torch.bool) for item in items], + # Generation runner wire payloads are flat and tensor-only. + # Dotted metadata keys are unflattened again by the output + # processor before the full-duplex data plane consumes them. + "meta.duplex_epoch": [torch.tensor(item.duplex_epoch, dtype=torch.int32) for item in items], + "meta.duplex_turn_id": [torch.tensor(item.duplex_turn_id, dtype=torch.int32) for item in items], + "meta.llm_output_text_utf8": [item.segment_text_utf8 for item in items], + "meta.tts_is_last_chunk": [torch.tensor(item.tts_is_last_chunk, dtype=torch.bool) for item in items], "meta.segment_end": [torch.tensor(item.segment_end, dtype=torch.bool) for item in items], "meta.turn_end": [torch.tensor(item.turn_end, dtype=torch.bool) for item in items], - "meta.duplex_turn_id": [ - torch.tensor(item.duplex_turn_id, dtype=torch.int64) - if item.duplex_turn_id is not None - else torch.empty(0, dtype=torch.int64) - for item in items - ], - "meta.duplex_epoch": [ - torch.tensor(item.duplex_epoch, dtype=torch.int64) - if item.duplex_epoch is not None - else torch.empty(0, dtype=torch.int64) - for item in items - ], }, ) @@ -562,7 +716,7 @@ class MiniCPMO45Code2Wav(nn.Module): for request_id in finished_req_ids: state_id = str(request_id) self._states.pop(state_id, None) - self._release_prompt(state_id) + self._release_request_prompt(state_id) def make_omni_output(self, model_outputs: Any, **_: Any) -> OmniOutput: if isinstance(model_outputs, OmniOutput): @@ -600,10 +754,11 @@ class MiniCPMO45Code2Wav(nn.Module): from stepaudio2.token2wav import Token2wav extra = self._extra_config() + model_root = self._resolve_model_root() prompt_path = Path(self._default_prompt_wav) if not prompt_path.is_file(): raise FileNotFoundError(f"MiniCPM-o Code2Wav prompt audio not found: {prompt_path}") - token2wav_path = Path(self.model_path) / "assets" / "token2wav" + token2wav_path = model_root / "assets" / "token2wav" if not token2wav_path.is_dir(): raise FileNotFoundError(f"MiniCPM-o Code2Wav assets not found: {token2wav_path}") use_float16 = bool(extra.get("token2wav_float16", False)) diff --git a/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni.py b/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni.py index c9083c261..f2733f3b7 100644 --- a/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni.py +++ b/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni.py @@ -175,7 +175,6 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp token_ids = self._minicpmo45_native_duplex_token_ids() listen_id = int(token_ids.get("listen_token_id", -1)) - tts_bos_id = int(token_ids.get("tts_bos_token_id", -1)) turn_eos_id = int(token_ids.get("turn_eos_token_id", -1)) if listen_id < 0 or listen_id >= logits.shape[-1]: return @@ -199,7 +198,6 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp continue force_listen = payload.get("force_listen") is True is_speech = payload.get("is_speech") - redirect_listen = False segment_key = (row.request_id, row.seq if row.seq is not None else -1) session_key = (row.session_id, row.incarnation) if row.session_id is not None else None if turn_eos_id >= 0 and session_key is not None: @@ -209,8 +207,6 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp ) if is_speech is True: if state is not None: - if not getattr(state, "current_turn_ended", True): - redirect_listen = True with suppress(Exception): state.last_terminator_token = None else: @@ -218,7 +214,7 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp if turn_ended and not pending_speech_context: force_listen = True - if not force_listen and not redirect_listen: + if not force_listen: continue if force_listen and segment_key in force_listen_segments: continue @@ -226,10 +222,6 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp logits[row_idx, :] = float("-inf") logits[row_idx, listen_id] = 0.0 force_listen_segments.add(segment_key) - elif 0 <= tts_bos_id < logits.shape[-1]: - if logits[row_idx, listen_id] > logits[row_idx, tts_bos_id]: - logits[row_idx, tts_bos_id] = logits[row_idx, listen_id] - logits[row_idx, listen_id] = float("-inf") # -------------------- Device utilities -------------------- @staticmethod @@ -730,6 +722,10 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp output_token_ids = getattr(sampling_metadata, "output_token_ids", None) or [] raw_recent_tokens = output_token_ids[row_idx] if row_idx < len(output_token_ids) else [] recent_tokens = [int(token_id) for token_id in raw_recent_tokens if isinstance(token_id, int) and token_id >= 0] + temperature = float(self._sampling_metadata_value(sampling_metadata, "temperature", row_idx, 0.7)) + top_k = int(self._sampling_metadata_value(sampling_metadata, "top_k", row_idx, 100)) + top_p = float(self._sampling_metadata_value(sampling_metadata, "top_p", row_idx, 0.8)) + state = self._minicpmo45_duplex_state_for_row(row_idx) if chunk_eos_id >= 0 and chunk_eos_id < logits.shape[-1]: max_speak_tokens = int( getattr( @@ -765,23 +761,19 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp if valid_forbidden: logits[:, valid_forbidden] = float("-inf") - special_ids = self._minicpmo45_native_special_token_ids(token_ids) - state = self._minicpmo45_duplex_state_for_row(row_idx) - generated_text_tokens = getattr(state, "generated_text_tokens", None) - repetition_tokens = generated_text_tokens if generated_text_tokens else recent_tokens + generated_tokens = getattr(state, "generated_tokens", None) + repetition_tokens = generated_tokens if generated_tokens else recent_tokens repetition_penalty = 1.05 if repetition_penalty != 1.0 and repetition_tokens: history_size = MiniCPMO45DuplexPolicy.REPETITION_HISTORY_SIZE for token_id in set(repetition_tokens[-history_size:]): - if token_id in special_ids or token_id < 0 or token_id >= logits.shape[-1]: + if token_id < 0 or token_id >= logits.shape[-1]: continue logits[0, token_id] /= repetition_penalty - temperature = float(self._sampling_metadata_value(sampling_metadata, "temperature", row_idx, 0.7)) - top_k = int(self._sampling_metadata_value(sampling_metadata, "top_k", row_idx, 100)) - top_p = float(self._sampling_metadata_value(sampling_metadata, "top_p", row_idx, 0.8)) if getattr(sampling_metadata, "all_greedy", False) or temperature <= 0: sampled = int(torch.argmax(logits, dim=-1).item()) + self._record_minicpmo45_duplex_generation_token(row_idx, sampled) sampled = self._maybe_cut_minicpmo45_native_duplex_text_chunk( sampled, recent_tokens, @@ -793,6 +785,7 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp logits = self._top_k_top_p_filter(logits, top_k=top_k, top_p=top_p) probs = F.softmax(logits, dim=-1) sampled = int(torch.multinomial(probs, num_samples=1, generator=generator).item()) + self._record_minicpmo45_duplex_generation_token(row_idx, sampled) sampled = self._maybe_cut_minicpmo45_native_duplex_text_chunk( sampled, recent_tokens, @@ -901,6 +894,28 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp return int(tts_bos_id) return int(sampled) + def _record_minicpmo45_duplex_generation_token(self, row_idx: int, sampled: int) -> None: + """Track tokens returned by the model-policy decoder. + + The released StreamDecoder is constructed without special_token_ids, so + every normally decoded token participates in repetition penalty. Forced + listen bypasses that decoder, while chunk_eos boundary decisions return + before this method is called. + """ + state = self._minicpmo45_duplex_state_for_row(row_idx) + if state is None: + return + payload = self._minicpmo45_duplex_payload_for_row(row_idx) + if isinstance(payload, dict) and payload.get("force_listen") is True: + return + generated_tokens = getattr(state, "generated_tokens", None) + if not isinstance(generated_tokens, list): + generated_tokens = [] + state.generated_tokens = generated_tokens + generated_tokens.append(int(sampled)) + history_size = MiniCPMO45DuplexPolicy.REPETITION_HISTORY_SIZE + del generated_tokens[:-history_size] + def _record_minicpmo45_duplex_terminator(self, row_idx: int, sampled: int, token_ids: dict[str, int]) -> None: """Remember sampled unit state for the next append. @@ -928,15 +943,6 @@ class MiniCPMO45OmniForConditionalGeneration(nn.Module, SupportsMultiModal, Supp with suppress(Exception): state.pending_speech_response_open = False return - special_ids = self._minicpmo45_native_special_token_ids(token_ids) - if sampled not in special_ids: - generated_text_tokens = getattr(state, "generated_text_tokens", None) - if not isinstance(generated_text_tokens, list): - generated_text_tokens = [] - state.generated_text_tokens = generated_text_tokens - generated_text_tokens.append(int(sampled)) - history_size = MiniCPMO45DuplexPolicy.REPETITION_HISTORY_SIZE - del generated_text_tokens[:-history_size] if ( sampled == tts_bos_id and getattr(state, "current_turn_ended", True) diff --git a/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_tts.py b/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_tts.py index 1ff37a88e..02b0bf91f 100644 --- a/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_tts.py +++ b/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_tts.py @@ -43,6 +43,7 @@ _CODEC_TOP_K = 25 _CODEC_TOP_P = 0.85 _CODEC_REPETITION_PENALTY = 1.05 _CODEC_MIN_TOKENS = 50 +_DUPLEX_CODEC_TOKENS_PER_CHUNK = 26 def _max_audio_tokens(condition_tokens: int) -> int: @@ -61,12 +62,7 @@ def _max_audio_tokens(condition_tokens: int) -> int: def _restore_weight_norm_weight(weight_g: torch.Tensor, weight_v: torch.Tensor) -> torch.Tensor: """Materialize ``weight_norm(..., dim=0)`` checkpoint parameters.""" - row_norm = torch.linalg.vector_norm( - weight_v.float(), - dim=1, - keepdim=True, - ).clamp_min(torch.finfo(torch.float32).tiny) - return weight_v * (weight_g.to(dtype=weight_v.dtype) / row_norm.to(dtype=weight_v.dtype)) + return torch._weight_norm(weight_v, weight_g, dim=0) def _apply_repetition_penalty( @@ -218,6 +214,8 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP): self, tts_token_ids: torch.Tensor, tts_hidden_states: torch.Tensor, + *, + native_duplex: bool = False, ) -> torch.Tensor: if tts_token_ids.numel() == 0 or tts_hidden_states.numel() == 0: # The thinker can legally emit an empty speech segment (<|tts_bos|> @@ -239,7 +237,12 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP): hidden_embeds = self.projector_semantic(hidden) if self._normalize: hidden_embeds = F.normalize(hidden_embeds, p=2, dim=-1) - return torch.cat([text_embeds + hidden_embeds, self._boundary_embeddings()], dim=0) + audio_bos = self.emb_text(torch.tensor([self._tts_bos_id], device=device, dtype=torch.long)) + condition = text_embeds + hidden_embeds + if native_duplex: + # Match MiniCPMTTS.generate_chunk's streaming condition. + return torch.cat([condition, audio_bos], dim=0) + return torch.cat([condition, self._boundary_embeddings()], dim=0) def preprocess( self, @@ -281,18 +284,22 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP): "MiniCPM-o Talker received an empty condition (request %s); this request produces no audio.", info_dict.get("request_id"), ) - full_embeds = self._build_condition_embeddings(token_ids, hidden_states) + native_duplex = bool(info_dict.get("native_duplex", False)) + full_embeds = self._build_condition_embeddings( + token_ids, + hidden_states, + native_duplex=native_duplex, + ) offset = int(info_dict.get("_omni_num_computed_tokens", 0)) - if offset == 0 and span_len > full_embeds.shape[0]: - # Async-chunk prewarms Stage 1 with placeholder token IDs. Two - # Thinker handoffs can arrive before the first one executes, - # so the scheduler prompt may already include the newer - # placeholder prefix while additional_information still - # carries the preceding condition. Materialize that prefix as - # the embeddings of its actual zero token IDs and keep the - # complete Talker condition tail-aligned (audio BOS remains - # the final prefill token). - prefix_len = span_len - full_embeds.shape[0] + request_id = str(info_dict.get("request_id", "0")) + meta = info_dict.get("meta") + # The handoff rebuilds only the tail-aligned Talker condition. + # Materialize zero-token embeddings for any scheduler prompt + # prefix so chunked prefill can slice from a non-zero offset. + prompt_len = info_dict.get("_omni_prompt_len") + target_len = int(prompt_len) if prompt_len is not None else offset + span_len + prefix_len = target_len - full_embeds.shape[0] + if prefix_len > 0: placeholder_ids = torch.zeros( prefix_len, dtype=torch.long, @@ -308,13 +315,21 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP): f"tts_ids={token_ids.shape[0]} tts_hidden={hidden_states.shape[0]} " f"prompt_len={info_dict.get('_omni_prompt_len')}" ) - max_tokens = _max_audio_tokens(int(token_ids.numel())) + duplex_boundary = isinstance(meta, dict) and ( + bool(meta.get("turn_start", False)) or bool(meta.get("turn_end", False)) + ) + if native_duplex: + max_tokens = _DUPLEX_CODEC_TOKENS_PER_CHUNK + min_tokens = 0 if duplex_boundary else _DUPLEX_CODEC_TOKENS_PER_CHUNK + else: + max_tokens = _max_audio_tokens(int(token_ids.numel())) + min_tokens = self._codec_min_tokens state = { "step": 0, "max_tokens": max_tokens, + "min_tokens": min_tokens, "finished": empty_condition, } - request_id = str(info_dict.get("request_id", "0")) request_states = getattr(self, "_request_audio_states", None) if request_states is None: request_states = {} @@ -370,7 +385,12 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP): penalty=self._codec_repetition_penalty, window_size=_REPETITION_WINDOW, ) - if step < self._codec_min_tokens: + request_states = getattr(self, "_request_audio_states", {}) + state = request_states.get(request_id) + min_tokens = ( + int(state.get("min_tokens", self._codec_min_tokens)) if isinstance(state, dict) else self._codec_min_tokens + ) + if step < min_tokens: logits[..., eos_id] = float("-inf") logits = _apply_top_k_top_p( logits, @@ -404,12 +424,61 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP): raise RuntimeError( f"MiniCPM-o continuous Talker received {len(sample_eligible)} sampling flags for {len(infos)} requests" ) + emit_duplex_metadata = any(isinstance(info, dict) and info.get("native_duplex") is True for info in infos) stop_rows: list[torch.Tensor] = [] codec_deltas: list[torch.Tensor] = [] terminal_flags: list[torch.Tensor] = [] + native_duplex_flags: list[torch.Tensor] = [] + duplex_epochs: list[torch.Tensor] = [] + duplex_turn_ids: list[torch.Tensor] = [] + segment_texts_utf8: list[torch.Tensor] = [] + turn_end_flags: list[torch.Tensor] = [] empty_delta = hidden.new_empty((0, 1), dtype=torch.long) for index, info in enumerate(infos): + info_dict = info if isinstance(info, dict) else {} + native_duplex = info_dict.get("native_duplex") is True + if emit_duplex_metadata: + duplex_info = info_dict.get("duplex") + if not isinstance(duplex_info, dict): + duplex_info = {} + epoch = duplex_info.get("epoch", -1) + turn_id = duplex_info.get("turn_id", -1) + if native_duplex and not all( + isinstance(value, int) and not isinstance(value, bool) and value >= 0 for value in (epoch, turn_id) + ): + raise RuntimeError( + "MiniCPM-o native duplex Talker requires non-negative integer " + f"epoch and turn_id, got epoch={epoch!r}, turn_id={turn_id!r}" + ) + meta_info = info_dict.get("meta") + if not isinstance(meta_info, dict): + meta_info = {} + segment_text = meta_info.get("native_duplex_segment_text", "") if native_duplex else "" + if not isinstance(segment_text, str): + segment_text = "" + turn_eos_id = meta_info.get("turn_eos_token_id") + ids_info = info_dict.get("ids") + tts_ids = ids_info.get("tts") if native_duplex and isinstance(ids_info, dict) else None + if isinstance(tts_ids, torch.Tensor): + contains_turn_eos = isinstance(turn_eos_id, int) and bool( + torch.any(tts_ids.reshape(-1) == turn_eos_id).item() + ) + elif isinstance(tts_ids, (list, tuple)): + contains_turn_eos = isinstance(turn_eos_id, int) and turn_eos_id in tts_ids + else: + contains_turn_eos = False + native_duplex_flags.append(torch.tensor(native_duplex, dtype=torch.bool)) + duplex_epochs.append(torch.tensor(epoch if isinstance(epoch, int) else -1, dtype=torch.long)) + duplex_turn_ids.append(torch.tensor(turn_id if isinstance(turn_id, int) else -1, dtype=torch.long)) + segment_texts_utf8.append( + torch.tensor( + list(segment_text.encode("utf-8")), + dtype=torch.uint8, + ) + ) + turn_end_flags.append(torch.tensor(native_duplex and contains_turn_eos, dtype=torch.bool)) + if not isinstance(info, dict): stop_rows.append(hidden.new_tensor([0.0, float("-inf")])) codec_deltas.append(empty_delta) @@ -453,12 +522,15 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP): codes = codes.to(device=hidden.device, dtype=torch.long).reshape(-1) step = int(state.get("step", 0)) sampled = self._sample_audio_code(hidden[end - 1 : end], codes, request_id, step) - is_eos = int(sampled.item()) == self._num_audio_tokens - 1 + sampled_id = int(sampled.item()) + is_eos = sampled_id == self._num_audio_tokens - 1 state["step"] = int(state.get("step", 0)) + 1 reached_limit = int(state["step"]) >= int(state.get("max_tokens", 2048)) finished = is_eos or reached_limit state["finished"] = finished - if not is_eos: + # MiniCPMTTS.generate_chunk consumes the boundary sample but + # returns only codes that were fed into the retained KV state. + if not is_eos and not reached_limit: codes = torch.cat([codes[-(_REPETITION_WINDOW - 1) :], sampled.reshape(1)]) delta = sampled.reshape(1, 1) else: @@ -476,9 +548,20 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP): self._batch_stop_logits = torch.stack(stop_rows, dim=0) if stop_rows else hidden.new_empty((0, 2)) # Lists are deliberate: the runner routes element i to request i, # preserving compaction alignment while emitting only this step's code. + meta_outputs = {"finished": terminal_flags} + if emit_duplex_metadata: + meta_outputs.update( + { + "native_duplex": native_duplex_flags, + "duplex_epoch": duplex_epochs, + "duplex_turn_id": duplex_turn_ids, + "llm_output_text_utf8": segment_texts_utf8, + "turn_end": turn_end_flags, + } + ) multimodal_outputs: dict[str, Any] = { "codes": {"audio": codec_deltas}, - "meta": {"finished": terminal_flags}, + "meta": meta_outputs, } return OmniOutput( text_hidden_states=hidden, diff --git a/vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py b/vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py index 950d5434d..d12a3b382 100644 --- a/vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py +++ b/vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py @@ -21,6 +21,7 @@ logger = logging.getLogger(__name__) _MINICPMO45_ASYNC_STATE = "_minicpmo45_async_codec_state" _MINICPMO45_STREAM_RECORD = "_minicpmo45_async_stream_record" _MINICPMO45_SILENCE_CODE = 4218 +_MINICPMO45_MIN_STREAM_BODY_FRAMES = 5 class _MiniCPMO45MetaStruct(MetaStruct): @@ -28,6 +29,7 @@ class _MiniCPMO45MetaStruct(MetaStruct): ref_audio_sr: int | None = None native_duplex_segment_text: str | None = None + llm_output_text_utf8: torch.Tensor | None = None duplex_turn_id: int | None = None duplex_epoch: int | None = None segment_end: bool | None = None @@ -212,6 +214,16 @@ def tts2code2wav_async_chunk( internal_id = getattr(request, "request_id", None) request_id = str(external_id if external_id is not None else internal_id) internal_id = str(internal_id if internal_id is not None else request_id) + output_meta = multimodal_output.get("meta") if isinstance(multimodal_output, Mapping) else None + output_meta = output_meta if isinstance(output_meta, Mapping) else {} + native_duplex = bool(_coerce_int(output_meta.get("native_duplex"))) + duplex_epoch = _coerce_int(output_meta.get("duplex_epoch")) + duplex_turn_id = _coerce_int(output_meta.get("duplex_turn_id")) + segment_text_utf8 = output_meta.get("llm_output_text_utf8") + if not isinstance(segment_text_utf8, torch.Tensor): + segment_text_utf8 = None + turn_end = bool(_coerce_int(output_meta.get("turn_end"))) + duplex_turn_key = (duplex_epoch, duplex_turn_id) request_payload = getattr(transfer_manager, "request_payload", None) if request_payload is None: @@ -227,7 +239,9 @@ def tts2code2wav_async_chunk( record = { "internal_id": internal_id, "cache_epoch": 0, + "chunk_seq": 0, "retired_internal_ids": set(), + "last_terminal_turn": None, } container[_MINICPMO45_STREAM_RECORD] = record elif internal_id in record["retired_internal_ids"]: @@ -236,6 +250,8 @@ def tts2code2wav_async_chunk( record["retired_internal_ids"].add(record["internal_id"]) record["internal_id"] = internal_id record["cache_epoch"] = int(record["cache_epoch"]) + 1 + record["chunk_seq"] = 0 + record["last_terminal_turn"] = None _drop_codec_state(transfer_manager, request_id) if _is_aborted(request): @@ -243,44 +259,46 @@ def tts2code2wav_async_chunk( _drop_codec_state(transfer_manager, request_id) return None + if native_duplex and turn_end and record.get("last_terminal_turn") == duplex_turn_key: + return None + state = container.get(_MINICPMO45_ASYNC_STATE) if not isinstance(state, dict): - request_info = getattr(request, "additional_information", None) - if not isinstance(request_info, Mapping): - request_info = {} - codes_info = request_info.get("codes") - if not isinstance(codes_info, Mapping): - codes_info = {} - meta_info = request_info.get("meta") - if not isinstance(meta_info, Mapping): - meta_info = {} - duplex_info = request_info.get("duplex") - if not isinstance(duplex_info, Mapping): - duplex_info = {} state = { "internal_id": internal_id, "pending": [], + "pending_text_utf8": [], + "segment_text_recorded": False, "left_context": [], "codec_end": 0, - "ref_audio": codes_info.get("ref"), - "ref_audio_sr": meta_info.get("ref_audio_sr"), - "segment_text": meta_info.get("native_duplex_segment_text"), - "segment_end": bool(meta_info.get("segment_end", False)), - "turn_end": bool(meta_info.get("turn_end", False)), - "duplex_turn_id": duplex_info.get("model_turn_id", duplex_info.get("turn_id")), - "duplex_epoch": duplex_info.get("epoch"), } container[_MINICPMO45_ASYNC_STATE] = state pending = state["pending"] pending.extend(_extract_codec_delta(multimodal_output, request_id)) + pending_text_utf8 = state.setdefault("pending_text_utf8", []) + current_text_utf8 = ( + segment_text_utf8.detach().to(device="cpu", dtype=torch.uint8).reshape(-1).tolist() + if isinstance(segment_text_utf8, torch.Tensor) + else [] + ) + if native_duplex and current_text_utf8 and not state.get("segment_text_recorded", False): + # Talker repeats the unit text on every codec step. Queue it once for + # the first Code2Wav payload that can carry audio for this unit. + pending_text_utf8.extend(current_text_utf8) + state["segment_text_recorded"] = True request_finished = getattr(request, "is_finished", None) finished = bool(is_finished or (callable(request_finished) and request_finished())) chunk_frames, left_context_frames = _codec_config(transfer_manager) - if not finished and len(pending) < chunk_frames: + flush_pending = finished + last_chunk = bool(flush_pending and (not native_duplex or turn_end)) + if not flush_pending and len(pending) < chunk_frames: return None - new_token_count = len(pending) if finished else chunk_frames + hold_short_unit = ( + native_duplex and flush_pending and not last_chunk and 0 < len(pending) < _MINICPMO45_MIN_STREAM_BODY_FRAMES + ) + new_token_count = 0 if hold_short_unit else (len(pending) if flush_pending else chunk_frames) new_codes = pending[:new_token_count] del pending[:new_token_count] codec_start = int(state["codec_end"]) @@ -292,53 +310,79 @@ def tts2code2wav_async_chunk( else: context = list(state["left_context"]) output_codes = [*context, *new_codes] - history = [*state["left_context"], *new_codes] + history = output_codes state["left_context"] = history[-left_context_frames:] if left_context_frames else [] - elif finished and codec_start > 0 and state["left_context"]: + elif last_chunk and codec_start > 0 and state["left_context"]: context = list(state["left_context"]) output_codes = context else: context = [] output_codes = [] state["codec_end"] = codec_end + code_flat_numel = len(output_codes) + if native_duplex and code_flat_numel > 0: + segment_text_utf8 = torch.tensor(pending_text_utf8, dtype=torch.uint8) + pending_text_utf8.clear() + if native_duplex and finished: + state["segment_text_recorded"] = False + if flush_pending and not last_chunk and code_flat_numel == 0: + # Keep the generic generation connector model-agnostic: a real token + # makes this control-only TTS boundary schedulable, while the explicit + # zero length tells Code2Wav to discard the placeholder. + output_codes = [0] - last_chunk = bool(finished and not pending) - if last_chunk: + if last_chunk and not native_duplex: record["retired_internal_ids"].add(internal_id) _drop_codec_state(transfer_manager, request_id) - chunk_seq = int(getattr(transfer_manager, "put_req_chunk", {}).get(request_id, 0)) + chunk_seq = int(record["chunk_seq"]) + record["chunk_seq"] = chunk_seq + 1 + ref_audio = None + ref_audio_sr = None + if int(record["cache_epoch"]) == 0 and chunk_seq == 0: + request_info = getattr(request, "additional_information", None) + if isinstance(request_info, Mapping): + codes_info = request_info.get("codes") + meta_info = request_info.get("meta") + raw_ref_audio = codes_info.get("ref") if isinstance(codes_info, Mapping) else None + raw_ref_audio_sr = meta_info.get("ref_audio_sr") if isinstance(meta_info, Mapping) else None + ref_audio_sr = _coerce_int(raw_ref_audio_sr) + if raw_ref_audio is not None: + ref_audio = torch.as_tensor(raw_ref_audio, dtype=torch.float32).reshape(-1).cpu() finished_tensor = torch.tensor(last_chunk, dtype=torch.bool) - ref_audio = state.get("ref_audio") if codec_start == 0 else None - return OmniPayloadStruct( + payload = OmniPayloadStruct( codes=CodesStruct( audio=torch.tensor(output_codes, dtype=torch.long), - ref=torch.as_tensor(ref_audio, dtype=torch.float32).reshape(-1) if ref_audio is not None else None, + ref=ref_audio, ), meta=_MiniCPMO45MetaStruct( request_id=request_id, chunk_seq=chunk_seq, cache_epoch=int(record["cache_epoch"]), - code_flat_numel=len(output_codes), + code_flat_numel=code_flat_numel, codec_chunk_frames=new_token_count, codec_left_context_frames=len(context), left_context_size=len(context), last_chunk=last_chunk, stream_finished=finished_tensor, finished=finished_tensor, + is_segment_finished=finished_tensor, req_id=[request_id], - ref_audio_sr=_coerce_int(state.get("ref_audio_sr")), - native_duplex_segment_text=( - str(state["segment_text"]) if isinstance(state.get("segment_text"), str) else None - ), - duplex_turn_id=_coerce_int(state.get("duplex_turn_id")), - duplex_epoch=_coerce_int(state.get("duplex_epoch")), - segment_end=bool(state.get("segment_end", False)), - turn_end=bool(state.get("turn_end", False)), - tts_is_last_chunk=last_chunk, + duplex_epoch=duplex_epoch, + duplex_turn_id=duplex_turn_id, + llm_output_text_utf8=segment_text_utf8, + tts_is_last_chunk=flush_pending, + turn_end=turn_end and last_chunk, + ref_audio_sr=ref_audio_sr, ), request_id=request_id, ) + if last_chunk and native_duplex: + record["last_terminal_turn"] = duplex_turn_key + record["cache_epoch"] = int(record["cache_epoch"]) + 1 + record["chunk_seq"] = 0 + _drop_codec_state(transfer_manager, request_id) + return payload def tts2code2wav_full_payload( @@ -496,13 +540,33 @@ def _native_tts_boundary_token_ids(special_token_ids): } +def _decode_native_duplex_token_ids( + token_ids: list[int], + streaming_context, + *, + request_id: str, +) -> str | None: + decode_token_ids = getattr(streaming_context, "source_token_decoder", None) + if not isinstance(decode_token_ids, Callable): + return None + decode_ids = [int(token_id) for token_id in token_ids] + try: + try: + return str(decode_token_ids(decode_ids, skip_special_tokens=True)) + except TypeError: + return str(decode_token_ids(decode_ids)) + except Exception: + logger.exception("Failed to decode MiniCPM-o duplex token delta for request_id=%s", request_id) + return "" + + def _native_duplex_segment_output_ids( output_ids: list[int], output_text: str, streaming_context, *, request_id: str, -) -> tuple[list[int], str]: +) -> tuple[list[int], str, bool]: """Slice the cumulative thinker output down to the current segment. Tracks how many output tokens were already handed to the talker in the @@ -511,7 +575,7 @@ def _native_duplex_segment_output_ids( """ bridge_states = getattr(streaming_context, "bridge_states", None) if not isinstance(bridge_states, dict): - return output_ids, output_text + return output_ids, output_text, True state = bridge_states.setdefault("minicpmo45_tts_handoff", {}) duplex_state = bridge_states.get("duplex") turn_id = duplex_state.get("model_turn_id", duplex_state.get("turn_id")) if isinstance(duplex_state, dict) else None @@ -521,17 +585,12 @@ def _native_duplex_segment_output_ids( state["request_id"] = request_id state["sent_output_len"] = 0 state["sent_output_ids"] = [] - state["acc_tts_ids"] = [] - state["acc_tts_hidden"] = [] + previous_turn_id = state.get("turn_id") sent_len = state.get("sent_output_len", 0) prev_output_ids = state.get("sent_output_ids", []) - prev_turn_id = state.get("turn_id") if not isinstance(sent_len, int) or sent_len < 0 or sent_len > len(output_ids): - # Shrunken cumulative output = epoch reset after barge-in: the talker - # condition history is stale too. + # Shrunken cumulative output = epoch reset after barge-in. sent_len = 0 - state["acc_tts_ids"] = [] - state["acc_tts_hidden"] = [] elif sent_len and isinstance(prev_output_ids, list): prev_prefix = prev_output_ids[:sent_len] current_prefix = output_ids[:sent_len] @@ -540,27 +599,15 @@ def _native_duplex_segment_output_ids( # others keep returning cumulative ids. Detect restart from the # token prefix instead of clearing the cursor at turn_eos. sent_len = 0 - state["acc_tts_ids"] = [] - state["acc_tts_hidden"] = [] - if isinstance(prev_turn_id, int) and isinstance(turn_id, int) and prev_turn_id != turn_id: - # The thinker output list is cumulative across clean turns, so the - # output cursor must stay put. The talker condition is per assistant - # turn, though; carrying it across turn_id boundaries makes stage 1 - # replay the previous turn after its consumed cursor has been reset. - state["acc_tts_ids"] = [] - state["acc_tts_hidden"] = [] + turn_start = sent_len == 0 or previous_turn_id != turn_id segment_ids = output_ids[sent_len:] - decode_token_ids = getattr(streaming_context, "source_token_decoder", None) - if isinstance(decode_token_ids, Callable): - decode_ids = [int(token_id) for token_id in segment_ids] - try: - try: - segment_text = str(decode_token_ids(decode_ids, skip_special_tokens=True)) - except TypeError: - segment_text = str(decode_token_ids(decode_ids)) - except Exception: - logger.exception("Failed to decode MiniCPM-o duplex token delta for request_id=%s", request_id) - segment_text = "" + decoded_segment_text = _decode_native_duplex_token_ids( + segment_ids, + streaming_context, + request_id=request_id, + ) + if decoded_segment_text is not None: + segment_text = decoded_segment_text elif sent_len == 0: # Non-orchestrator unit tests and legacy callers may not install a # decoder. Never slice cumulative text with a stale character cursor. @@ -575,22 +622,7 @@ def _native_duplex_segment_output_ids( state["sent_output_len"] = len(output_ids) state["sent_output_ids"] = list(output_ids) state["turn_id"] = turn_id - return segment_ids, segment_text - - -def _reset_native_tts_handoff(streaming_context) -> None: - bridge_states = getattr(streaming_context, "bridge_states", None) - if not isinstance(bridge_states, dict): - return - state = bridge_states.get("minicpmo45_tts_handoff") - if not isinstance(state, dict): - return - # Keep the output cursor: the thinker can keep reporting cumulative - # output after turn_eos on the same resumable request. If a runtime really - # restarts token ids, _native_duplex_segment_output_ids detects the prefix - # mismatch and resets the slice cursor there. - state["acc_tts_ids"] = [] - state["acc_tts_hidden"] = [] + return segment_ids, segment_text, turn_start def _native_duplex_data_plane_metadata(streaming_context) -> dict[str, object] | None: @@ -623,32 +655,6 @@ def _native_duplex_data_plane_metadata(streaming_context) -> dict[str, object] | return metadata -def _accumulate_native_tts_handoff(streaming_context, new_ids, new_hidden): - """Hand the talker the FULL accumulated condition on every handoff. - - The runner's streaming buffer update is not merge-safe for a resumable - stage-1 request: in-place updates merge sub-keys, but a resume prefill - REPLACES the buffer, silently dropping every earlier segment's tts - tokens/hiddens (observed losing alternating reply segments — the talker - vocalized text it never saw between islands it did). Accumulate here so - the latest handoff always carries the complete history and downstream - replace semantics are lossless; the talker consumes by cursor. - """ - bridge_states = getattr(streaming_context, "bridge_states", None) - if not isinstance(bridge_states, dict): - return new_ids, new_hidden - state = bridge_states.setdefault("minicpmo45_tts_handoff", {}) - acc_ids = state.setdefault("acc_tts_ids", []) - acc_hidden = state.setdefault("acc_tts_hidden", []) - if new_ids: - acc_ids.extend(int(t) for t in new_ids) - if new_hidden: - acc_hidden.extend(new_hidden) - if not acc_ids: - return None, None - return list(acc_ids), list(acc_hidden) - - def _build_tts_scheduler_prompt_token_ids( tts_token_ids: torch.Tensor | None, llm_output_ids: list[int], @@ -723,6 +729,7 @@ def llm2tts( # buffer overflows. llm_output_ids = list(llm_output_ids) thinker_text = getattr(output, "text", "") or "" + native_turn_start = False if _has_native_duplex_prompt_metadata(mm_output): # The thinker's resumable duplex request reports cumulative # output ids/text, but earlier segments are already folded into @@ -732,7 +739,7 @@ def llm2tts( # alignment holds, the talker prompt grows linearly with new # tokens instead of quadratically, and downstream transcripts # carry per-unit deltas instead of re-sending the whole reply. - llm_output_ids, thinker_text = _native_duplex_segment_output_ids( + llm_output_ids, thinker_text, native_turn_start = _native_duplex_segment_output_ids( llm_output_ids, thinker_text, _streaming_context, @@ -875,6 +882,18 @@ def llm2tts( .to(torch.float32) .contiguous() ) + handoff_ids = _coerce_token_id_list(tts_token_ids_slice) if tts_token_ids_slice is not None else None + if is_native_duplex_handoff and handoff_ids: + handoff_text = _decode_native_duplex_token_ids( + handoff_ids, + _streaming_context, + request_id=str(llm_output.request_id), + ) + if handoff_text is not None: + # Match the released streaming_generate contract: transcript + # text comes from total_ids_in_unit, the same slice that + # conditions the Talker. + thinker_text = handoff_text model_intermediate_buffer = build_duplex_intermediate_buffer( request_id=str(llm_output.request_id), prompt_token_ids=prompt_token_ids, @@ -900,6 +919,7 @@ def llm2tts( # The talker detects turn end from <|turn_eos|> inside the # handed condition (official conditions on its embedding). meta["turn_eos_token_id"] = int(turn_eos_id) + meta["turn_start"] = native_turn_start if native_segment_end: meta["segment_end"] = True req_mm_data = multi_modal_data.get(llm_output.request_id) @@ -911,30 +931,27 @@ def llm2tts( if ref_audio is not None: ref_waveform, ref_sr = ref_audio set_ref_audio(model_intermediate_buffer, _to_transport_list(ref_waveform), ref_sr) - handoff_ids = _coerce_token_id_list(tts_token_ids_slice) if tts_token_ids_slice is not None else None handoff_hidden = _to_transport_list(tts_hidden_slice) if tts_hidden_slice is not None else None native_turn_end_handoff = False if is_native_duplex_handoff: turn_eos_id = special_token_ids.get("turn_eos_token_id") native_turn_end_handoff = turn_eos_id is not None and handoff_ids is not None and turn_eos_id in handoff_ids - handoff_ids, handoff_hidden = _accumulate_native_tts_handoff( - _streaming_context, - handoff_ids, - handoff_hidden, - ) if not handoff_ids: continue set_tts_handoff(model_intermediate_buffer, handoff_ids, handoff_hidden) if native_turn_end_handoff: model_intermediate_buffer.setdefault("meta", {})["turn_end"] = True - _reset_native_tts_handoff(_streaming_context) if handoff_ids is not None and handoff_hidden is not None: - condition_length = max(len(handoff_ids), len(handoff_hidden)) + 2 + condition_suffix_length = 1 if is_native_duplex_handoff else 2 + condition_length = max(len(handoff_ids), len(handoff_hidden)) + condition_suffix_length scheduler_prompt_token_ids = [0] * condition_length handoff_meta = model_intermediate_buffer.setdefault("meta", {}) - handoff_meta["replace_streaming_prompt"] = True handoff_meta["next_stage_prompt_len"] = condition_length + # Native duplex resumes one Talker request within a turn, but a new + # assistant turn must discard the previous turn's prompt and KV. + if not is_native_duplex_handoff or native_turn_start: + handoff_meta["replace_streaming_prompt"] = True else: scheduler_prompt_token_ids = _build_tts_scheduler_prompt_token_ids( tts_token_ids_slice, diff --git a/vllm_omni/model_executor/stage_input_processors/qwen3_omni.py b/vllm_omni/model_executor/stage_input_processors/qwen3_omni.py index edde6972d..7e28b94c3 100644 --- a/vllm_omni/model_executor/stage_input_processors/qwen3_omni.py +++ b/vllm_omni/model_executor/stage_input_processors/qwen3_omni.py @@ -685,19 +685,32 @@ def talker2code2wav_async_chunk( """ Multimodal output version. """ - if not isinstance(multimodal_output, Mapping): - return None - talker_codes = multimodal_output.get("codes", {}) - if not isinstance(talker_codes, dict): - return None - code_predictor_codes = talker_codes.get("audio") - if code_predictor_codes is None: - return None + request_id = request.external_req_id + code_predictor_codes = None + if isinstance(multimodal_output, Mapping): + talker_codes = multimodal_output.get("codes", {}) + if isinstance(talker_codes, dict): + code_predictor_codes = talker_codes.get("audio") - if code_predictor_codes.numel() == 0: - return None + sampling_params = getattr(request, "sampling_params", None) + stop_token_ids = set(getattr(sampling_params, "stop_token_ids", None) or []) + stop_token_id = getattr(sampling_params, "stop_token_id", None) + if stop_token_id is not None: + stop_token_ids.add(stop_token_id) - if not code_predictor_codes.any(): + append_codes = ( + isinstance(code_predictor_codes, torch.Tensor) + and code_predictor_codes.numel() > 0 + and bool(code_predictor_codes.any()) + ) + if append_codes: + first_codebook = int(code_predictor_codes[0, 0].item()) + if first_codebook in stop_token_ids: + logger.debug("skip stop-token codec frame: first_codebook=%s", first_codebook) + append_codes = False + if append_codes: + transfer_manager.code_prompt_token_ids[request_id].append(code_predictor_codes) + elif not is_finished: return None connector = getattr(transfer_manager, "connector", None) @@ -707,20 +720,10 @@ def talker2code2wav_async_chunk( left_context_size_config = int(cfg.get("codec_left_context_frames", 25)) configured_initial_chunk_size = int(cfg.get("initial_codec_chunk_frames") or 0) - sampling_params = getattr(request, "sampling_params", None) - stop_token_ids = set(getattr(sampling_params, "stop_token_ids", None) or []) - stop_token_id = getattr(sampling_params, "stop_token_id", None) - if stop_token_id is not None: - stop_token_ids.add(stop_token_id) - first_codebook = int(code_predictor_codes[0, 0].item()) - if first_codebook in stop_token_ids: - logger.debug("skip stop-token codec frame: first_codebook=%s", first_codebook) - return None - - request_id = request.external_req_id chunk_id = transfer_manager.put_req_chunk[request_id] - transfer_manager.code_prompt_token_ids[request_id].append(code_predictor_codes) length = len(transfer_manager.code_prompt_token_ids[request_id]) + if length <= 0: + return None if configured_initial_chunk_size > 0: if chunk_id == 0: @@ -732,6 +735,9 @@ def talker2code2wav_async_chunk( if chunk_length != 0 and not is_finished: return None + if is_finished and not append_codes and chunk_length == 0: + return None + context_length = chunk_length if chunk_length != 0 else chunk_size_config # ensure left context does not exceed available length if configured_initial_chunk_size > 0 and chunk_id == 1: diff --git a/vllm_omni/outputs/multimodal_accumulation.py b/vllm_omni/outputs/multimodal_accumulation.py index 08b2551bc..715f6366c 100644 --- a/vllm_omni/outputs/multimodal_accumulation.py +++ b/vllm_omni/outputs/multimodal_accumulation.py @@ -11,6 +11,7 @@ CHUNK_METADATA_KEYS: frozenset[str] = frozenset( "duplex_epoch", "duplex_turn_id", "llm_output_text_utf8", + "segment_end", "tts_is_last_chunk", "turn_end", } diff --git a/vllm_omni/outputs/output_processor.py b/vllm_omni/outputs/output_processor.py index ebfe0689a..4d06f4124 100644 --- a/vllm_omni/outputs/output_processor.py +++ b/vllm_omni/outputs/output_processor.py @@ -523,24 +523,27 @@ class MultimodalOutputProcessor(VLLMOutputProcessor): upstream_outputs.append(eco) # Handle multimodal-only outputs (generation stages) locally. - self._process_mm_only_outputs(mm_only_outputs) + mm_request_outputs = self._process_mm_only_outputs(mm_only_outputs) # Delegate text/pooling outputs to upstream. - return super().process_outputs( + processed = super().process_outputs( upstream_outputs, engine_core_timestamp=engine_core_timestamp, iteration_stats=iteration_stats, ) + processed.request_outputs.extend(mm_request_outputs) + return processed def _process_mm_only_outputs( self, engine_core_outputs: list[EngineCoreOutput], - ) -> None: + ) -> list[OmniRequestOutput | PoolingRequestOutput]: """Handle outputs from generation stages that have no detokenizer. These cannot go through upstream process_outputs because it asserts detokenizer is not None when pooling_output is None. """ + request_outputs: list[OmniRequestOutput | PoolingRequestOutput] = [] for eco in engine_core_outputs: req_state = self.request_states.get(eco.request_id) if req_state is None or not isinstance(req_state, OmniRequestState): @@ -554,6 +557,11 @@ class MultimodalOutputProcessor(VLLMOutputProcessor): req_state.num_cached_tokens = eco.num_cached_tokens req_state.is_prefilling = False + is_non_final_audio_chunk = ( + finish_reason is not None + and req_state.output_kind == RequestOutputKind.DELTA + and is_non_final_delta_audio_chunk(req_state.mm_accumulated, req_state.mm_type) + ) if request_output := req_state.make_request_output( new_token_ids, None, # pooling_output @@ -564,9 +572,13 @@ class MultimodalOutputProcessor(VLLMOutputProcessor): ): if req_state.queue is not None: req_state.queue.put(request_output) + else: + request_outputs.append(request_output) - if finish_reason is not None: + is_segment_finished = bool(getattr(eco, "is_segment_finished", False)) + if finish_reason is not None and not is_segment_finished and not is_non_final_audio_chunk: self._finish_request(req_state) + return request_outputs def _update_stats_from_output( self,