Skip to content

[Bugfix] MiniCPM-o 4.5 duplex: let the turn-end Talker condition finish speaking - #8911

Merged
amy-why-3459 merged 9 commits into
vllm-project:mainfrom
twu3202:fix/minicpmo-duplex-turn-end-tail
Oct 20, 2026
Merged

amy-why-3459 merged 9 commits into
vllm-project:mainfrom
twu3202:fix/minicpmo-duplex-turn-end-tail

Conversation

@twu3202

@twu3202 twu3202 commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #8807.

Contributor intent (required human answers)

1. What outcome do you want to achieve?

Fix, or at least mitigate, MiniCPM-o 4.5 not speaking the last 1–3 words in full-duplex mode.

2. What is the highest-leverage variable for that outcome?

WER/CER, and the number of turns whose tail is cut.

3. How will this work move that variable?

By changing the last chunk of each turn: first check whether the turn's text ends a complete sentence, and so on, so that the end of the sentence is timed better, which mitigates the problem.

4. Whose customer or user needs does this address, and what are those needs?

Anyone who uses MiniCPM-o full-duplex and cares about speech completion.

Purpose

In native full duplex the Talker speaks 25 codec frames per unit while the Thinker hands it up to 28 characters, so at turn end the Talker still owes text from earlier units. The turn-end condition has a 104-token budget and masks EOS for 5 steps after each 25-frame boundary, but the Talker still stops on its 25-frame cadence (just before or right after the masked steps) and the owed words are not spoken. Details and measurements are in #8807.

This PR changes only the turn-end condition, and only when the turn's text ends a sentence:

  • The condition is closed with <text_eos>, like the last chunk of TTSStreamingGenerator.generate_with_buffer in the checkpoint's utils.py. The checkpoint's MiniCPMODuplex does not add it, and stops its turn-end chunk after at most 25 codes like every other chunk.
  • EOS is allowed only after two silent codec frames in a row (6 codec ids that are almost always silence), and forced after eight quiet ones in a row (35 ids that are mostly silence or breath). Without the forced stop, the Talker added words after the sentence in 1 of the 192 turns on MRv2 and 4 on V1 (e.g. "…as quiet as the desert. Fowl. Fowl.").
  • A turn whose text stops mid-sentence (e.g. the model stops when the user starts talking) keeps the boundary mask. Draining such a turn until silent ran it to the 104-token cap with made-up speech (V1, first turn of the two-turn session below: 9.4 s → 13.5 s).

#8807 suggests letting every condition run to its own EOS. With a 104-token budget and no floor or mask on every condition, the Talker still stopped at 24–26 frames in 363 of 386 mid-turn conditions (WER 0.0708, see my comment there), so this PR keeps the per-unit pacing. Mid-turn conditions are unchanged.

