[Model] Add MiniMax Music 3 text-to-music (#6186)
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
This commit is contained in:
1 parent
199ef7779c
commit
b283215c28
43 files changed
+6876
-405
No files matched your search
@@ -0,0 +1,132 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Regression tests for MiniMax Music 3 batched payload partitioning.
|
||||
|
||||
The stage-0 payload is split per request by indexing list values: a list is
|
||||
indexed by batch row, anything else is handed to every request unchanged. So
|
||||
per-request metadata has to be carried as lists. Merging it into one dict made
|
||||
every request in a batch receive the last request's seed, window offset and
|
||||
terminal flag, which crosses seeds between songs, misplaces acoustic windows,
|
||||
and lets one request's terminal flag end another request's stream early.
|
||||
|
||||
These drive ``make_omni_output`` directly with a hand-built batch, so no GPU
|
||||
and no engine are needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.minimax_music3.talker import (
|
||||
MiniMaxMusic3TalkerForConditionalGeneration as Talker,
|
||||
)
|
||||
from vllm_omni.utils.mm_outputs import to_payload_element
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
_WIDTH = 4
|
||||
|
||||
|
||||
def _info(*, seed: int, start: int, frames: int, last: bool) -> dict:
|
||||
"""One batch row's intermediate buffer, as ``postprocess`` leaves it."""
|
||||
return {
|
||||
"latent": torch.full((frames, _WIDTH), float(seed)),
|
||||
"codes": {"audio": torch.full((frames, 8), seed)},
|
||||
"meta": {
|
||||
"audio_seed": seed,
|
||||
"num_processed_tokens": start,
|
||||
"codec_chunk_frames": frames,
|
||||
"last_chunk": last,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _payload_for(output, row: int) -> dict:
|
||||
"""Partition the batch payload down to one request, as the runner does."""
|
||||
flat = {}
|
||||
for key, value in output.multimodal_outputs.items():
|
||||
if isinstance(value, dict):
|
||||
for qual, val in value.items():
|
||||
flat[f"{key}.{qual}"] = val
|
||||
else:
|
||||
flat[key] = value
|
||||
return {key: to_payload_element(value, row, 1, 1) for key, value in flat.items()}
|
||||
|
||||
|
||||
class TestBatchedMetadataPartitioning:
|
||||
"""Two requests in one batch must keep their own metadata."""
|
||||
|
||||
@staticmethod
|
||||
def _two_request_batch():
|
||||
infos = [
|
||||
_info(seed=111, start=100, frames=3, last=False),
|
||||
_info(seed=222, start=200, frames=5, last=True),
|
||||
]
|
||||
talker = Talker.__new__(Talker)
|
||||
return talker.make_omni_output(torch.zeros(2, _WIDTH), model_intermediate_buffer=infos)
|
||||
|
||||
def test_seeds_are_not_crossed(self) -> None:
|
||||
"""A song decoded with another request's seed is a different song."""
|
||||
output = self._two_request_batch()
|
||||
assert _payload_for(output, 0)["meta.audio_seed"] == 111
|
||||
assert _payload_for(output, 1)["meta.audio_seed"] == 222
|
||||
|
||||
def test_window_offsets_stay_with_their_request(self) -> None:
|
||||
"""The start frame decides where a window lands in the song."""
|
||||
output = self._two_request_batch()
|
||||
assert _payload_for(output, 0)["meta.num_processed_tokens"] == 100
|
||||
assert _payload_for(output, 1)["meta.num_processed_tokens"] == 200
|
||||
|
||||
def test_terminal_flag_does_not_leak_across_requests(self) -> None:
|
||||
"""One request finishing must not end another request's stream."""
|
||||
output = self._two_request_batch()
|
||||
assert _payload_for(output, 0)["meta.last_chunk"] is False
|
||||
assert _payload_for(output, 1)["meta.last_chunk"] is True
|
||||
|
||||
def test_span_length_matches_its_own_latent(self) -> None:
|
||||
output = self._two_request_batch()
|
||||
for row, frames in ((0, 3), (1, 5)):
|
||||
entry = _payload_for(output, row)
|
||||
assert entry["meta.codec_chunk_frames"] == frames
|
||||
assert entry["latent"].shape[0] == frames
|
||||
|
||||
def test_latent_and_codes_follow_the_same_row(self) -> None:
|
||||
"""The tensors were already per-request; pin that they stay aligned."""
|
||||
output = self._two_request_batch()
|
||||
for row, seed in ((0, 111), (1, 222)):
|
||||
entry = _payload_for(output, row)
|
||||
assert entry["latent"][0, 0].item() == pytest.approx(float(seed))
|
||||
assert int(entry["codes.audio"][0, 0]) == seed
|
||||
|
||||
|
||||
class TestPartialBatches:
|
||||
"""Only some rows carry a span on any given step."""
|
||||
|
||||
def test_row_without_a_span_does_not_take_another_row_metadata(self) -> None:
|
||||
infos = [{}, _info(seed=777, start=300, frames=2, last=True)]
|
||||
talker = Talker.__new__(Talker)
|
||||
output = talker.make_omni_output(torch.zeros(2, _WIDTH), model_intermediate_buffer=infos)
|
||||
|
||||
idle = _payload_for(output, 0)
|
||||
assert idle["latent"].numel() == 0
|
||||
assert idle["meta.audio_seed"] is None
|
||||
assert idle["meta.last_chunk"] is None
|
||||
|
||||
active = _payload_for(output, 1)
|
||||
assert active["meta.audio_seed"] == 777
|
||||
assert active["meta.last_chunk"] is True
|
||||
|
||||
def test_batch_with_no_spans_emits_no_payload(self) -> None:
|
||||
talker = Talker.__new__(Talker)
|
||||
output = talker.make_omni_output(torch.zeros(2, _WIDTH), model_intermediate_buffer=[{}, {}])
|
||||
assert output.multimodal_outputs is None
|
||||
|
||||
def test_consumed_entries_are_cleared_from_the_buffer(self) -> None:
|
||||
"""The runner never clears this buffer, so a span left behind would be
|
||||
re-emitted on every later step."""
|
||||
infos = [_info(seed=1, start=0, frames=2, last=False)]
|
||||
talker = Talker.__new__(Talker)
|
||||
talker.make_omni_output(torch.zeros(1, _WIDTH), model_intermediate_buffer=infos)
|
||||
assert "latent" not in infos[0]
|
||||
assert "meta" not in infos[0]
|
||||
@@ -0,0 +1,214 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit tests for MiniMax Music 3 per-request AR frame bookkeeping.
|
||||
|
||||
The talker emits overlapping windows while dropping frames it can no longer
|
||||
need, and flushes whatever is left as one span when the song ends. Getting the
|
||||
eviction boundary wrong by one frame silently corrupts the conditioning handed
|
||||
to the acoustic stage from the second window onward, which is audible but hard
|
||||
to attribute, so the lifecycle is simulated here frame by frame.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.minimax_music3.chunking import chunk_windows
|
||||
from vllm_omni.model_executor.models.minimax_music3.constants import (
|
||||
AR_CHUNK_FRAMES,
|
||||
AR_CHUNK_HOP_FRAMES,
|
||||
NUM_CODEBOOKS,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minimax_music3.frame_state import (
|
||||
FrameBuffer,
|
||||
RequestARState,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
# One scalar per frame instead of 32768 dims: the bookkeeping is what is under
|
||||
# test, and a frame's value doubles as its identity.
|
||||
_WIDTH = 4
|
||||
|
||||
|
||||
def _frame(index: int) -> torch.Tensor:
|
||||
return torch.full((1, _WIDTH), float(index))
|
||||
|
||||
|
||||
def _identities(chunk: torch.Tensor) -> list[int]:
|
||||
return [int(row[0].item()) for row in chunk]
|
||||
|
||||
|
||||
class TestFrameBuffer:
|
||||
def test_slice_returns_the_requested_frames(self) -> None:
|
||||
buffer = FrameBuffer()
|
||||
for index in range(10):
|
||||
buffer.append(_frame(index))
|
||||
assert _identities(buffer.slice(3, 7)) == [3, 4, 5, 6]
|
||||
|
||||
def test_slice_is_relative_to_the_live_base(self) -> None:
|
||||
buffer = FrameBuffer()
|
||||
for index in range(10):
|
||||
buffer.append(_frame(index))
|
||||
buffer.discard_before(4)
|
||||
assert buffer.base_frame == 4
|
||||
assert _identities(buffer.slice(4, 8)) == [4, 5, 6, 7]
|
||||
|
||||
def test_slicing_a_discarded_range_raises(self) -> None:
|
||||
buffer = FrameBuffer()
|
||||
for index in range(10):
|
||||
buffer.append(_frame(index))
|
||||
buffer.discard_before(5)
|
||||
with pytest.raises(RuntimeError, match="unavailable"):
|
||||
buffer.slice(3, 6)
|
||||
|
||||
def test_slicing_past_the_end_raises(self) -> None:
|
||||
buffer = FrameBuffer()
|
||||
for index in range(4):
|
||||
buffer.append(_frame(index))
|
||||
with pytest.raises(RuntimeError, match="unavailable"):
|
||||
buffer.slice(2, 9)
|
||||
|
||||
def test_discard_is_clamped_at_both_ends(self) -> None:
|
||||
buffer = FrameBuffer()
|
||||
for index in range(5):
|
||||
buffer.append(_frame(index))
|
||||
buffer.discard_before(-3)
|
||||
assert buffer.base_frame == 0
|
||||
buffer.discard_before(99)
|
||||
assert buffer.base_frame == 5
|
||||
assert buffer.end_frame == 5
|
||||
|
||||
def test_peak_tracks_the_high_water_mark(self) -> None:
|
||||
buffer = FrameBuffer()
|
||||
for index in range(10):
|
||||
buffer.append(_frame(index))
|
||||
buffer.discard_before(8)
|
||||
assert buffer.peak_frames == 10
|
||||
|
||||
|
||||
class TestWindowLifecycle:
|
||||
"""Drive the state exactly as the talker does, one frame per step."""
|
||||
|
||||
@staticmethod
|
||||
def _run(total_frames: int) -> tuple[list[list[int]], int]:
|
||||
"""Generate ``total_frames`` frames, returning the emitted spans.
|
||||
|
||||
Returns:
|
||||
The frame identities of every emitted span, and the peak buffer
|
||||
occupancy reached along the way.
|
||||
"""
|
||||
state = RequestARState(seed=1, max_frames=total_frames)
|
||||
spans: list[list[int]] = []
|
||||
for index in range(total_frames):
|
||||
state.frames.append(_frame(index))
|
||||
state.codes.append(torch.full((1, NUM_CODEBOOKS), index))
|
||||
state.generated_frames += 1
|
||||
window = state.ready_window()
|
||||
if window is not None:
|
||||
spans.append(_identities(state.take_window(window, is_final=False)))
|
||||
|
||||
# Terminal flush: everything not yet emitted, as one contiguous span.
|
||||
start = state.emitted_windows * AR_CHUNK_HOP_FRAMES
|
||||
end = state.frames.end_frame
|
||||
if end > start:
|
||||
spans.append(_identities(state.frames.slice(start, end)))
|
||||
return spans, state.frames.peak_frames
|
||||
|
||||
def test_short_song_is_one_span(self) -> None:
|
||||
spans, _ = self._run(50)
|
||||
assert spans == [list(range(50))]
|
||||
|
||||
def test_every_frame_is_delivered_exactly_once_in_order(self) -> None:
|
||||
"""Spans overlap by design, but their union must be the whole song.
|
||||
|
||||
Reconstruct by taking each span's non-overlapping remainder, which is
|
||||
what the acoustic stage does after cropping.
|
||||
"""
|
||||
for total in (250, 437, 600, 901):
|
||||
spans, _ = self._run(total)
|
||||
rebuilt: list[int] = []
|
||||
for span in spans:
|
||||
rebuilt.extend(frame for frame in span if frame >= len(rebuilt))
|
||||
assert rebuilt == list(range(total)), f"gap or duplicate at {total} frames"
|
||||
|
||||
def test_windows_advance_by_the_hop(self) -> None:
|
||||
spans, _ = self._run(600)
|
||||
for index, span in enumerate(spans[:-1]):
|
||||
assert span[0] == index * AR_CHUNK_HOP_FRAMES
|
||||
assert len(span) == AR_CHUNK_FRAMES
|
||||
|
||||
def test_mid_stream_windows_are_never_the_final_frame(self) -> None:
|
||||
"""A window is held back until a further frame proves it is not last."""
|
||||
total = 600
|
||||
spans, _ = self._run(total)
|
||||
for span in spans[:-1]:
|
||||
assert span[-1] < total - 1
|
||||
|
||||
def test_terminal_span_covers_the_tail(self) -> None:
|
||||
total = 437
|
||||
spans, _ = self._run(total)
|
||||
assert spans[-1][-1] == total - 1
|
||||
|
||||
def test_terminal_span_is_bounded(self) -> None:
|
||||
"""The tail is at most one full window plus one hop."""
|
||||
for total in (250, 301, 437, 600, 901, 1000):
|
||||
spans, _ = self._run(total)
|
||||
assert len(spans[-1]) <= AR_CHUNK_FRAMES + AR_CHUNK_HOP_FRAMES
|
||||
|
||||
def test_buffer_stays_bounded_over_a_long_song(self) -> None:
|
||||
"""Peak occupancy must not grow with song length; that is the point
|
||||
of evicting retired frames."""
|
||||
_, peak_short = self._run(600)
|
||||
_, peak_long = self._run(3000)
|
||||
assert peak_long == peak_short
|
||||
assert peak_long <= AR_CHUNK_FRAMES + AR_CHUNK_HOP_FRAMES
|
||||
|
||||
def test_emitted_window_count_matches_the_window_plan(self) -> None:
|
||||
"""What the stream emitted must reconcile with chunk_windows()."""
|
||||
for total in (250, 437, 600):
|
||||
spans, _ = self._run(total)
|
||||
planned = chunk_windows(total)
|
||||
# Every window is accounted for: the pre-terminal spans are one
|
||||
# window each, and the terminal span carries whatever remains.
|
||||
assert len(spans) <= len(planned)
|
||||
assert spans[0][0] == planned[0].start
|
||||
|
||||
|
||||
class TestSamplingPositions:
|
||||
def test_each_frame_reserves_one_slot_per_codebook(self) -> None:
|
||||
"""Codebook streams must never collide, or codes repeat across frames."""
|
||||
state = RequestARState(seed=1, max_frames=10)
|
||||
positions = [state.advance_position() for _ in range(5)]
|
||||
assert positions == [0, 8, 16, 24, 32]
|
||||
|
||||
def test_draw_slots_are_globally_unique(self) -> None:
|
||||
state = RequestARState(seed=1, max_frames=64)
|
||||
slots: set[int] = set()
|
||||
for _ in range(64):
|
||||
base = state.advance_position()
|
||||
for codebook in range(NUM_CODEBOOKS):
|
||||
slot = base + codebook
|
||||
assert slot not in slots
|
||||
slots.add(slot)
|
||||
assert len(slots) == 64 * NUM_CODEBOOKS
|
||||
|
||||
|
||||
class TestCodeRetention:
|
||||
def test_codes_span_matches_the_frame_span(self) -> None:
|
||||
state = RequestARState(seed=1, max_frames=10)
|
||||
for index in range(10):
|
||||
state.codes.append(torch.full((1, NUM_CODEBOOKS), index))
|
||||
codes = state.take_codes(2, 6)
|
||||
assert codes is not None
|
||||
assert codes.shape == (4, NUM_CODEBOOKS)
|
||||
assert [int(row[0]) for row in codes] == [2, 3, 4, 5]
|
||||
|
||||
def test_out_of_range_span_is_dropped_not_raised(self) -> None:
|
||||
"""Codes are diagnostic; the audio path must not fail without them."""
|
||||
state = RequestARState(seed=1, max_frames=10)
|
||||
for index in range(4):
|
||||
state.codes.append(torch.full((1, NUM_CODEBOOKS), index))
|
||||
assert state.take_codes(2, 99) is None
|
||||
assert state.take_codes(0, 0) is None
|
||||
@@ -0,0 +1,203 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit tests for MiniMax Music 3 prompt assembly and window arithmetic.
|
||||
|
||||
The prompt transformations are not cosmetic: they decide token positions, and
|
||||
token positions decide the audio. These tests pin the documented behaviours
|
||||
that a well-meaning cleanup would otherwise "fix".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.model_executor.models.minimax_music3.chunking import (
|
||||
chunk_windows,
|
||||
crop_sample_bounds,
|
||||
overlap_mel_length,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minimax_music3.constants import (
|
||||
AR_CHUNK_FRAMES,
|
||||
AR_CHUNK_HOP_FRAMES,
|
||||
BLEND_MEL_FRAMES,
|
||||
DAV_HOP_SAMPLES,
|
||||
HOP_MEL_FRAMES,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minimax_music3.prompt import (
|
||||
SPECIAL_TOKEN_IDS,
|
||||
build_cfg_null_token_ids,
|
||||
build_prompt,
|
||||
clean_caption,
|
||||
normalize_lyrics,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class TestNormalizeLyrics:
|
||||
def test_prepends_start_tag(self) -> None:
|
||||
assert normalize_lyrics("[Verse]\nhello").startswith("[start]\n")
|
||||
|
||||
def test_lowercases_structure_tags(self) -> None:
|
||||
assert "[verse]" in normalize_lyrics("[Verse]\nhello")
|
||||
assert "[Verse]" not in normalize_lyrics("[Verse]\nhello")
|
||||
|
||||
def test_text_after_a_leading_tag_is_dropped(self) -> None:
|
||||
"""Documented contract: a tag must sit on its own line.
|
||||
|
||||
Lyrics written next to a tag are silently lost, which is why the
|
||||
serving docs tell callers to put tags on their own line.
|
||||
"""
|
||||
out = normalize_lyrics("[Verse] Walking down the street")
|
||||
assert "Walking" not in out
|
||||
assert "[verse]" in out
|
||||
|
||||
def test_text_on_its_own_line_survives(self) -> None:
|
||||
out = normalize_lyrics("[Verse]\nWalking down the street")
|
||||
assert "Walking down the street" in out
|
||||
|
||||
def test_bracket_space_becomes_newline(self) -> None:
|
||||
assert normalize_lyrics("a [chorus]") == "[start]\na\n[chorus]"
|
||||
|
||||
|
||||
class TestCleanCaption:
|
||||
def test_special_token_form_is_rewritten_not_passed_through(self) -> None:
|
||||
"""A caption must not be able to inject a real special token."""
|
||||
assert clean_caption("<|bpm 92|>") == "bpm is 92"
|
||||
assert "<|" not in clean_caption("<|bpm 92|>")
|
||||
|
||||
def test_lone_tag_keeps_its_inner_text(self) -> None:
|
||||
assert clean_caption("<|instrumental|>") == "instrumental"
|
||||
|
||||
def test_markdown_is_stripped(self) -> None:
|
||||
assert clean_caption("## Heading") == "Heading"
|
||||
assert clean_caption("**bold**") == "bold"
|
||||
assert clean_caption("- item") == "item"
|
||||
|
||||
def test_blank_lines_collapse_at_two_not_three(self) -> None:
|
||||
"""The source collapses runs of two or more, not three or more.
|
||||
|
||||
This shifts blank-line token positions and therefore the audio, so it
|
||||
is pinned deliberately.
|
||||
"""
|
||||
assert clean_caption("a\n\nb") == "a\nb"
|
||||
assert clean_caption("a\n\n\n\nb") == "a\nb"
|
||||
|
||||
|
||||
class TestBuildPrompt:
|
||||
def test_framing_tokens_are_present_and_ordered(self) -> None:
|
||||
prompt = build_prompt("a bright pop song", "[Verse]\nhello")
|
||||
assert prompt.startswith("<|im_start|><|caption_start|>")
|
||||
assert prompt.endswith("<|im_end|><|audio_start|>")
|
||||
assert prompt.index("<|caption_end|>") < prompt.index("<|lyrics_start|>")
|
||||
|
||||
|
||||
class TestBuildCfgNullTokenIds:
|
||||
@staticmethod
|
||||
def _prompt_ids(body_len: int) -> list[int]:
|
||||
return (
|
||||
[SPECIAL_TOKEN_IDS["<|im_start|>"]]
|
||||
+ list(range(1000, 1000 + body_len))
|
||||
+ [SPECIAL_TOKEN_IDS["<|im_end|>"], SPECIAL_TOKEN_IDS["<|audio_start|>"]]
|
||||
)
|
||||
|
||||
def test_length_is_preserved(self) -> None:
|
||||
"""Equal length is what lets the two rows decode in lockstep."""
|
||||
ids = self._prompt_ids(12)
|
||||
assert len(build_cfg_null_token_ids(ids)) == len(ids)
|
||||
|
||||
def test_body_is_replaced_and_framing_survives(self) -> None:
|
||||
ids = self._prompt_ids(12)
|
||||
null = build_cfg_null_token_ids(ids)
|
||||
assert null[0] == SPECIAL_TOKEN_IDS["<|im_start|>"]
|
||||
assert null[-2] == SPECIAL_TOKEN_IDS["<|im_end|>"]
|
||||
assert null[-1] == SPECIAL_TOKEN_IDS["<|audio_start|>"]
|
||||
assert set(null[1:-2]) == {SPECIAL_TOKEN_IDS["<|audio_cfg|>"]}
|
||||
|
||||
def test_input_is_not_mutated(self) -> None:
|
||||
ids = self._prompt_ids(6)
|
||||
before = list(ids)
|
||||
build_cfg_null_token_ids(ids)
|
||||
assert ids == before
|
||||
|
||||
def test_minimal_prompt_with_empty_body(self) -> None:
|
||||
ids = [
|
||||
SPECIAL_TOKEN_IDS["<|im_start|>"],
|
||||
SPECIAL_TOKEN_IDS["<|audio_cfg|>"],
|
||||
SPECIAL_TOKEN_IDS["<|im_end|>"],
|
||||
SPECIAL_TOKEN_IDS["<|audio_start|>"],
|
||||
]
|
||||
assert build_cfg_null_token_ids(ids) == ids
|
||||
|
||||
def test_too_short_is_rejected(self) -> None:
|
||||
with pytest.raises(ValueError, match="too short"):
|
||||
build_cfg_null_token_ids([1, 2, 3])
|
||||
|
||||
def test_wrong_framing_is_rejected(self) -> None:
|
||||
with pytest.raises(ValueError, match="framing"):
|
||||
build_cfg_null_token_ids([9, 9, 9, 9, 9])
|
||||
|
||||
|
||||
class TestChunkWindows:
|
||||
def test_no_frames_yields_nothing(self) -> None:
|
||||
assert chunk_windows(0) == []
|
||||
|
||||
def test_short_song_is_one_final_window(self) -> None:
|
||||
windows = chunk_windows(50)
|
||||
assert len(windows) == 1
|
||||
assert (windows[0].start, windows[0].end) == (0, 50)
|
||||
assert windows[0].is_first and windows[0].is_last
|
||||
|
||||
def test_exactly_one_window(self) -> None:
|
||||
windows = chunk_windows(AR_CHUNK_FRAMES)
|
||||
assert len(windows) == 1
|
||||
assert windows[0].is_last
|
||||
|
||||
def test_windows_advance_by_the_hop(self) -> None:
|
||||
windows = chunk_windows(450)
|
||||
starts = [w.start for w in windows]
|
||||
assert starts == list(range(0, starts[-1] + 1, AR_CHUNK_HOP_FRAMES))
|
||||
assert windows[-1].is_last
|
||||
assert not any(w.is_last for w in windows[:-1])
|
||||
|
||||
def test_windows_cover_every_frame(self) -> None:
|
||||
frames = 437
|
||||
windows = chunk_windows(frames)
|
||||
covered: set[int] = set()
|
||||
for window in windows:
|
||||
covered.update(range(window.start, window.end))
|
||||
assert covered == set(range(frames))
|
||||
|
||||
def test_final_window_is_clamped_to_the_song(self) -> None:
|
||||
windows = chunk_windows(437)
|
||||
assert windows[-1].end == 437
|
||||
|
||||
def test_only_the_first_window_is_first(self) -> None:
|
||||
windows = chunk_windows(600)
|
||||
assert windows[0].is_first
|
||||
assert not any(w.is_first for w in windows[1:])
|
||||
|
||||
|
||||
class TestCropBounds:
|
||||
def test_single_window_song_is_not_cropped(self) -> None:
|
||||
window = chunk_windows(50)[0]
|
||||
assert crop_sample_bounds(window) == (0, 0)
|
||||
|
||||
def test_first_window_keeps_its_head(self) -> None:
|
||||
left, right = crop_sample_bounds(chunk_windows(600)[0])
|
||||
assert left == 0
|
||||
assert right == (HOP_MEL_FRAMES - BLEND_MEL_FRAMES) * DAV_HOP_SAMPLES
|
||||
|
||||
def test_last_window_keeps_its_tail(self) -> None:
|
||||
left, right = crop_sample_bounds(chunk_windows(600)[-1])
|
||||
assert left == BLEND_MEL_FRAMES * DAV_HOP_SAMPLES
|
||||
assert right == 0
|
||||
|
||||
def test_middle_window_is_cropped_on_both_sides(self) -> None:
|
||||
windows = chunk_windows(900)
|
||||
middle = windows[len(windows) // 2]
|
||||
left, right = crop_sample_bounds(middle)
|
||||
assert left > 0 and right > 0
|
||||
|
||||
def test_overlap_is_half_a_window(self) -> None:
|
||||
assert overlap_mel_length() == HOP_MEL_FRAMES // 2
|
||||
@@ -0,0 +1,217 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Regression tests for MiniMax Music 3 row-to-request binding.
|
||||
|
||||
vLLM compacts and reorders the persistent batch whenever a request finishes or
|
||||
is preempted, so a batch row number is only meaningful within the step that
|
||||
produced it. Every failure pinned here was a real defect: state keyed by row
|
||||
survived into a step where the row belonged to someone else, or state keyed by
|
||||
one spelling of the request id was released under another and leaked.
|
||||
|
||||
These exercise the pure helpers and the binding arithmetic directly, so they
|
||||
need no GPU and no engine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.model_executor.models.minimax_music3.constants import (
|
||||
DEFAULT_MAX_AUDIO_FRAMES,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minimax_music3.talker import (
|
||||
_CFG_UNCOND_SUFFIX,
|
||||
_first_int,
|
||||
_pair_id_from_request,
|
||||
_request_key,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class TestRequestKey:
|
||||
def test_engine_list_form_is_unwrapped(self) -> None:
|
||||
"""The engine injects global_request_id as a single-element list.
|
||||
|
||||
Keying state on the list's ``str()`` would produce "['req-1']", which
|
||||
never matches the bare id that ``on_requests_finished`` delivers, so
|
||||
every finished song would leak its frame buffer.
|
||||
"""
|
||||
assert _request_key(["req-1"]) == "req-1"
|
||||
|
||||
def test_bare_string_is_unchanged(self) -> None:
|
||||
assert _request_key("req-1") == "req-1"
|
||||
|
||||
def test_list_and_bare_forms_agree(self) -> None:
|
||||
"""Allocation and release must land on the same key."""
|
||||
assert _request_key(["req-1"]) == _request_key("req-1")
|
||||
|
||||
def test_empty_and_missing_are_stable(self) -> None:
|
||||
assert _request_key(None) == ""
|
||||
assert _request_key([]) == ""
|
||||
|
||||
def test_tuple_form(self) -> None:
|
||||
assert _request_key(("req-9",)) == "req-9"
|
||||
|
||||
|
||||
class TestPairIdDerivation:
|
||||
def test_conditioned_request_is_its_own_pair(self) -> None:
|
||||
assert _pair_id_from_request("req-1") == "req-1"
|
||||
|
||||
def test_companion_maps_to_its_partner(self) -> None:
|
||||
assert _pair_id_from_request(f"req-1{_CFG_UNCOND_SUFFIX}") == "req-1"
|
||||
|
||||
def test_pair_members_agree_on_the_pair_id(self) -> None:
|
||||
cond = "req-42"
|
||||
uncond = f"req-42{_CFG_UNCOND_SUFFIX}"
|
||||
assert _pair_id_from_request(cond) == _pair_id_from_request(uncond)
|
||||
|
||||
def test_a_request_ending_in_a_similar_string_is_not_confused(self) -> None:
|
||||
assert _pair_id_from_request("req-cfg_uncond") == "req-cfg_uncond"
|
||||
|
||||
|
||||
class TestFirstInt:
|
||||
def test_serving_layer_list_wrapping(self) -> None:
|
||||
"""tts_params values arrive wrapped in a single-element list."""
|
||||
assert _first_int([750], DEFAULT_MAX_AUDIO_FRAMES) == 750
|
||||
|
||||
def test_bare_value(self) -> None:
|
||||
assert _first_int(750, DEFAULT_MAX_AUDIO_FRAMES) == 750
|
||||
|
||||
def test_missing_falls_back(self) -> None:
|
||||
assert _first_int(None, DEFAULT_MAX_AUDIO_FRAMES) == DEFAULT_MAX_AUDIO_FRAMES
|
||||
assert _first_int([], DEFAULT_MAX_AUDIO_FRAMES) == DEFAULT_MAX_AUDIO_FRAMES
|
||||
|
||||
def test_nonsense_falls_back_rather_than_raising(self) -> None:
|
||||
assert _first_int("abc", DEFAULT_MAX_AUDIO_FRAMES) == DEFAULT_MAX_AUDIO_FRAMES
|
||||
assert _first_int(0, DEFAULT_MAX_AUDIO_FRAMES) == DEFAULT_MAX_AUDIO_FRAMES
|
||||
assert _first_int(-5, DEFAULT_MAX_AUDIO_FRAMES) == DEFAULT_MAX_AUDIO_FRAMES
|
||||
|
||||
|
||||
def _feedback_plan(
|
||||
request_ids: list[str],
|
||||
computed: list[int],
|
||||
scheduled: list[int],
|
||||
prompt_tokens: dict[str, int],
|
||||
has_codes: set[str],
|
||||
) -> list[int]:
|
||||
"""Mirror of the talker's feedback row selection.
|
||||
|
||||
Returns the flat token offsets whose embedding gets replaced by an audio
|
||||
frame. Kept as a standalone function so the arithmetic can be checked
|
||||
without constructing a model.
|
||||
"""
|
||||
offsets: list[int] = []
|
||||
running = 0
|
||||
for count in scheduled:
|
||||
offsets.append(running)
|
||||
running += count
|
||||
|
||||
rows: list[int] = []
|
||||
for row, request_id in enumerate(request_ids):
|
||||
if request_id not in has_codes:
|
||||
continue
|
||||
if scheduled[row] != 1 or computed[row] < prompt_tokens.get(request_id, 0):
|
||||
continue
|
||||
rows.append(offsets[row])
|
||||
return rows
|
||||
|
||||
|
||||
class TestFeedbackRowSelection:
|
||||
"""The guard that decides which token embeddings become audio frames."""
|
||||
|
||||
def test_decode_only_step_replaces_every_row(self) -> None:
|
||||
ids = ["a", "a__cfg_uncond", "b", "b__cfg_uncond"]
|
||||
rows = _feedback_plan(
|
||||
ids,
|
||||
computed=[120, 120, 90, 90],
|
||||
scheduled=[1, 1, 1, 1],
|
||||
prompt_tokens={i: 50 for i in ids},
|
||||
has_codes=set(ids),
|
||||
)
|
||||
assert rows == [0, 1, 2, 3]
|
||||
|
||||
def test_prefill_row_is_never_overwritten(self) -> None:
|
||||
"""A prompt token must keep its real embedding.
|
||||
|
||||
The earlier guard compared the flat token count against a row budget,
|
||||
so a short prompt slipped through and had its first embeddings
|
||||
replaced with a previous request's audio frame.
|
||||
"""
|
||||
rows = _feedback_plan(
|
||||
["a", "b"],
|
||||
computed=[0, 300],
|
||||
scheduled=[8, 1],
|
||||
prompt_tokens={"a": 8, "b": 40},
|
||||
has_codes={"b"},
|
||||
)
|
||||
# Row 0 occupies flat tokens 0..7 and must be untouched; row 1's single
|
||||
# decode token sits at flat offset 8.
|
||||
assert rows == [8]
|
||||
|
||||
def test_short_prefill_below_the_row_budget_is_still_safe(self) -> None:
|
||||
"""The exact shape that defeated the old token-count guard."""
|
||||
rows = _feedback_plan(
|
||||
["a"],
|
||||
computed=[0],
|
||||
scheduled=[10],
|
||||
prompt_tokens={"a": 10},
|
||||
has_codes={"a"},
|
||||
)
|
||||
assert rows == []
|
||||
|
||||
def test_chunked_prefill_midway_is_not_fed_back(self) -> None:
|
||||
rows = _feedback_plan(
|
||||
["a"],
|
||||
computed=[2048],
|
||||
scheduled=[2048],
|
||||
prompt_tokens={"a": 5000},
|
||||
has_codes={"a"},
|
||||
)
|
||||
assert rows == []
|
||||
|
||||
def test_final_prefill_chunk_is_not_fed_back(self) -> None:
|
||||
"""The step that completes a prompt still carries prompt tokens."""
|
||||
rows = _feedback_plan(
|
||||
["a"],
|
||||
computed=[4096],
|
||||
scheduled=[904],
|
||||
prompt_tokens={"a": 5000},
|
||||
has_codes={"a"},
|
||||
)
|
||||
assert rows == []
|
||||
|
||||
def test_mixed_batch_offsets_follow_the_flat_layout(self) -> None:
|
||||
"""Tokens are a flat concatenation, so offsets are a running sum."""
|
||||
ids = ["p", "d1", "d2"]
|
||||
rows = _feedback_plan(
|
||||
ids,
|
||||
computed=[0, 500, 700],
|
||||
scheduled=[64, 1, 1],
|
||||
prompt_tokens={"p": 64, "d1": 40, "d2": 40},
|
||||
has_codes={"d1", "d2"},
|
||||
)
|
||||
assert rows == [64, 65]
|
||||
|
||||
def test_request_without_codes_yet_is_skipped(self) -> None:
|
||||
"""The first decode step after prefill has no previous frame."""
|
||||
rows = _feedback_plan(
|
||||
["a"],
|
||||
computed=[50],
|
||||
scheduled=[1],
|
||||
prompt_tokens={"a": 50},
|
||||
has_codes=set(),
|
||||
)
|
||||
assert rows == []
|
||||
|
||||
def test_reordered_batch_binds_by_request_not_position(self) -> None:
|
||||
"""The same two requests in swapped rows get swapped offsets.
|
||||
|
||||
This is the compaction case: holding codes in a row-indexed buffer
|
||||
would have handed each request the other's frame.
|
||||
"""
|
||||
prompt_tokens = {"a": 50, "b": 50}
|
||||
first = _feedback_plan(["a", "b"], [100, 200], [1, 1], prompt_tokens, {"a"})
|
||||
swapped = _feedback_plan(["b", "a"], [200, 100], [1, 1], prompt_tokens, {"a"})
|
||||
assert first == [0]
|
||||
assert swapped == [1]
|
||||
Reference in new issue
Block a user