[WIP][Bugfix][MiniCPM-o] Fix Full duplex bugs for minicpm-challenge (#5496)

Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
Sy03 authored and GitHub committed 2026-07-28 15:59:26 +08:00
1 parent fb89ab43ea
commit ffa9a6af69
60 files changed
+3696 -634

No files matched your search

@@ -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
@@ -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()
@@ -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")
@@ -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(
@@ -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(