Files
hao-ai-lab-FastVideo/v2/tests/test_thinker_talker.py
T
SolitaryThinker c7e0a8e894 [feat] Add Qwen-Omni thinker→talker→vocoder model (3 experts, 3 loops)
Ports vllm-omni's canonical qwen2_5_omni omni-speech cascade as a v2 card:
a third weight-sharing topology — three disjoint experts (thinker, talker,
vocoder) on three loop types (ar_decode → ar_decode → audio_decode) in one
request, with chained cross-stage conditioning and streaming codec→waveform.
vllm-omni runs these as three opaque request-scheduled stages; v2 makes every
thinker token, talker token, and vocoder chunk a runtime-visible WorkUnit.

- models/backend.py: ToyTalker (a genuinely distinct AR expert, weight-salted)
  + ToyVocoder (streaming code2wav: codec tokens → waveform chunks).
- models/omni/vocoder_loop.py: VocoderLoop filling the pre-declared
  LoopKind.AUDIO_DECODE / WorkUnitKind.AUDIO_CHUNK slot.
- models/omni/ar_loop.py: ARDecodeLoop gains a configurable prompt_slot so two
  chained AR loops don't collide on the prefill slot (thinker vs talker).
- models/qwen_omni/: card (3 experts/3 loops) + program (tokenize → thinker →
  emit_text → thinker→talker full-payload hand-off → talker → talker→vocoder →
  vocoder → emit_audio). Cross-stage hand-offs are explicit Program nodes, the
  model-native form of vllm-omni's custom_process_input_func.
- _enums.py: Capability.TEXT_TO_SPEECH.
- tests/test_thinker_talker.py: 6 tests incl. three-loop interleave parity,
  cascade conditioning, AUDIO_CHUNK streaming. Full suite 97 passed.

designv4.md: §2.3 topology table extended to four topologies; new §9.5 on the
cascade; reference-synthesis + package layout updated.
2026-07-04 17:15:34 +00:00

115 lines
5.3 KiB
Python

"""Qwen-Omni thinker→talker→vocoder — the third weight-sharing topology (design_v3 §4; vllm-omni
``qwen2_5_omni`` pipeline).
Asserts the Card/Loop/Program vocabulary holds for a THREE-expert, THREE-loop cascade — the topology
neither MoT (one shared module, two loops) nor joint-RL (two disjoint experts, two loops) covers:
* three disjoint experts (thinker, talker, vocoder) on three loop *types* (ar_decode, ar_decode,
audio_decode) in one request — every token and vocoder chunk a runtime-visible WorkUnit;
* cascaded conditioning — the talker reads the thinker's tokens+hidden state, the vocoder the
talker's speech tokens (the model-native form of vllm-omni's ``custom_process_input_func``);
* streaming codec→waveform via AUDIO_CHUNK WorkUnits;
* the three-loop program still passes the interleave parity gate.
"""
from __future__ import annotations
import numpy as np
from v2._enums import LoopKind, WorkUnitKind
from v2.card import load_card
from v2.cache import CacheManager
from v2.models import build_qwen_omni_card, build_qwen_omni_program
from v2.models.qwen_omni.program import thinker_to_talker_node
from v2.parity import assert_interleave_parity
from v2.request import OutputSpec, TaskType, make_request
from v2.runtime import Engine
def _engine():
card = build_qwen_omni_card()
inst = load_card(card, cache_manager=CacheManager.from_card(card))
eng = Engine()
eng.register(card.model_id, inst, build_qwen_omni_program())
return eng, inst
def _run(eng, prompt="hello world", *, stream_audio=False):
out = OutputSpec(stream={"audio": True}) if stream_audio else OutputSpec()
return eng.run(make_request(TaskType.T2A, "qwen-omni-tts", prompt, outputs=out))
# --- card / serve shape ----------------------------------------------------------- #
def test_qwen_omni_card_validates_and_serves_text_and_audio():
eng, _ = _engine()
res = _run(eng, "Explain audio generation in 15 words.")
assert res.artifacts["text"].text.startswith("tok:") # thinker text
aud = res.artifacts["audio"]
samples = np.asarray(aud.samples)
assert samples.ndim == 1 and samples.size > 0 # vocoder waveform
assert aud.sample_rate == 44100 # AudioArtifact, first-class rate
assert float(np.abs(samples).max()) <= 1.0 # bounded waveform
def test_three_disjoint_experts_three_loop_types():
"""The third topology: three loops bind three DISTINCT components (no sharing) — vs Cosmos3 where
two loops bind the SAME MoT module."""
card = build_qwen_omni_card()
kinds = {lid: spec.kind for lid, spec in card.loops.items()}
assert kinds == {"thinker_decode": LoopKind.AR_DECODE,
"talker_decode": LoopKind.AR_DECODE,
"vocoder": LoopKind.AUDIO_DECODE}
assert card.loops["vocoder"].work_unit_kind == WorkUnitKind.AUDIO_CHUNK
bound = [card.loops[l].shared_weight_components for l in ("thinker_decode", "talker_decode", "vocoder")]
assert bound == [["thinker"], ["talker"], ["vocoder"]] # three disjoint singletons
_, inst = _engine()
experts = {id(inst.component(c)) for c in ("thinker", "talker", "vocoder")}
assert len(experts) == 3 # genuinely separate weights
# --- cascade conditioning (the hand-offs are real) -------------------------------- #
def test_thinker_to_talker_passes_full_payload():
"""The talker prefill = the thinker's tokens PLUS a hidden-state-derived token (vllm-omni's
``thinker2talker_full_payload``)."""
_, inst = _engine()
slots = {"thinker_out": {"tokens": [11, 22, 33]}}
thinker_to_talker_node(inst, slots, None, None)
pre = slots["talker_prompt_tokens"]
assert pre[:3] == [11, 22, 33] # thinker tokens carried forward
assert len(pre) == 4 # + one hidden-state-derived token
def test_cascade_propagates_thinker_through_to_audio():
"""Different prompt → different thinker tokens → different talker speech → different waveform.
Proves the three loops are conditioned, not independent."""
eng_a, _ = _engine()
eng_b, _ = _engine()
a = _run(eng_a, "alpha")
b = _run(eng_b, "beta gamma delta")
assert a.artifacts["text"].text != b.artifacts["text"].text
assert not np.array_equal(np.asarray(a.artifacts["audio"].samples),
np.asarray(b.artifacts["audio"].samples))
# --- vocoder as runtime-visible AUDIO_CHUNK WorkUnits + streaming ------------------ #
def test_vocoder_streams_audio_chunks():
eng_off, _ = _engine()
eng_on, _ = _engine()
off = _run(eng_off, stream_audio=False)
on = _run(eng_on, stream_audio=True)
n_chunks = on.metrics["audio_chunks"]
assert n_chunks >= 1
# the audio-streaming delta is exactly the vocoder's per-chunk emits (AR text emits are common)
assert on.metrics["stream_chunks"] - off.metrics["stream_chunks"] == n_chunks
# --- the three-loop program still passes the core parity gate --------------------- #
def test_qwen_omni_interleave_parity():
eng, _ = _engine()
reqs = [make_request(TaskType.T2A, "qwen-omni-tts", p) for p in ("alpha", "beta", "alpha")]
divs = assert_interleave_parity(eng, reqs)
assert not divs, f"thinker→talker→vocoder failed interleave parity: {divs}"