Changes:

  • stage_input_processors/minicpmo_4_5_omni.py: keep the last 16 characters of the text handed to the Talker in this turn (after the slice at <|turn_eos|>) in the handoff state; set meta["turn_text_ends_sentence"] on every native-duplex unit that hands a condition to the Talker (true only on a turn-end unit when the turn's handed text ends with 。!?.!?…, ignoring closing quotes and brackets); one more prompt slot for <text_eos>.
  • minicpmo_4_5_omni_tts.py: turn_end_drain is now a kind (none / boundary mask / until silent); the stop rule is applied in the MRv2 path, the V1 host path and the multi-frame (K-step) loop from [Bugfix][Ascend][Perf] MiniCPM-o 4.5 Talker multi-frame decode and NPU pipeline optimizations #7929; <text_eos> on a closed turn-end condition.
  • codec_state.py: the same rule in the V1 device kernel, using a per-codec-id flag table.
  • Unit tests for the MRv2, V1 host, V1 device and multi-frame (K-step) paths and for the sentence-end flag.

Related: #8723 (fused MRv2 Talker kernel, tests turn_end_drain == 1) and #8573 (CUDA multi-frame decode, uses bool(state.get("turn_end_drain"))) read the same field. Whichever of these and this PR lands second has to carry kind 2 (until silent) into the other's code.

Test Plan

vLLM Version: 0.31.0

vLLM-Omni Commit: this branch on top of e461c00 (main). The Seed-TTS comparisons below were run on 37f094a (main) vs this change on top of it; after the rebase the Seed-TTS en runs, unit tests and e2e tests were repeated, and the two-turn sessions were run on e461c00 (main) vs this branch. Main (dfdae25) was later merged in, which brought #7929's multi-frame loop; that loop is covered by CPU unit tests only.

One RTX 6000 Ada (48 GB), official openbmb/MiniCPM-o-4_5 weights. To fit 48 GB, minicpmo_4_5.yaml (which minicpmo_4_5_duplex_mrv2.yaml inherits) was cut down locally: max_sessions, every max_num_seqs and max_graph_batch 16 → 4; gpu_memory_utilization 0.55 / 0.15 / 0.18 → 0.62 / 0.13 / 0.15; Stage 0 kv_cache_memory_bytes: 6442450944 on CUDA.

vllm serve openbmb/MiniCPM-o-4_5 --omni --trust-remote-code --deploy-config vllm_omni/deploy/minicpmo_4_5_duplex_mrv2.yaml
# or --deploy-config vllm_omni/deploy/minicpmo_4_5.yaml (V1)

# Seed-TTS en, 48 sessions x 4 turns, concurrency 1, whisper-large-v3
SEED_TTS_WER_EVAL=1 SEED_TTS_WER_SAVE_ITEMS=1 vllm bench serve --omni --backend openai-realtime-tts --endpoint /v1/realtime \
    --dataset-name seed-tts --dataset-path zhaochenyang20/seed-tts-eval --seed-tts-locale en --seed-tts-turns-per-session 4 \
    --num-prompts 48 --max-concurrency 1 --no-oversample --disable-shuffle --trust-remote-code \
    --extra-body '{"save_duplex_request_metrics": true}' --save-result --save-detailed
# zh: --seed-tts-locale zh on minicpmo_4_5_duplex_mrv2.yaml

pytest -s -v tests/e2e/online_serving/test_minicpmo_4_5_duplex.py tests/e2e/online_serving/test_minicpmo_4_5_duplex_mrv2.py --run-level advanced_model
pytest tests/model_executor/models/minicpmo_4_5 tests/model_executor/stage_input_processors/test_minicpmo_4_5_omni.py \
    tests/distributed/omni_connectors/test_chunk_transfer_adapter.py -m "not full_model"

Two-turn sessions: the two-question audio from #8820, 3 sessions per deploy, realtime_duplex_demo.py at temperature 0, transcribed with whisper-large-v3.

Test Result

Seed-TTS en, 192 turns, main 37f094a vs this PR (same deploy and seed; reruns give the same transcripts):

Deploy WER Turns with the tail cut Turns with a silent gap ≥ 400 ms Audio per turn
minicpmo_4_5_duplex_mrv2.yaml main 0.0656 67 5 3.9 s
this PR 0.0261 6 4 4.3 s
minicpmo_4_5.yaml (V1) main 0.0736 58 10 3.9 s
this PR 0.0364 8 12 4.2 s

"Tail cut" = the transcript matches the reference up to some word, then stops or gets the last word wrong; a few of the remaining ones are mispronounced names. Turns that are clean on main but not with this PR: 4 on MRv2 (three single-word misreadings such as "secrecy" → "secrety", and one word dropped mid-sentence) and 1 on V1, where "Catch as catch can." loses "can": main also pauses about 0.2 s before that word, and with this PR the Talker stops in the pause. In the other direction, 64 turns on MRv2 and 53 on V1 go from wrong on main to clean. On V1, turns with a gap ≥ 400 ms go from 10 to 12.

Latency from the same runs (concurrency 1, per 4-turn session): mean E2EL 16.36 → 16.63 s on V1 and 16.29 → 16.62 s on MRv2; mean TTFT 312 → 320 ms and 289 → 291 ms. Mean audio RTF goes down (1.05 → 0.97 and 1.03 → 0.95) because each session has about 1.5 s more audio.

How the 192 turn-end conditions stopped on MRv2: 128 sampled EOS after two silent codes, 60 were stopped after eight quiet codes, and 4 kept the boundary mask because the Thinker's text (which repeats the reference) has no final punctuation. None reached the 104-token cap. Of the 60 quiet stops, one has a higher WER than main, a misreading ("secrety") rather than a lost word.

For reference, the checkpoint's own MiniCPMODuplex on the same 192 turns (same prompts and reference audio; codes turned into audio with this repo's MiniCPMO45Token2wav, since stepaudio2 isn't installed here) cuts the tail in 84 turns, against 67 for main. Its Thinker text is complete in all 84, and its turn-end Talker chunk used the whole 25-code budget in 48 of them. So the owed text comes from the reference duplex design; main's 104-token turn-end budget already reduces it.

Codec ids, measured on MRv2 Talker output from a main run and an earlier run of this rule (Seed-TTS en; a frame counts as silent when it is 35 dB or more below the turn's 90th-percentile frame level). The ids were picked from this data; the zh run below is a check on other data.

Set Silent share per id Share of all silent frames Share of all voiced frames
6 silent ids 95–100% (4137: 95%, 4218: 95%, 4299: 97%, 6324/6405/6486: 100%) 47% 0.32%
35 quiet ids ≥ 60% each 76% 2.0%

Seed-TTS zh, MRv2, 192 turns (48 × 4), scored with paraformer-zh as in seed_tts_eval.py: CER 0.0239 on main and 0.0105 with this PR. Turns with any error go from 33 to 15: 19 go from wrong to clean and 1 from clean to wrong (a name misread); of the 14 wrong on both, 6 get better, 5 stay the same and 3 get worse. Two of those 3 are the same sentence, "全球每年有超过一百三十五万人,因交通事故而死亡。" (two reference voices): main cuts it after "因交通事" or "因交通事故", and this PR stops in the pause at the comma before "因". The third is a single-character misreading.

Two-turn sessions (#8820 audio, e461c00 main vs this branch, 3 sessions each at temperature 0; the 3 sessions of a run have the same length):

  • V1: turn 1 is unchanged. Turn 2 has the same transcript as main; the audio matches main except the last ~0.6 s and ends 0.08 s earlier.
  • MRv2, on one server start: turn 1 (the model stops mid-sentence when the second question starts, so it keeps the boundary mask) is unchanged. Turn 2 matches main up to the end of its last sentence ("…可以去看看哦。"). Main stops there; this PR pauses 0.56 s and says one more "哦" before stopping (turn 2 lasts 15.0 s vs 13.4 s). The quiet stop does not catch this one.
  • The MRv2 session is not stable across server starts, on main too: three starts of main gave 22.8, 17.9 and 18.5 s of session audio, with two different replies in turn 2. On a start whose turn 2 is "…陶瓷馆等等。", this PR matches main up to the end of turn 2 and stops 0.3 s later (turn 2 lasts 8.7 s vs 8.4 s), with the same transcript.

After rebasing on e461c00:

  • Seed-TTS en, both deploys: the same transcript and audio length for all 192 turns as before the rebase.
  • test_minicpmo_4_5_duplex.py 13/13 and test_minicpmo_4_5_duplex_mrv2.py 5/5 (real weights).
  • Unit tests above with CUDA: same results as main except the new tests, which pass. Three tests fail on both with the cut-down deploy (test_deploy_resolves_three_stage_pipeline[minicpmo_4_5.yaml] and two test_mrv2_profile_retains_full_thinker_handoff cases); they pass with the stock YAML.

…sh speaking

The Talker stops on its 25-frame cadence on the turn-end condition as well,
so text sent in earlier units can be left unspoken (vllm-project#8807). When the turn's
text ends a sentence, close the turn-end condition with <text_eos> and let it
stop only after two silent codec frames in a row, forcing a stop after eight
quiet ones. A turn cut off mid-sentence keeps the existing boundary mask.

Signed-off-by: Tianyao Wu <rayroy31@gmail.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@vllm-omni-review-bot

Copy link
Copy Markdown

This PR appears to belong to: docs/design/module/ar_runtime.md, docs/design/module/model_integration.md.

Module owners: @tzhouam @fake0fan @Gaohan123

Routing: @tzhouam via module of the changed files, CODEOWNERS; @fake0fan via module of the changed files; @Gaohan123 via module of the changed files

@twu3202, please review your own changes and leave a short self-review comment describing what you checked. PRs without author self-review may not be assigned a reviewer.

Please take a look when you have a chance. If you would like an automated review, mention @vllm-omni-review-bot in a comment.

Make the next-turn case fail without the turn-start reset of the text tail,
give the MRv2 test Talker the real codec vocabulary size, and use the drain
kind constants in the tests.

Signed-off-by: Tianyao Wu <rayroy31@gmail.com>
@twu3202

twu3202 commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

Self-review:

  • Scope: only the native-duplex turn-end Talker condition changes, and only when the turn's Thinker text ends a sentence. Mid-turn conditions, turn ends cut mid-sentence (same boundary mask as main), offline TTS and the non-native paths are unchanged.
  • turn_end_drain is read in three places: the MRv2 output step and sampler, the V1 host path, and the V1 codec_state kernel. Kind 1 behaves as before in all three. For kind 2, the new CUDA test runs the kernel against the host path, and a CPU script ran the host rule, the kernel's arithmetic and the MRv2 counters over 20k random code sequences with no difference.
  • No host reads on the per-step path. The codec class table (num_audio_tokens + 1 entries) is uploaded once with a pinned non-blocking copy and is never built under graph capture. MRv2 only updates the run counters when a batch has a kind-2 row; a fresh prefill and a resume after preemption both reset them.
  • The extra <text_eos> slot in the Talker prompt length and the model's condition follow the same flag.
  • fb053f3 tightens the turn-start test so it fails when the tail reset is removed (checked by removing it), plus test and docstring tidy-ups. After it: pre-commit is clean on the changed files, and the minicpmo_4_5 and stage-processor unit tests pass on CUDA except the three cut-down-deploy failures listed in the description.

@hsliuustc0106 hsliuustc0106 added tts code related to tts models bug Something isn't working labels Oct 15, 2026

@amy-why-3459 amy-why-3459 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed fb053f3. This is a static review; I have not independently run the model or CUDA tests.

Quality acceptance: The reported aggregate WER/CER improvements are useful, but the reported regressions should also become fixed evaluation cases: stopping at an intra-sentence pause and emitting additional speech after the intended sentence. Please compare truncation, added speech, and tail latency separately, and document maintainer acceptance of the tradeoff or constrain the new policy further. The current rule-level tests establish implementation behavior, not that quiet codec runs reliably identify completion of the outstanding text. These examples come from the PR report, not an independent reproduction.

Validation gap: The visible checks cover builds, static checks, DCO, and documentation. The reported component tests and model evaluations do not establish complete repository unit-test coverage. Please provide complete unit-test evidence on this SHA using the repository's Buildkite-defined CPU groups and required hardware/backend-specific UT jobs: exact commands, environment/dependency versions, passed/failed/skipped/deselected/collection-error counts, and CI or complete log links. Explain skipped tests and unavailable jobs, or obtain an explicit maintainer disposition for those gaps. For apparently pre-existing failures, include a same-environment base/head comparison. After fixes, provide evidence for the updated head. Merge readiness is not yet established by the available UT evidence.

if prefilling is not None:
hits &= ~prefilling[:, None]
runs = self._mrv2_code_runs.index_select(0, slot_ids)
runs = torch.where(hits, runs + 1, torch.zeros_like(runs))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve or reconstruct trailing-code runs when resuming the same condition after preemption.

This clears both counters for every prefilling row, including recomputation after preemption. Recomputation does not start a new condition: if the generated suffix already contains two silent codes, EOS would be eligible without preemption but is masked after this reset. Losing the quiet-code run similarly delays the forced-stop threshold, making stopping behavior depend on scheduling.

Please distinguish a new condition from a resumed condition, resetting only the former and preserving or rebuilding the latter's runs from its generated codec history. Add a regression test comparing EOS decisions for the same codec sequence with and without preemption. test_mrv2_turn_end_drain_resets_runs_on_prefill currently asserts the reset rather than this equivalence.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7e08057. A prefill row now rebuilds the runs from the request's token history (req_states.all_token_ids, the condition's codes before seq_lens) instead of resetting them. A new condition has no codes yet and starts from zero; a request resumed after preemption gets back the runs it had, up to the quiet-stop length, which is all the rule reads.

test_mrv2_turn_end_drain_stops_the_same_way_after_preemption runs the same code sequence with and without a preemption (resumed in another slot) and compares the EOS decisions step by step. It fails with the old reset.

@amy-why-3459 amy-why-3459 added the high priority high priority issue, needs to be done asap label Oct 15, 2026
@amy-why-3459

Copy link
Copy Markdown
Collaborator

@natureofnature @BeatSeat PTAL

A request resumed after preemption re-prefills the codes of the condition it
was draining. Rebuild its silent and quiet runs from the request's token
history (req_states.all_token_ids) rather than resetting them, so the EOS
decisions do not depend on preemption. A new condition has no codes yet and
still starts from zero.

Signed-off-by: Tianyao Wu <rayroy31@gmail.com>
@twu3202

twu3202 commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. P2 is fixed in 7e08057 (reply inline).

Quality. Same runs as the description (192 turns each), main vs this PR:

Truncated turns Turns with added speech Silence after the last voiced frame, p50 / p90 / max
en, MRv2 67 → 6 0 → 0 0.00 → 0.24 / 0.40 → 0.40 / 0.92 → 1.12 s
en, V1 58 → 8 2 → 2 0.04 → 0.20 / 0.40 → 0.36 / 0.60 → 1.04 s
zh, MRv2 16 → 3 0 → 0 0.08 → 0.16 / 0.24 → 0.36 / 0.48 → 1.64 s

The two V1 turns with added speech are the same sentence, and both end in an extra "and" on main too. Mean E2EL per 4-turn session goes up 1.6% (V1) and 2.0% (MRv2).

Fixed cases for the two regressions:

  • Stop in a pause inside the sentence: common_voice_en_18466098-common_voice_en_18466096 ("Catch as catch can.", V1), and 10002850-00000039 / 10004328-00000039 ("全球每年有超过一百三十五万人,因交通事故而死亡。", MRv2; main cuts these two later in the same sentence).
  • Added speech after the sentence: turn 2 of the [Bug]: MiniCPM-o 4.5 stutter in multi-turn conversation #8820 two-turn session on MRv2 (one extra "哦").

@BeatSeat @natureofnature, is this trade-off acceptable to you, or would you rather constrain the rule further? In an earlier version of the rule, requiring three silent codes instead of two raised MRv2 WER from 0.0257 to 0.0286 and added speech after the sentence in 5 turns instead of 0, so I kept two.

Unit tests. The ready pipeline's unit-test groups from .buildkite/cuda/test-ready.yml, with the same commands and the repo's pytest config, on base e461c00 and head 7e08057 in one environment: one RTX 6000 Ada (sm_89, 48 GB), Python 3.12.13, torch 2.13.0+cu130, vLLM 0.31.0, Triton 3.7.1, Transformers 5.14.1, pytest 9.1.1, stock deploy YAMLs. The CUDA groups ran with HF_HUB_OFFLINE=1.

Group base: passed / failed / skipped / deselected head: passed / failed / skipped / deselected
Simple · Model Executor 4447 / 0 / 4 / 1693 4461 / 0 / 4 / 1695
Simple · Diffusion Models 4463 / 0 / 16 / 408 4463 / 0 / 16 / 408
Simple · Engine&Entrypoints&Diffusion 8255 / 0 / 38 (+1 xfailed) / 1792 8255 / 0 / 38 (+1 xfailed) / 1792
Simple · Other 5459 / 0 / 33 / 1212 5459 / 0 / 33 / 1212
Engine&Model Executor&Worker · L4 1515 / 0 / 6 / 6989 * 1516 / 0 / 6 / 7004 *
Engine&Model Executor&Worker · H100 5 / 0 / 8 / 8497 5 / 0 / 8 / 8513
Distributed 0 / 0 / 33 / 488 0 / 0 / 33 / 488

* In the group run, tests/engine/test_async_omni_engine_abort.py::test_abort failed on both sides because the Qwen2.5-Omni-7B weights were not on this machine. After downloading them (sha256 checked against the Hub), it passes on both sides with the same command.

There are no collection errors. No test that both sides ran changes its outcome. Head adds the 15 new tests of this PR, all passing (14 CPU, 1 CUDA). The skips are the same on both sides and come from hardware or optional dependencies: NPU, ROCm, 2 or 4 GPUs, Hopper FA3, FlashAttention 2, Mooncake/NIXL, torchao, and models that are not in the HF cache. I can't run the NPU, ROCm or XPU jobs.

Commands, pytest summary lines and the per-test comparison

Commands, run in each checkout with VLLM_WORKER_MULTIPROC_METHOD=spawn (the CUDA ones with HF_HUB_OFFLINE=1):

pytest -sv tests/model_executor -m 'core_model and cpu'
pytest -sv tests/diffusion/models -m 'core_model and cpu'
pytest -sv tests/entrypoints tests/engine tests/diffusion --ignore=tests/diffusion/models -m 'core_model and cpu'
pytest -sv tests/ -m 'core_model and cpu' --ignore=tests/diffusion --ignore=tests/model_executor --ignore=tests/entrypoints --ignore=tests/engine
pytest -sv tests/engine/ tests/model_executor/ tests/worker_v2/ tests/worker/ -m 'core_model and cuda and L4' --run-level 'core_model'
pytest -sv tests/engine/ tests/model_executor/ tests/worker_v2/ tests/worker/ -m 'core_model and cuda and H100' --run-level 'core_model'
pytest -sv tests/distributed/ -m 'core_model and cuda and L4' --run-level 'core_model'
# after downloading Qwen/Qwen2.5-Omni-7B:
pytest -sv tests/engine/test_async_omni_engine_abort.py -m 'core_model and cuda and L4' --run-level 'core_model'

pytest summary lines (warnings omitted):

           base                                                        head
group 1    4447 passed, 4 skipped, 1693 deselected                     4461 passed, 4 skipped, 1695 deselected
group 2    4463 passed, 16 skipped, 408 deselected                     4463 passed, 16 skipped, 408 deselected
group 3    8255 passed, 38 skipped, 1792 deselected, 1 xfailed         8255 passed, 38 skipped, 1792 deselected, 1 xfailed
group 4    5459 passed, 33 skipped, 1212 deselected, 3 subtests passed 5459 passed, 33 skipped, 1212 deselected, 3 subtests passed
group 5    1 failed, 1514 passed, 6 skipped, 6989 deselected           1 failed, 1515 passed, 6 skipped, 7004 deselected
group 6    5 passed, 8 skipped, 8497 deselected                        5 passed, 8 skipped, 8513 deselected
group 7    33 skipped, 488 deselected                                  33 skipped, 488 deselected
test_abort 1 passed                                                    1 passed

Per-test comparison of the junit files: no test present on both sides changes outcome. Tests only on head, all passed:

  • test_talker_batching.py: test_trailing_code_runs_count_only_the_current_condition, test_turn_end_drain_kind_follows_sentence_end, test_turn_end_drain_until_silent_stops_only_after_silence
  • test_talker_mrv2.py: test_mrv2_turn_end_drain_waits_for_silence, test_mrv2_turn_end_drain_stops_the_same_way_after_preemption, test_mrv2_turn_end_drain_new_condition_starts_from_zero, test_mrv2_turn_end_drain_forces_eos_after_a_quiet_run
  • test_minicpmo_4_5_omni.py: test_native_duplex_turn_end_closes_text_only_after_a_sentence_end (6 cases), test_native_duplex_turn_end_uses_the_last_text_of_the_turn
  • test_talker_gpu_state.py: test_v1_gpu_turn_end_drain_until_silent_matches_cpu[cuda]

@linyueqian linyueqian left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please align the sentence-end decision with the text actually sent to the Talker, as the current mismatch can select the wrong close-text and drain mode.

Static review of all eight changed files and their integration with main 96274a0. No fork code, tests, or end-to-end inference were run, and the author's speech-quality results were not independently reproduced.

state["turn_id"] = turn_id
# The end of the turn's text so far (see _native_duplex_turn_text_ends_sentence).
tail = "" if turn_start else str(state.get("turn_text_tail") or "")
state["turn_text_tail"] = (tail + segment_text)[-_TURN_TEXT_TAIL_CHARS:]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[important] Update the tail from the text actually handed off to the Talker. Here, segment_text decodes the entire token delta, but llm2tts later slices the handoff at the first <|turn_eos|>. If an incomplete phrase is followed by <|turn_eos|> and a period, the engine marks the unit sentence-complete even though the period never reaches the Talker, enabling <text_eos> and until-silent draining for incomplete speech. Conversely, a completed sentence followed by an extra word after <|turn_eos|> gets the opposite misclassification. The existing test_native_duplex_turn_eos_row_is_handed_to_talker already covers ordinary tokens after <|turn_eos|>. Please move the tail update after handoff slicing and add regression coverage for both directions, retaining the prior-unit tail when the handoff contains only <|turn_eos|>.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 893a03f. The tail is now built from the decoded handoff, the same slice the Talker gets. The turn-start reset stays in _native_duplex_segment_output_ids, which runs for every unit, control-only ones included, and llm2tts appends handoff_text after the slice at <|turn_eos|>. A unit whose handoff is only <|turn_eos|> adds nothing, so the previous unit's tail is kept (test_native_duplex_turn_end_uses_the_last_text_of_the_turn).

test_native_duplex_turn_end_reads_only_the_text_handed_to_the_talker covers both directions: "So the" + <|turn_eos|> + "." is not sentence-complete, and "Sure, here it is." + <|turn_eos|> + "And" is. Both cases fail on the previous head.

The handoff to the Talker stops at the first <|turn_eos|>, but the turn's text
tail was built from the whole decoded unit, so text sampled after
<|turn_eos|> could flip the sentence-end decision either way. Reset the tail at
turn start as before, and append only the decoded handoff text.

Signed-off-by: Tianyao Wu <rayroy31@gmail.com>

@natureofnature natureofnature left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed 7e08057. The MRv2 trailing-run reconstruction and slot-resume regression address the earlier preemption-counter finding at the code level.

[P2] The sentence-end mismatch remains, as tracked in the existing review thread. In stage_input_processors/minicpmo_4_5_omni.py:763–765, turn_text_tail is updated from the full token delta before llm2tts truncates the Talker handoff at the first <|turn_eos|>. A period after that boundary can incorrectly enable <text_eos> and until-silent draining for incomplete speech; an extra word after it can disable them for a complete sentence.

Please update the tail from the decoded handoff slice and cover both misclassification directions, plus an EOS-only handoff that preserves the previous unit's tail. I would fix this before merging.

Static review only: no fork code, tests, or GPU/model inference were run, and the reported speech-quality results were not independently reproduced.

@twu3202

twu3202 commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 893a03f for the sentence-end comment from @linyueqian and @natureofnature (reply in the thread). I re-ran the same checks on it:

  • Ready-pipeline unit-test groups, same setup as above (passed / failed / skipped): Model Executor 4463 / 0 / 4, Diffusion Models 4463 / 0 / 16, Engine&Entrypoints&Diffusion 8255 / 0 / 38 (+1 xfailed), Other 5459 / 0 / 33, L4 1516 / 0 / 6, H100 5 / 0 / 8, Distributed 0 / 0 / 33. test_abort now passes in the group run; apart from that, the only change from 7e08057 is the two new test cases.
  • Seed-TTS en (MRv2, V1) and zh (MRv2): the same transcript and audio length for all 192 turns as before this change.
  • Two-turn sessions: V1 is unchanged. The MRv2 session turned out not to be stable across server starts, on main too, so I added a line about that to the description.

@amy-why-3459

Copy link
Copy Markdown
Collaborator

Re-reviewed 893a03f7fcae2f85630a2461ac06b31773c36544, including the previous review threads and the author's updated validation. No new confirmed code defect found.

The two earlier correctness findings are addressed at the code level:

  • MRv2 reconstructs trailing silent/quiet runs from the current condition's generated history after preemption, and the regression compares EOS decisions against uninterrupted decoding, including a different slot. New-condition prefill resets the runs.
  • The sentence-end flag now uses the decoded handoff after slicing at <|turn_eos|>. Tests cover both misclassification directions, preservation of the preceding unit's tail for an EOS-only handoff, and turn-start reset. Prompt reservation and <text_eos> insertion use the same decision.

Architecturally, keeping this policy inside the MiniCPM-o handoff/Talker implementation is appropriate. The V1 host path, V1 device kernel, and MRv2 path all distinguish the three drain kinds; the existing boundary-mask behavior remains the fallback. The silence classes are an empirical completion heuristic, however, not proof that all outstanding text was spoken.

Remaining merge considerations:

  • Quality acceptance: The additional per-case and truncation/added-speech/tail-latency results answer the earlier request for a breakdown. Please record the model maintainers' acceptance of the reported tradeoff (including the intra-sentence pause cases and the extra “哦” in the two-turn case), or constrain the policy further. Preserve these examples as repeatable evaluation cases; aggregate WER/CER improvement alone does not resolve them. This is an acknowledged quality tradeoff, not a newly reproduced defect.
  • Validation evidence: The updated comment reports the ready-pipeline UT groups on this exact head, which is substantial progress; there is no need to repeat already passing groups merely for this review. Please attach the corresponding complete logs/JUnit artifacts and obtain matching-head CI coverage or explicit maintainer disposition for the remaining unavailable/skipped hardware/backend jobs. In particular, the distributed group reports 0 passed / 33 skipped, and running the H100 selection on an Ada GPU does not establish coverage of the skipped Hopper paths. Maintainers own CI admission. Complete repository UT coverage and merge readiness are not yet established by the available summaries alone.

This was a static source/test review; I did not execute fork code or independently reproduce the CUDA/model quality results. The benchmark and test counts above are author-reported evidence.

@amy-why-3459 amy-why-3459 added the ready label to trigger buildkite CI label Oct 17, 2026
@amy-why-3459

Copy link
Copy Markdown
Collaborator

Independent V1 Seed-TTS check of 893a03f vs e461c00

Same setup for both revisions: minicpmo_4_5.yaml (V1, not MRv2), openbmb/MiniCPM-o-4_5, Seed-TTS en, --disable-shuffle --no-oversample, 4 sessions × 4 turns (the first 16 rows, 16 turns), --max-concurrency 1, Whisper-large-v3. Two L20X GPUs. This is a smaller slice than the 192-turn numbers in the PR description.

Revision Mean WER Turns with any error
e461c00 (base) 0.0983 9/16
893a03f (this PR) 0.0371 4/16

The drop matches the failure mode in the PR: on the base, longer sentences lose the last words (pine trees → pine, as quiet as the desert cut off, toward its own fate cut off, on each side → on it, great heights → greatness). On this PR those tails are spoken. The four remaining errors are not cut-off endings: sensed → 's, fifty written as 50, difficulties → vocalities, and an apostrophe mismatch on an otherwise complete great heights sentence.

I did not rerun the 192-turn MRv2/V1 suite or the zh CER numbers reported in the description.

@twu3202

twu3202 commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @amy-why-3459 for the review and for running V1 yourself. Buildkite has run on 0fe9bad (your merge of main) and passed: https://buildkite.com/vllm/vllm-omni/builds/17496. That covers the two groups I couldn't run properly here: Distributed Test has 25 passed / 8 skipped, and the H100 group has 79 passed on an H100. The MiniCPM-o duplex and MRv2 duplex tests passed too. On the NPU pipeline the only failure is the A5 diffusion layer job (exit -1); the MiniCPM-o NPU jobs passed. I can still attach my local JUnit files if they're useful.

@BeatSeat

BeatSeat commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

WER seems much more acceptable. How does the rest compare to turn-based WER from the o4.5 model?

Current code LGTM.

@hsliuustc0106 hsliuustc0106 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • P2: Carry until-silent draining through the V1 multi-frame Talker path. At 0fe9bad3fbb43c0cfca06b29d008179d38a2d028, the new drain-kind assignment can store kind 2, but the K-step consumer still bool-casts it and applies only the legacy 25-frame boundary mask. The shipped NPU duplex configuration enables K8, so this path bypasses the updated single-frame silence/quiet-run logic. For example, at step 24 with no trailing silence and EOS-best logits, K-step allows EOS while the single-frame path must mask it until two silent frames; it also does not force the eight-quiet-frame stop. This leaves premature truncation or extra speech on that supported path.

    twu3202: preserve all three drain kinds in both greedy and stochastic K-step sampling. Acceptance: current-head parity tests before/after two silent frames and eight quiet frames, including K-frame boundaries and unchanged legacy kind-1 behavior; provide the local command/environment/results and disclose any hardware gap. Status: awaiting repair.

Source-only review of all eight changed files and relevant dispatch/sampler contracts; no local tests or NPU speech evaluation executed.

The K-step loop read the drain kind as a bool, so an until-silent chunk
only got the boundary mask there. It now masks codec EOS until this
condition ends in two silent codes and stops the chunk after eight quiet
ones, the same as the single-frame path, using the host-side confirmed
frame record so no device sync is added.

Signed-off-by: Tianyao Wu <rayroy31@gmail.com>
@twu3202

twu3202 commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

@hsliuustc0106 Thanks, you're right: the K-step loop came in from #7929 with the merge of main and still read the kind as a bool. Fixed in ad79b5a. It now handles the drain kinds the same way as the single-frame path: kind 1 keeps the boundary mask, and kind 2 masks codec EOS until the condition ends in two silent codes and finishes the chunk without sampling once eight quiet codes are confirmed. The runs are counted on _request_codec_history, the host-side record of confirmed frames, so no device sync is added. Greedy and stochastic sampling get the same flag.

New tests in test_talker_kstep_feedback.py:

  • test_kstep_turn_end_drain_matches_single_frame: 12 code histories × 3 drain kinds × greedy/stochastic, on the real samplers with a head that prefers EOS. Each case checks the single-frame result against a fixed expectation, and the K-step result (forced, masked or allowed, plus its stop row) against the single-frame one. The histories include your step-24 example, a silent pair across the frame-16 step boundary, 7 vs 8 quiet codes, an eight-quiet stop on the first frame of a step (frame 32), and codes retained from the previous condition.
  • test_kstep_drain_until_silent_across_steps: five K=8 steps through merge_frame_outputs. Each step's codes, stop rows and finished flag are checked, each frame's EOS mask matches the single-frame path, and the stop lands on frame 32 as it does there.

On 0fe9bad, 19 of the 73 new tests fail, all kind 2; the kind-2 cases that pass there are the ones where the boundary mask gives the same answer. Every kind-0 and kind-1 case passes on both commits.

Local results (CPU only; Python 3.12.13, torch 2.13.0+cu130, vLLM 0.31.0, Threadripper PRO 7965WX):

  • pytest tests/model_executor/models/minicpmo_4_5/test_talker_kstep_feedback.py: 121 passed.
  • pytest tests/model_executor -m 'core_model and cpu' on ad79b5a and on 0fe9bad: no test changes outcome across the 5093 tests both run, and the 73 new ones pass. The same 23 fail or error on both (omnivoice, cosyvoice3 and ming_image; they need a visible GPU or a Hub download, which this offline CPU run hides).
  • pre-commit run --files on the two changed files: passed.

Hardware gap: the K-step path only runs on NPU and I don't have one, so this is not run on NPU and there is no NPU speech evaluation.

@amy-why-3459 amy-why-3459 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed ad79b5a (only the K-step drain fix, 0fe9bad..ad79b5a).

The K-step branch now applies the same criteria as _make_omni_output_single_frame: _DRAIN_BOUNDARY_MASK keeps the cadence window, and _DRAIN_UNTIL_SILENT masks EOS until two trailing silent codes and finishes before sampling once eight quiet codes are confirmed. Counting the runs on _request_codec_history[-step:] is equivalent to the single-frame recent_codes[-step:] here: step and the history both advance once per emitted frame, and _advance_classified only sets emit=False on the finishing sample, so the two can't drift mid-condition. The step > 0 guard keeps retained codes from the previous condition out, and the read piggybacks on the per-frame D2H that is already there, so no extra sync is added. The early-finish branch leaves _request_codec_device_states untouched, which looks fine since the next native-duplex condition drops those caches in prefill anyway.

The parametrized cross-check against the single-frame path (incl. the frame-32 step-start stop, the silent pair across the frame-16 boundary, and the retained-code cases) covers the edge cases I'd expect here. No new issues on this delta.

Since the K-step path is NPU-only and this commit hasn't been run there, an Ascend duplex run with K-step enabled (WER/CER plus tail-cut count, as in #8807) would still be good to have before merge to confirm the end-to-end behavior.

@twu3202

twu3202 commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

@BeatSeat

How does the rest compare to turn-based WER from the o4.5 model?

I ran turn-based on the same rows to compare: the same 192 Seed-TTS rows per language, through /v1/chat/completions (use_tts_template, V1 deploy) instead of a duplex session, with the same system prompt, ASR and reference voices as the duplex runs (those use the first row's voice for all four turns of a session, so each turn-based row got its session's voice).

en WER zh CER
Turn-based 0.0184 0.0105
Duplex, this PR 0.0261 (MRv2), 0.0364 (V1) 0.0105 (MRv2)
Duplex, main 0.0656 (MRv2), 0.0736 (V1) 0.0239 (MRv2)

For reference, the model card reports 2.38% (en WER) / 0.86% (zh CER) on the full test sets.

So zh is at the turn-based level, and en is 0.8 points above it on MRv2 and 1.8 on V1. Looking at the utterances that turn-based gets right but duplex doesn't (en 11 on MRv2 and 20 on V1, zh 10):

  • A few still end early: en 4 on MRv2 and 6 on V1, mostly the last word, and zh 3, all at a comma.
  • In zh, 4 come from the duplex Thinker's text, not the speech: three references are disfluent and the Thinker smooths them (e.g. "提升出出行安全" → "提升出行安全", or it leaves out the filler "嗯"), and one drops the first word.
  • The rest are mostly single-word misreadings (e.g. "this" heard as "the", "catch" as "cats").

@amy-why-3459

Copy link
Copy Markdown
Collaborator

@linyueqian Could you please take another look at this PR? If everything looks good, could you approve it? Thanks!

@amy-why-3459 amy-why-3459 added ready label to trigger buildkite CI and removed ready label to trigger buildkite CI labels Oct 20, 2026

@hsliuustc0106 hsliuustc0106 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • @twu3202, fix the conflicting test marks at 12eeb65c919700370864f87156a2a449e67167ee. The current pre-commit job fails in check_test_marks.py: module-level CPU is inherited by the CUDA/L4 hardware test. Separate that accelerator case into another module, or scope CPU marks to CPU cases while retaining core_model and the hardware marker. Acceptance: the repository marker check passes on the changed file(s), CPU coverage remains selected in its lane, and the accelerator case is selected only in the CUDA/L4 lane. Please include the fix SHA and check/collection output; do not skip or weaken the check.

Verified from the failed job and source marker declarations; I did not run these tests or reproduce a Talker runtime defect.

@vllm-omni-review-bot

vllm-omni-review-bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Omni ReviewBot: superseded

The CI failure noted on 12eeb65c9197 refers to an earlier head; the pull request now points at 1a9210e414e4.

test_talker_mrv2.py marks the whole module cpu, and its CUDA/L4 sampler
test inherited that mark, which check_test_marks.py rejects since vllm-project#8380.
Move the test unchanged to test_talker_mrv2_cuda.py, which carries only
core_model plus the test's hardware_test marks.

Signed-off-by: Tianyao Wu <rayroy31@gmail.com>
@twu3202

twu3202 commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

@hsliuustc0106 Fixed in 1a9210e, which only touches the two test files below; the checker and the hook's SKIP settings are unchanged. The L4 test moves unchanged to test_talker_mrv2_cuda.py, which has the module mark core_model plus the test's own hardware_test(res={"cuda": "L4"}, num_cards=1), and imports _talker from test_talker_mrv2.py, like common/test_audio_in_video_cuda.py does. test_talker_mrv2.py keeps core_model and cpu. The conflict was already on main: the module's cpu mark is from #8222 and the CUDA test from #8576 (#8708 gave it the L4 hardware_test). The check from #8380 reaches the file because this PR edits it.

Output on 1a9210e (vLLM 0.31.0, one RTX 6000 Ada):

$ git diff --name-only --diff-filter=ACMR 01f807f68...1a9210e41 -- tests | xargs python tools/pre_commit/check_test_marks.py
$ echo $?
0
# the same check on 12eeb65c9's test_talker_mrv2.py:
#   - tests/model_executor/models/minicpmo_4_5/test_talker_mrv2.py [conflicting CPU/accelerator marks]

$ pytest tests/model_executor/models/minicpmo_4_5/ --run-level core_model --collect-only -q -m 'core_model and cpu' | tail -1
1270/1410 tests collected (140 deselected)
# 12eeb65c9: 1271/1410 tests collected (139 deselected)

$ pytest tests/model_executor/models/minicpmo_4_5/ --run-level core_model --collect-only -q -m 'core_model and cuda and L4' | grep -E 'test_talker_mrv2|collected'
tests/model_executor/models/minicpmo_4_5/test_talker_mrv2_cuda.py::test_mrv2_sampler_applies_codec_window_penalty_instead_of_stock_penalty
108/1410 tests collected (1302 deselected)
# 12eeb65c9: 108 collected, the same test under test_talker_mrv2.py

$ pytest tests/model_executor/models/minicpmo_4_5/test_talker_mrv2.py -m 'core_model and cpu'
34 passed

$ pytest tests/model_executor/models/minicpmo_4_5/test_talker_mrv2_cuda.py -m 'core_model and cuda and L4' --run-level core_model
1 passed

The one test the CPU lane no longer selects is the L4 test; the other 34 tests in test_talker_mrv2.py stay in it. pre-commit run --files on the two files passes all hooks.

@amy-why-3459 amy-why-3459 added ready label to trigger buildkite CI and removed ready label to trigger buildkite CI labels Oct 20, 2026
@amy-why-3459
amy-why-3459 enabled auto-merge (squash) October 20, 2026 11:52

@linyueqian linyueqian left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rechecked ced278b. The only author commit since ad79b5a is 1a9210e, which moves the L4 MRv2 sampler test into test_talker_mrv2_cuda.py so the module-level CPU mark no longer covers it; pre-commit now passes. After merges from main, the PR's own changes to the four runtime files remain identical to ad79b5a. The fused codec prelude from #9077 still takes eos_window_masked from the drain logic, and the EOS censor runs before the fused branch, so all three drain kinds carry through on that path. No remaining actionable findings.

Static review only; I did not run fork code, tests, or e2e. Main CUDA passes at ced278b (build 17675). The completion and WER comparisons reported in this thread were run on earlier revisions with the same runtime logic; NPU speech validation was not run.

@amy-why-3459
amy-why-3459 merged commit 3e40008 into vllm-project:main Oct 20, 2026
8 of 11 checks passed
ZhengWG pushed a commit to ZhengWG/vllm-omni that referenced this pull request Oct 21, 2026
…sh speaking (vllm-project#8911)

Signed-off-by: Tianyao Wu <rayroy31@gmail.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working high priority high priority issue, needs to be done asap ready label to trigger buildkite CI tts code related to tts models

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug][MiniCPM-o 4.5] Native full-duplex drops the last 1–3 words of a turn (Seed-TTS WER ~0.08 vs ~0.016 for chat)

8 participants