[WIP][Bugfix][MiniCPM-o] Fix Full duplex bugs for minicpm-challenge (#5496)
Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
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(
|
||||
|
||||
Reference in new issue
Block a user