[Model] Add Gepard-1.0 native-AR FSQ/NanoCodec TTS (offline inference) (#5666)

Signed-off-by: Mengjie Zhao <zmj0129@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-08-14 22:06:05 -07:00
1 parent d671e6b0db
commit 35cacf020c
17 files changed
+2727

No files matched your search

+16
View File
@@ -108,6 +108,22 @@ steps:
--run-level "full_model"
mirror_hardwares: h100_1
# Own step: the image has no NanoCodec, so this file import-skips out of
# the mark-selected TTS steps above. The overrides keep nemo_toolkit[tts]
# from downgrading transformers; see
# examples/offline_inference/text_to_speech/README.md.
- label: "TTS · Gepard-1.0 · L4"
timeout_in_minutes: 60
commands:
- export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
- printf '%s\n' "transformers>=5.5.3" "huggingface-hub>=1.0" > /tmp/gepard-overrides.txt
- uv pip install --python "$(python3 -c 'import sys; print(sys.executable)')" "nemo_toolkit[tts]==2.7.3" --overrides /tmp/gepard-overrides.txt
- >-
pytest -s -v tests/e2e/offline_inference/test_gepard_tts.py
-m "tts and cuda"
--run-level "full_model"
mirror_hardwares: l4_1
- label: "Diffusion · L4"
timeout_in_minutes: 60
commands:
+1
View File
@@ -98,6 +98,7 @@ th {
|`DyninOmniForConditionalGeneration` | Dynin-Omni | `snu-aidas/Dynin-Omni` | ✅︎ | | | | — |
| `MiniCPMO45OmniForConditionalGeneration` | MiniCPM-o 4.5 | `openbmb/MiniCPM-o-4_5` | ✅︎ | | ✅︎ | | — |
| `ErnieImagePipeline` | ERNIE-Image | `baidu/ERNIE-Image`, `baidu/ERNIE-Image-Turbo` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `GepardTalkerForConditionalGeneration` | Gepard-1.0 | `nineninesix/gepard-1.0` | ✅︎ | | | | — |
|`HiDreamImagePipeline` | HiDream-I1-Full | `HiDream-ai/HiDream-I1-Full` | ✅︎ | ✅︎ | | | — |
The `Recipe` column links the corresponding deployment recipe. `Published` is
@@ -16,6 +16,7 @@ list of supported architectures across all modalities, see
|---|---|---|---|---|---|---|
| CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | 2 (talker + code2wav) | ✓ | ✓ | — | 24 kHz |
| Fish Speech S2 Pro | `fishaudio/s2-pro` | dual-AR | ✓ | ✓ | — | 44.1 kHz |
| Gepard-1.0 | `nineninesix/gepard-1.0` | single (native AR + NanoCodec) | — (zero-shot; cloning WIP) | — (serving WIP) | zero-shot | 22.05 kHz |
| GLM-TTS | `zai-org/GLM-TTS` | 2 (AR + DiT) | ✓ (required) | ✓ | — | 24 kHz |
| Ming-omni-tts | `inclusionAI/Ming-omni-tts-0.5B` | 2 (AR + audio VAE) | ✓ | ✓ | style / IP / dialect / TTA / podcast | 44.1 kHz |
| Ming-flash-omni-TTS | `Jonathan1909/Ming-flash-omni-2.0` | single (talker only) | — (caption-controlled) | — | style / IP / basic captions | 44.1 kHz |
@@ -395,6 +396,58 @@ python examples/offline_inference/text_to_speech/qwen3_tts/end2end.py \
---
## Gepard-1.0
Single-stage native AR TTS at 22.05 kHz. Pipeline: `Qwen3.5 backbone → 32 FSQ codebook heads (one frame/step) → NeMo NanoCodec`. Backbone runs under vLLM paged attention; the 32-head sampling + learned embedding feedback ride the native-AR runner hooks. `enforce_eager` (CUDA graph is a perf follow-up).
### Prerequisites
The NanoCodec decoder needs NeMo (NVIDIA Open Model License), installed separately:
```bash
uv pip install "nemo_toolkit[tts]==2.7.3" \
--overrides <(printf '%s\n' "transformers>=5.5.3" "huggingface-hub>=1.0")
```
The overrides are what make that command work. `nemo_toolkit[tts]` declares
`transformers~=4.57.0`, so a plain install downgrades the version
`requirements/common.txt` pins and takes `huggingface-hub` back to 0.x with it — after
which `import vllm` fails. Both packages are named here rather than left to resolve on
their own: transformers 5.x happens to require `huggingface-hub` 1.x, but relying on
that would make the second repair an accident of the first. `--constraint` cannot be
used instead, since it narrows a version range rather than overriding a dependency a
package declares, and the two sets never intersect. NeMo 2.7.3 decodes correctly
against transformers 5.x, so nothing is lost by holding it there.
(The same clash is why this is documented here rather than declared in
`pyproject.toml`: an extra resolves together with the base dependencies, so
`vllm-omni[gepard]` would be unsatisfiable rather than opt-in. SoulX-Singer English
SVS documents `nemo_toolkit[asr]` the same way.)
On a host whose CUDA toolkit cannot build kernels for the GPU — no `nvcc`/`ninja`, or a
consumer Blackwell (`sm_120`) card — also set `VLLM_USE_FLASHINFER_SAMPLER=0`. vLLM's
sampler JIT-compiles through FlashInfer on first use, and where that build fails the engine
dies inside `profile_run` long after every import has succeeded, which reads as a model
failure rather than an environment one. The PyTorch sampler it falls back to costs nothing
here.
### Quick start (zero-shot, default voice)
```bash
python examples/offline_inference/text_to_speech/gepard/end2end.py \
--text "Hello, this is a Gepard demo."
```
### Voice cloning
Not yet — PR1 is zero-shot only (the learned `null_prefix` default voice). Reference-audio cloning (`ref_compressor` speaker prefix) is a follow-up PR.
### Notes
- Output: 22.05 kHz mono WAV.
- First run downloads two checkpoints, not one: the model itself, and the NanoCodec decoder it names in `codec_id` (`nvidia/nemo-nano-codec-22khz-1.89kbps-21.5fps` by default). Both are fetched automatically; expect the first startup to be correspondingly slower.
- Offline only for now; the `/v1/audio/speech` adapter is a follow-up PR.
- Deploy config: `vllm_omni/deploy/gepard.yaml` (the example's default; copy it and pass `--deploy-config` to change it).
- Generation length and reproducibility are stage settings, not script flags. One output token is one audio frame, so `max_tokens` in the YAML is the frame budget; `seed` makes the in-model 32-head sampling reproducible. The script deliberately passes no `SamplingParams`: one supplied by a caller replaces the stage defaults wholesale rather than merging, which would drop the pipeline's stop token and run every request to `max_tokens`.
- Text length: short texts are repeated internally to match the training layout (the checkpoint's `text_repetition` block); the upper bound is the stage's `max_model_len`, enforced by the engine. Empty text is rejected rather than voiced.
- `VLLM_GEPARD_GREEDY=1` swaps the 32-head Gumbel-max sampling for argmax, for reproducible comparisons that do not depend on a seed.
## VoxCPM2
Single-stage native AR TTS at 48 kHz. Pipeline: `feat_encoder → MiniCPM4 → FSQ → residual_lm → LocDiT → AudioVAE`.
@@ -0,0 +1,139 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Offline inference example for Gepard-1.0 TTS via vLLM-Omni.
Single-stage native-AR pipeline: a Qwen3.5 backbone samples one 32-code FSQ
frame per step; the NeMo NanoCodec decodes the frames to a 22.05 kHz mono
waveform. Zero-shot: the default learned voice, no reference audio.
Generation length and seed are stage settings rather than flags here. One
output token is one audio frame, so ``max_tokens`` in the deploy YAML is the
frame budget; pass a copy of the YAML via ``--deploy-config`` to change it.
Usage:
python end2end.py --text "Hello, this is Gepard speaking."
"""
from __future__ import annotations
import os
from pathlib import Path
import soundfile as sf
import torch
import vllm_omni
from vllm_omni.utils.tracking_parser import TrackingArgumentParser
os.environ.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
from vllm_omni import Omni # noqa: E402
MODEL = "nineninesix/gepard-1.0"
SAMPLE_RATE = 22050
# Absolute: the loader hands the path straight to OmegaConf, which resolves a
# bare name against the current working directory, not the package.
DEFAULT_DEPLOY_CONFIG = str(Path(vllm_omni.__file__).resolve().parent / "deploy" / "gepard.yaml")
def build_request(text: str) -> dict:
"""Build an Omni request payload for Gepard (zero-shot, no ref audio).
The model only consumes the [speaker slots, SOT, text, EOT, SOS] layout, so
a bare text prompt would have no speaker slots and no SOS.
"""
from transformers import AutoTokenizer
from vllm_omni.model_executor.models.gepard.configuration_gepard import GepardConfig
from vllm_omni.model_executor.models.gepard.prompt import build_gepard_prompt_ids
# From the checkpoint's own sidecar, which is what the worker builds its
# config from too. A layout that disagrees with the trained one does not
# fail, it just degrades the speech.
cfg = GepardConfig.from_checkpoint(MODEL)
tokenizer = AutoTokenizer.from_pretrained(MODEL, trust_remote_code=True)
prompt_token_ids = build_gepard_prompt_ids(
tokenizer(text, add_special_tokens=False)["input_ids"],
config=cfg,
)
return {
"prompt_token_ids": prompt_token_ids,
"additional_information": {"text": [text]},
}
def save_audio(waveform: torch.Tensor, path: str, sample_rate: int = SAMPLE_RATE) -> None:
sf.write(path, waveform.float().numpy(), sample_rate)
seconds = waveform.numel() / sample_rate
print(
f" Saved {path} ({tuple(waveform.shape)}, {sample_rate} Hz, "
f"{seconds:.2f}s, peak {float(waveform.abs().max()):.3f})"
)
def main(args) -> None:
omni = Omni(
model=MODEL,
deploy_config=args.deploy_config,
stage_init_timeout=args.stage_init_timeout,
init_timeout=args.init_timeout,
)
# No explicit SamplingParams: one supplied here would replace the stage
# defaults rather than merge over them, dropping the pipeline's stop token.
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
print(f"Synthesizing: {args.text!r}")
inputs = build_request(text=args.text)
for i, stage_outputs in enumerate(omni.generate(inputs)):
for j, out in enumerate(stage_outputs.outputs):
mm = out.multimodal_output
if mm is None:
print(f" [req {i}] No audio output.")
continue
# The consolidation path renames "model_outputs" to "audio".
audio = mm.get("audio")
if audio is None:
audio = mm.get("model_outputs")
if isinstance(audio, list):
audio = audio[0] if len(audio) else None
if audio is None:
print(f" [req {i}] No waveform in multimodal_output (keys: {list(mm)}).")
continue
sr_tensor = mm.get("sr")
sr = int(sr_tensor.item()) if hasattr(sr_tensor, "item") else SAMPLE_RATE
save_audio(audio.cpu(), str(output_dir / f"output_{i}_{j}.wav"), sr)
print("Done.")
def parse_args():
parser = TrackingArgumentParser(description="Gepard-1.0 offline TTS inference (zero-shot)")
parser.add_argument("--text", default="Hello, this is Gepard speaking.", help="Text to synthesize.")
parser.add_argument(
"--output-dir",
default=os.path.join(
os.environ.get("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
"gepard_output",
),
help="Directory for WAV outputs (default: ~/.cache/gepard_output).",
)
parser.add_argument(
"--deploy-config",
default=DEFAULT_DEPLOY_CONFIG,
help=(
"Path to the deploy YAML (default: the packaged vllm_omni/deploy/gepard.yaml). This must "
"stay set: without the YAML's `pipeline: gepard` pin the architectures fallback routes "
"Qwen3_5ForCausalLM to the diffusion registry. Copy it to change max_tokens or seed."
),
)
# The upstream defaults are too tight for a cold start.
parser.add_argument("--stage-init-timeout", type=int, default=900)
parser.add_argument("--init-timeout", type=int, default=1800)
return parser.parse_args()
if __name__ == "__main__":
main(parse_args())
@@ -0,0 +1,345 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""E2E offline inference tests for the Gepard-1.0 single-stage native-AR TTS.
Zero-shot only (the model's default learned voice). These tests need a GPU and
the NeMo NanoCodec, so the whole file runs in the weekly tier -- see the comment
above ``pytestmark``. The window arithmetic behind the streaming decode is
covered on CPU in ``tests/model_executor/models/test_gepard_window.py``.
"""
from __future__ import annotations
import functools
import tempfile
from pathlib import Path
from typing import NamedTuple
import pytest
import soundfile as sf
import torch
from transformers import AutoTokenizer
from tests.helpers.mark import hardware_test
from tests.helpers.media import convert_audio_file_to_text
from tests.helpers.runtime import OmniRunner # noqa: F401 (indirect fixture)
from tests.helpers.stage_config import get_deploy_config_path
from vllm_omni import Omni
from vllm_omni.model_executor.models.gepard.configuration_gepard import GepardConfig
from vllm_omni.model_executor.models.gepard.prompt import build_gepard_prompt_ids
# The codec decoder is an optional dependency; skip rather than fail the engine
# inside the worker process when it is absent. The CI image does not carry it,
# so the weekly step installs it first -- see .buildkite/cuda/test-weekly.yml.
# Install recipe: examples/offline_inference/text_to_speech/README.md.
pytest.importorskip("nemo.collections.tts.models")
MODEL_NAME = "nineninesix/gepard-1.0"
STAGE_CONFIG = get_deploy_config_path("gepard.yaml")
SAMPLE_RATE = 22050
# NanoCodec upsamples one FSQ frame to exactly this many samples.
SAMPLES_PER_FRAME = 1024
@functools.cache
def _config() -> GepardConfig:
"""The checkpoint's own layout, not the class defaults.
The worker builds its config from the ``gepard_config.json`` sidecar, so a
prompt assembled from the defaults would silently use a different layout
the day the sidecar moves — and a mismatched layout degrades the speech
rather than failing, which is the hardest way for this to be found.
"""
return GepardConfig.from_checkpoint(MODEL_NAME)
# Prompt -> a word that appears in no other prompt, so a clip can be traced
# back to the request that asked for it.
#
# These are not interchangeable. Batching perturbs every request's sampling
# trajectory, and on some trajectories this checkpoint's stop head never fires;
# this set was screened over 10 batched runs with no runaway. Re-screen the
# whole set when changing one, not just the row changed.
_DISTINGUISHABLE_PROMPTS = {
"He drinks coffee every morning.": "coffee",
"Machine learning is interesting.": "learning",
"Please close the window before leaving.": "window",
"My favorite color is purple.": "purple",
}
# Above text_repetition.apply_below, so the layout is voiced once instead of
# repeated. Every other prompt here takes the repeated branch.
_LONG_TEXT = (
"The morning train was late again, so I walked the last two miles along the "
"river and reached the office just before nine."
)
_OMNI_RUNNER_PARAM = (
MODEL_NAME,
STAGE_CONFIG,
{"trust_remote_code": True},
)
# Only a common word is checked, which needs less ASR than the shared helper's
# ``small`` default; a miss escalates to it.
_ASR_MODEL = "base"
_ASR_ESCALATION_MODEL = "small"
# Transcription dominates this file's runtime -- Whisper runs on CPU on a
# single-GPU runner -- so only two of the four concurrent clips are read as
# speech. The pairwise waveform comparison covers all four.
_TRANSCRIBED_CLIPS = 2
# No level marker at module scope: each test carries the one matching how often
# its claim needs re-checking, though the weekly step collects the file whole.
# There is deliberately no core_model row -- under dummy weights the stop head is
# random, so every request would run to gepard.yaml's max_tokens.
pytestmark = [
pytest.mark.tts,
pytest.mark.parametrize("omni_runner", [_OMNI_RUNNER_PARAM], indirect=True),
]
def _build_request(text: str) -> dict:
"""Assemble the [speaker slots, SOT, text, EOT, SOS] prompt."""
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True)
prompt_token_ids = build_gepard_prompt_ids(
tokenizer(text, add_special_tokens=False)["input_ids"],
config=_config(),
)
return {
"prompt_token_ids": prompt_token_ids,
"additional_information": {"text": [text]},
}
def _extract_audio(mm) -> torch.Tensor | None:
# The consolidation path renames "model_outputs" to "audio".
audio = mm.get("audio")
if audio is None:
audio = mm.get("model_outputs")
if isinstance(audio, list):
audio = audio[0] if len(audio) else None
return audio
class _Clip(NamedTuple):
wav: torch.Tensor
# Counts the frames the codec was handed using only what the engine
# returned, never the model's internals.
committed_frames: int
def _synthesize_all(omni: Omni, texts: list[str]) -> list[_Clip]:
"""Submit every text in one call; return one clip per request, in
submission order.
``Omni.generate`` yields in completion order and never sorts, so the pairing
is restored from the request id prefix it assigns (``f"{i}_{uuid4()}"``).
Without that, the only testable claim is that the batch as a whole looks
plausible, which a stable swap between two requests satisfies.
Nothing here authors a SamplingParams: a fresh one would replace the stage
defaults rather than merge over them, dropping the pipeline's stop token.
"""
outputs = omni.generate([_build_request(t) for t in texts])
assert len(outputs) == len(texts), f"expected {len(texts)} outputs, got {len(outputs)}"
by_index: dict[int, _Clip] = {}
for stage_outputs in outputs:
index = int(str(stage_outputs.request_id).split("_", 1)[0])
waveform = None
frames = 0
for out in stage_outputs.outputs:
# Explicit None check: `x or []` would ask an array for a truth value.
token_ids = out.token_ids if out.token_ids is not None else []
# STOP sits just past head0's range, so a token equal to it is
# never a committed frame.
frames += sum(1 for t in token_ids if int(t) != _config().stop_token)
mm = out.multimodal_output
if mm is None:
continue
audio = _extract_audio(mm)
if audio is None:
continue
sr_t = mm.get("sr")
if hasattr(sr_t, "item"):
assert int(sr_t.item()) == SAMPLE_RATE
waveform = audio.cpu().float()
assert waveform is not None, "no audio output produced"
by_index[index] = _Clip(wav=waveform, committed_frames=frames)
assert sorted(by_index) == list(range(len(texts))), (
f"request ids did not cover every submitted prompt exactly once: got {sorted(by_index)}"
)
return [by_index[i] for i in range(len(texts))]
def _synthesize(omni: Omni, text: str) -> _Clip:
return _synthesize_all(omni, [text])[0]
def _assert_clip_is_sane(clip: _Clip) -> None:
"""The checks every clip has to pass, whatever produced it."""
wav = clip.wav
assert wav.dim() == 1, f"expected mono 1-D, got shape {tuple(wav.shape)}"
assert wav.numel() > 0, "empty waveform"
assert torch.isfinite(wav).all(), "non-finite samples"
# Frame conservation against the engine's own token count, not just
# divisibility. This is what catches a payload carrying non-audio data or a
# dropped tail; both pass every other check here.
assert wav.numel() == clip.committed_frames * SAMPLES_PER_FRAME, (
f"{wav.numel()} samples for {clip.committed_frames} committed frames — "
f"expected exactly {clip.committed_frames * SAMPLES_PER_FRAME}"
)
# Anything far outside the waveform range is not codec output.
assert float(wav.abs().max()) <= 1.5, f"peak {float(wav.abs().max()):.2f} is out of range"
def _assert_stopped_on_its_own(clip: _Clip) -> None:
"""The request must stop on its own rather than run to ``max_tokens``.
Separate from the structural checks because it reads the stop head's
judgement, not the payload's shape: under ``load_format: dummy`` that head
is random and never fires, so every clip runs to the token cap.
"""
seconds = clip.wav.numel() / SAMPLE_RATE
assert 0.5 < seconds < 30.0, f"implausible duration {seconds:.2f}s — did STOP fire?"
def _transcribe(wav: torch.Tensor, tmp_dir: str, name: str, model_size: str = _ASR_MODEL) -> str:
path = str(Path(tmp_dir) / f"{name}.wav")
sf.write(path, wav.numpy(), SAMPLE_RATE)
return convert_audio_file_to_text(path, model_size=model_size, language="en").lower()
def _transcribe_for(wav: torch.Tensor, tmp_dir: str, name: str, accepts) -> str:
"""Transcribe with the fast model, retrying once with the stronger one.
``accepts`` decides whether the fast transcript is usable. Escalating only
on a miss keeps the average clip at ``_ASR_MODEL`` while a genuine model
artifact still fails the caller's assertion, since the stronger ASR
mistranscribes it too.
"""
transcript = _transcribe(wav, tmp_dir, name)
if accepts(transcript):
return transcript
return _transcribe(wav, tmp_dir, name, model_size=_ASR_ESCALATION_MODEL)
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize(
("text", "keyword"),
[
# The canonical path: one request, one transcript, the cheapest check
# that reads the audio as speech.
pytest.param(
"Hello, this is Gepard speaking.",
"hello",
marks=pytest.mark.advanced_model,
id="canonical",
),
# No keyword: this row carries the structural and stopping checks over a
# second text and leaves the transcript to the row above.
pytest.param(
"The quick brown fox jumps over the lazy dog.",
None,
marks=pytest.mark.full_model,
id="second_text",
),
],
)
def test_gepard_offline_zero_shot(omni_runner, run_level: str, text: str, keyword: str | None) -> None:
"""Zero-shot synthesis produces a finite mono waveform at 22.05 kHz.
The transcript check is the one assertion here that reads the audio as
speech rather than as a tensor. It stands in for a parity comparison, which
needs a reference implementation this PR cannot run — noise, a repeated
loop or the wrong clip all pass every numeric check above it.
"""
clip = _synthesize(omni_runner, text)
_assert_clip_is_sane(clip)
if run_level not in {"advanced_model", "full_model"}:
return # dummy weights: the audio is structural only, so do not read it
_assert_stopped_on_its_own(clip)
if keyword is None:
return
with tempfile.TemporaryDirectory() as tmp_dir:
transcript = _transcribe_for(clip.wav, tmp_dir, "zero_shot", lambda t: keyword in t)
assert keyword in transcript, f"expected {keyword!r} in transcript, got {transcript!r}"
@pytest.mark.full_model
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_gepard_offline_long_text_skips_repetition(omni_runner, run_level: str) -> None:
"""The other branch of the prompt layout, end to end.
A text at or above ``apply_below`` tokens is voiced once; shorter ones are
repeated to carry enough mass against the speaker prefix. The layout
assertion keeps this row honest — if a tokenizer change pushed this text
back under the threshold it would silently retest the repeated path.
"""
prompt_ids = _build_request(_LONG_TEXT)["prompt_token_ids"]
assert prompt_ids.count(_config().start_of_text) == 1, "_LONG_TEXT is no longer above apply_below"
clip = _synthesize(omni_runner, _LONG_TEXT)
_assert_clip_is_sane(clip)
if run_level not in {"advanced_model", "full_model"}:
return
_assert_stopped_on_its_own(clip)
with tempfile.TemporaryDirectory() as tmp_dir:
transcript = _transcribe_for(clip.wav, tmp_dir, "long_text", lambda t: "river" in t)
assert "river" in transcript, f"expected 'river' in transcript, got {transcript!r}"
@pytest.mark.slow
@hardware_test(res={"cuda": "L4"}, num_cards=1)
def test_gepard_offline_concurrent_requests_stay_isolated(omni_runner, run_level: str) -> None:
"""Four requests in one batch, each holding its own generation state.
The prompts are deliberately unlike each other: cross-talk between the
per-request frame history, the codec window or the output routing shows up
as a transcript matching the wrong prompt, which similar prompts would
hide. ``max_num_seqs`` is 4, so these really do run together.
"""
texts = list(_DISTINGUISHABLE_PROMPTS)
clips = _synthesize_all(omni_runner, texts)
for clip in clips:
_assert_clip_is_sane(clip)
if run_level not in {"advanced_model", "full_model"}:
return # dummy weights: the audio is structural only, so do not read it
for clip in clips:
_assert_stopped_on_its_own(clip)
# All four clips, not just the transcribed ones: delivering one request's
# audio for another leaves two byte-identical waveforms.
for i in range(len(clips)):
for j in range(i + 1, len(clips)):
assert not torch.equal(clips[i].wav, clips[j].wav), (
f"clips {i} and {j} are the same waveform — one request's audio was delivered for another"
)
# Each clip must say what its own prompt asked for: accepting any of the
# four would pass a stable permutation, which is what a mix-up produces.
keywords = set(_DISTINGUISHABLE_PROMPTS.values())
with tempfile.TemporaryDirectory() as tmp_dir:
for i, clip in enumerate(clips[:_TRANSCRIBED_CLIPS]):
expected = _DISTINGUISHABLE_PROMPTS[texts[i]]
# Escalate on the expected word being absent, not on "no keyword
# at all" -- a neighbour's word is the failure under test.
transcript = _transcribe_for(clip.wav, tmp_dir, f"concurrent_{i}", lambda t: expected in t)
present = sorted(k for k in keywords if k in transcript)
assert present == [expected], (
f"clip for {texts[i]!r} transcribed as {transcript!r}: expected {expected!r} and "
f"no other prompt's keyword, found {present} — requests bled into each other"
)
@@ -0,0 +1,363 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""CPU coverage for Gepard's prompt layout, streaming-decode window and config.
The e2e test needs a GPU, NeMo and the checkpoint, so it only runs nightly.
These pin the parts that are pure logic and break silently: which frames each
decode covers, which samples survive the lookback trim, the prompt layout, and
that the packaged deploy config sits where the example looks for it.
The codec is replaced by a shift-invariant stand-in whose output depends only
on the codes, which makes "chunked emissions concatenate to one whole-history
decode" an exact assertion — the contract lookback exists to provide.
"""
from __future__ import annotations
from pathlib import Path
import pytest
import torch
import torch.nn as nn
import vllm_omni
from vllm_omni.config.stage_config import load_deploy_config
from vllm_omni.model_executor.models.gepard.configuration_gepard import GepardConfig
from vllm_omni.model_executor.models.gepard.gepard_talker import (
CHUNK_FRAMES,
FIRST_CHUNK_FRAMES,
GepardTalkerForConditionalGeneration,
_GepardState,
)
from vllm_omni.model_executor.models.gepard.nanocodec import (
LOOKBACK_FRAMES,
NanoCodec,
apply_end_of_speech_tail,
)
from vllm_omni.model_executor.models.gepard.prompt import build_gepard_prompt_ids
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
SAMPLES_PER_FRAME = 1024
NUM_HEADS = 32
SAMPLE_RATE = 22050
# Text-token count at or above which the layout is left unrepeated, read from
# the checkpoint defaults so the boundary cases follow the config.
APPLY_BELOW = GepardConfig().text_repetition_apply_below
class _ShiftInvariantCodec:
"""Frame tagged ``v`` -> samples ``v*SPF .. v*SPF+SPF-1``, wherever it sits."""
def __init__(self) -> None:
self.windows: list[int] = [] # frame count of every decode call
def decode_from_codes(self, codes: torch.Tensor, codes_len: torch.Tensor):
assert codes.shape[0] == 1, "streaming decode is per-request"
assert codes.shape[1] == NUM_HEADS, f"expected {NUM_HEADS} code channels"
tags = codes[0, 0, :].to(torch.int64) # (T,)
self.windows.append(int(tags.shape[0]))
offsets = torch.arange(SAMPLES_PER_FRAME, dtype=torch.int64)
audio = (tags.unsqueeze(1) * SAMPLES_PER_FRAME + offsets).reshape(1, -1)
return audio.to(torch.float32), codes_len * SAMPLES_PER_FRAME
def _make_codec() -> tuple[NanoCodec, _ShiftInvariantCodec]:
codec = NanoCodec(codec_id="test/fake-codec", sample_rate=SAMPLE_RATE)
fake = _ShiftInvariantCodec()
codec._codec = fake # bypasses load(); NeMo is never imported
assert codec.is_loaded
return codec, fake
def _make_talker(codec: NanoCodec) -> GepardTalkerForConditionalGeneration:
"""A real talker with only the audio-path attributes populated.
``__init__`` needs a VllmConfig and builds the backbone; the methods under
test touch only these four attributes.
"""
talker = GepardTalkerForConditionalGeneration.__new__(GepardTalkerForConditionalGeneration)
nn.Module.__init__(talker) # sets up _parameters/_buffers/_modules only
talker._codec = codec
talker._audio_queue = []
talker._active_states = {}
talker._deferred_cleanup_ids = set()
return talker
def _frame(tag: int) -> torch.Tensor:
"""One committed frame: 32 per-dimension codes, all carrying the tag."""
return torch.full((NUM_HEADS,), tag, dtype=torch.long)
def _expected_samples(num_frames: int) -> torch.Tensor:
offsets = torch.arange(SAMPLES_PER_FRAME, dtype=torch.int64)
tags = torch.arange(num_frames, dtype=torch.int64).unsqueeze(1)
return (tags * SAMPLES_PER_FRAME + offsets).reshape(-1).to(torch.float32)
def _drain(talker) -> torch.Tensor:
"""Concatenate and clear the queued deltas, as make_omni_output does."""
if not talker._audio_queue:
return torch.empty(0)
out = torch.cat([a.reshape(-1) for _, a in talker._audio_queue])
talker._audio_queue.clear()
return out
def _generate(talker, state, num_frames: int, *, stop_at_end: bool) -> None:
"""Drive the commit loop the way forward() does, one frame per step."""
for tag in range(num_frames):
state.frames.append(_frame(tag))
state.frame_count += 1
state.past_first_step = True
talker._emit_audio(state, is_final=stop_at_end and tag == num_frames - 1)
# Counts straddling both cadence thresholds, where an off-by-one hides.
@pytest.mark.parametrize(
"num_frames",
[
1,
2,
FIRST_CHUNK_FRAMES - 1,
FIRST_CHUNK_FRAMES,
FIRST_CHUNK_FRAMES + 1,
CHUNK_FRAMES - 1,
CHUNK_FRAMES,
CHUNK_FRAMES + 1,
FIRST_CHUNK_FRAMES + CHUNK_FRAMES,
79,
200,
],
)
def test_chunked_emissions_equal_a_whole_history_decode(num_frames: int) -> None:
codec, _fake = _make_codec()
talker = _make_talker(codec)
state = _GepardState(request_id="req-0")
_generate(talker, state, num_frames, stop_at_end=True)
torch.testing.assert_close(_drain(talker), _expected_samples(num_frames))
assert state.emitted_frames == num_frames
def test_every_frame_is_emitted_exactly_once() -> None:
"""The trim must not drop or duplicate samples at a chunk boundary."""
codec, _fake = _make_codec()
talker = _make_talker(codec)
state = _GepardState(request_id="req-0")
num_frames = FIRST_CHUNK_FRAMES + 2 * CHUNK_FRAMES + 3
_generate(talker, state, num_frames, stop_at_end=True)
audio = _drain(talker)
assert audio.numel() == num_frames * SAMPLES_PER_FRAME
# Tags are globally unique, so a duplicated or dropped frame shows up here.
assert len(torch.unique(audio)) == audio.numel()
def test_decode_windows_carry_lookback_context() -> None:
"""Every decode after the first must re-decode LOOKBACK_FRAMES of context."""
codec, fake = _make_codec()
talker = _make_talker(codec)
state = _GepardState(request_id="req-0")
num_frames = FIRST_CHUNK_FRAMES + 2 * CHUNK_FRAMES
_generate(talker, state, num_frames, stop_at_end=True)
assert len(fake.windows) >= 3, "expected a first chunk plus steady-state chunks"
assert fake.windows[0] == FIRST_CHUNK_FRAMES, "the first chunk has no left context"
for window in fake.windows[1:]:
assert window == CHUNK_FRAMES + LOOKBACK_FRAMES
def test_codec_runs_once_per_chunk_not_once_per_frame() -> None:
codec, fake = _make_codec()
talker = _make_talker(codec)
state = _GepardState(request_id="req-0")
num_frames = FIRST_CHUNK_FRAMES + 3 * CHUNK_FRAMES
_generate(talker, state, num_frames, stop_at_end=True)
assert len(fake.windows) == 4
assert sum(fake.windows) < num_frames * 2 # far below one decode per frame
def test_final_emit_drains_a_partial_chunk() -> None:
"""A request can end mid-chunk (max_tokens, STOP); the frames committed
since the last cadence boundary must still reach the queue.
Arithmetic only: this drives ``_emit_audio`` directly, so it says nothing
about *whether* the engine reaches it on the request's last step. That
wiring lives in ``test_gepard_wiring.py`` — a bug there leaves this green.
"""
codec, _fake = _make_codec()
talker = _make_talker(codec)
state = _GepardState(request_id="req-0")
num_frames = FIRST_CHUNK_FRAMES + CHUNK_FRAMES + 5
_generate(talker, state, num_frames, stop_at_end=False)
assert state.emitted_frames < num_frames, "a partial chunk should still be pending"
talker._emit_audio(state, is_final=True)
torch.testing.assert_close(_drain(talker), _expected_samples(num_frames))
assert state.emitted_frames == num_frames
def test_emitting_with_nothing_pending_is_a_no_op() -> None:
codec, fake = _make_codec()
talker = _make_talker(codec)
state = _GepardState(request_id="req-0")
_generate(talker, state, 4, stop_at_end=True)
calls_after_flush = len(fake.windows)
talker._audio_queue.clear()
talker._emit_audio(state, is_final=True)
assert len(fake.windows) == calls_after_flush, "codec ran with no new frames"
assert talker._audio_queue == []
def test_concurrent_requests_keep_separate_windows() -> None:
codec, _fake = _make_codec()
talker = _make_talker(codec)
states = [_GepardState(request_id=f"req-{i}") for i in range(2)]
for state in states:
_generate(talker, state, FIRST_CHUNK_FRAMES, stop_at_end=True)
by_req: dict[str, list[torch.Tensor]] = {}
for req_id, audio in talker._audio_queue:
by_req.setdefault(req_id, []).append(audio.reshape(-1))
assert set(by_req) == {"req-0", "req-1"}
for chunks in by_req.values():
torch.testing.assert_close(torch.cat(chunks), _expected_samples(FIRST_CHUNK_FRAMES))
def test_end_of_speech_tail_fades_and_pads() -> None:
audio = torch.ones(SAMPLE_RATE) # 1 s of full-scale signal
out = apply_end_of_speech_tail(audio, SAMPLE_RATE, fade_ms=10.0, silence_ms=20.0)
fade = int(SAMPLE_RATE * 10.0 / 1000.0)
pad = int(SAMPLE_RATE * 20.0 / 1000.0)
assert out.numel() == SAMPLE_RATE + pad
assert torch.all(out[-pad:] == 0.0), "trailing pad must be silent"
assert out[SAMPLE_RATE - fade - 1] == 1.0, "the fade must not reach back past fade_ms"
# Monotonically ramped to zero — the pad alone would leave the click.
ramp = out[SAMPLE_RATE - fade : SAMPLE_RATE]
assert torch.all(ramp[:-1] >= ramp[1:])
assert ramp[-1] == 0.0
def test_end_of_speech_tail_is_a_no_op_by_default() -> None:
audio = torch.ones(128)
out = apply_end_of_speech_tail(audio, SAMPLE_RATE, fade_ms=0.0, silence_ms=0.0)
assert out is audio
def _repeat_count(ids: list[int], cfg: GepardConfig) -> int:
"""How many ``[SOT ... EOT]`` copies the layout carries."""
return ids.count(cfg.start_of_text)
def test_gepard_prompt_layout() -> None:
"""The prompt carries the speaker slots and exactly one SOS, at the end."""
cfg = GepardConfig()
ids = build_gepard_prompt_ids([1, 2, 3], config=cfg)
slots = [cfg.speaker_token_base + i for i in range(cfg.num_speaker_prefix)]
assert ids[: cfg.num_speaker_prefix] == slots
assert ids.count(cfg.start_of_speech) == 1, "only the canonical copy may carry SOS"
assert ids[-1] == cfg.start_of_speech, "the first frame is sampled from the SOS position"
def test_short_text_repeats_to_reach_the_target_token_mass() -> None:
"""A short text is repeated until its region holds ~target_text_tokens."""
cfg = GepardConfig()
text = [1, 2, 3, 4] # target 16 / 4 -> 4 copies
ids = build_gepard_prompt_ids(text, config=cfg)
assert _repeat_count(ids, cfg) == 4
assert ids.count(cfg.start_of_speech) == 1, "only the last copy opens the speech region"
assert ids[-1] == cfg.start_of_speech
@pytest.mark.parametrize(
("num_text_tokens", "repeated"),
[
(APPLY_BELOW - 1, True),
(APPLY_BELOW, False),
(APPLY_BELOW + 1, False),
],
)
def test_apply_below_is_the_repetition_boundary(num_text_tokens: int, repeated: bool) -> None:
"""Both sides of the threshold: at apply_below the text is left alone.
The layout has to match training here or WER collapses, and the boundary
is where an off-by-one would be invisible in a single happy-path clip.
"""
cfg = GepardConfig()
ids = build_gepard_prompt_ids(list(range(1, num_text_tokens + 1)), config=cfg)
assert (_repeat_count(ids, cfg) > 1) is repeated
def test_max_repeats_caps_a_one_token_text() -> None:
"""target/1 would ask for 16 copies; the cap keeps the prefill bounded."""
cfg = GepardConfig()
ids = build_gepard_prompt_ids([1], config=cfg)
assert _repeat_count(ids, cfg) == cfg.text_repetition_max_repeats
def test_repetition_thresholds_are_read_from_the_config() -> None:
"""The numbers come from the checkpoint sidecar, not from literals.
A checkpoint whose text_repetition block differs must change the layout;
duplicating the values in the builder would silently ignore it.
"""
cfg = GepardConfig(text_repetition={"enabled": True, "target_text_tokens": 9, "apply_below": 8, "max_repeats": 2})
assert _repeat_count(build_gepard_prompt_ids([1, 2, 3], config=cfg), cfg) == 2 # ceil(9/3)=3, capped at 2
assert _repeat_count(build_gepard_prompt_ids(list(range(8)), config=cfg), cfg) == 1 # at apply_below
def test_repetition_can_be_disabled() -> None:
cfg = GepardConfig(text_repetition={"enabled": False})
ids = build_gepard_prompt_ids([1], config=cfg)
assert _repeat_count(ids, cfg) == 1
assert ids[-1] == cfg.start_of_speech
def test_empty_text_is_rejected() -> None:
"""An empty text would still assemble a valid layout and be voiced.
``[slots, SOT, EOT, SOS]`` parses, prefills and generates: the failure is
audible, not raised, which is the worst shape for a caller to debug.
"""
with pytest.raises(ValueError, match="at least one text token"):
build_gepard_prompt_ids([], config=GepardConfig())
def test_packaged_deploy_config_is_where_the_example_looks_for_it() -> None:
"""The example resolves its default off ``vllm_omni.__file__``; tests reach
the same YAML through ``tests.helpers.stage_config``, so nothing covered the
packaged location until this.
``pipeline: gepard`` is the load-bearing key: the checkpoint self-identifies
as qwen3_5_text, so without the pin the architectures fallback routes it to
the diffusion registry.
This asserts the location, not the example's constant — importing the
example would pull ``Omni`` and NeMo into a CPU test. Running the quick
start verbatim stays a release check.
"""
path = Path(vllm_omni.__file__).resolve().parent / "deploy" / "gepard.yaml"
assert path.is_file(), f"packaged deploy config missing at {path}"
deploy = load_deploy_config(path)
assert deploy.pipeline == "gepard"
assert deploy.stages, "deploy config declares no stages"
@@ -0,0 +1,731 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""CPU coverage for the seam between Gepard's talker and the AR runner.
``test_gepard_window.py`` pins the decode arithmetic *below* this layer, and
the e2e test exercises the layer *above* it but only along the path where the
stop head fires. Neither covers the wiring itself: whether anything calls the
final emit on a request's last step, and whether the payload it produces
survives the runner's sparse-audio routing.
These drive the real ``preprocess`` -> ``forward`` -> ``make_omni_output``
sequence through a stand-in for ``GPUARModelRunner.execute_model``, and use the
runner's own ``_resolve_sparse_mm_routing`` to decide what actually reaches the
caller. Only the LM backbone and the frame sampler are stubbed out; every
method under test is the real one, so a test here goes red when the engine
path breaks — which a test that calls the flush by hand cannot do.
"""
from __future__ import annotations
import logging
import pytest
import torch
import torch.nn as nn
from vllm_omni.model_executor.models.gepard.configuration_gepard import GepardConfig
from vllm_omni.model_executor.models.gepard.gepard_talker import (
CHUNK_FRAMES,
FIRST_CHUNK_FRAMES,
GepardTalkerForConditionalGeneration,
_ReqInfo,
)
from vllm_omni.model_executor.models.gepard.nanocodec import NanoCodec
from vllm_omni.model_executor.models.gepard.prompt import build_gepard_prompt_ids
from vllm_omni.worker.gpu_ar_model_runner import GPUARModelRunner
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
SAMPLES_PER_FRAME = 1024
NUM_HEADS = 32
SAMPLE_RATE = 22050
LOGGER_NAME = "vllm_omni.model_executor.models.gepard.gepard_talker"
# Each request tags its frames from a distinct base, so a sample that reaches
# the wrong request is visible in the value, not just the length.
_TAG_STRIDE = 100_000
class _TagCodec:
"""Frame carrying tag ``v`` -> samples ``v*SPF .. v*SPF+SPF-1``, wherever it sits.
Shift-invariant, so "what the caller received" can be compared against one
whole-history decode exactly. The tag rides channel 1: channel 0 is head0,
which the talker keeps inside its real (small) vocab.
"""
def __init__(self) -> None:
self.decode_calls = 0
def decode_from_codes(self, codes: torch.Tensor, codes_len: torch.Tensor):
assert codes.shape[0] == 1, "streaming decode is per-request"
assert codes.shape[1] == NUM_HEADS, f"expected {NUM_HEADS} code channels"
self.decode_calls += 1
tags = codes[0, 1, :].to(torch.int64) # (T,)
offsets = torch.arange(SAMPLES_PER_FRAME, dtype=torch.int64)
audio = (tags.unsqueeze(1) * SAMPLES_PER_FRAME + offsets).reshape(1, -1)
return audio.to(torch.float32), codes_len * SAMPLES_PER_FRAME
class _StubBackbone(nn.Module):
"""Stands in for the Qwen3.5 backbone; only shapes matter to this seam."""
def __init__(self, hidden: int) -> None:
super().__init__()
self.hidden = hidden
def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
return torch.zeros(int(input_ids.shape[0]), self.hidden)
def forward(
self,
input_ids: torch.Tensor | None = None,
positions: torch.Tensor | None = None,
intermediate_tensors=None,
inputs_embeds: torch.Tensor | None = None,
) -> torch.Tensor:
src = inputs_embeds if inputs_embeds is not None else input_ids
return torch.zeros(int(src.shape[0]), self.hidden)
def _expected_samples(tag_base: int, num_frames: int) -> torch.Tensor:
offsets = torch.arange(SAMPLES_PER_FRAME, dtype=torch.int64)
tags = (torch.arange(num_frames, dtype=torch.int64) + tag_base).unsqueeze(1)
return (tags * SAMPLES_PER_FRAME + offsets).reshape(-1).to(torch.float32)
class _MiniEngine:
"""Drives the talker the way ``GPUARModelRunner.execute_model`` does.
Reproduces the three orderings the dropped-tail bug lived in:
* ``on_requests_finished`` fires at the START of the step AFTER the one
that finished the request (it reads ``scheduler_output.finished_req_ids``);
* a finished request is not scheduled again, so it is absent from
``req_ids_output_copy`` and from that step's routing;
* with nothing left to schedule there is no step at all, so no further
``forward`` runs;
* a stop-token finish gets one more scheduled step, a budget finish does
not -- under async scheduling only the latter is guarded against it.
"""
def __init__(self, talker: GepardTalkerForConditionalGeneration, *, max_model_len: int) -> None:
self.talker = talker
self.max_model_len = max_model_len
self.reqs: dict[str, dict] = {}
self.order: list[str] = []
self._finished_last_step: list[str] = []
self.delivered: dict[str, list[torch.Tensor]] = {}
self.committed_frames: dict[str, int] = {}
self.steps = 0
self._tags_handed_out = 0
def add_request(
self,
req_id: str,
*,
max_tokens: int,
stop_after: int | None = None,
text_len: int = 3,
seed: int | None = None,
) -> None:
"""Submit a request. An id already seen is a resubmit of that id.
Tags come from a counter, so a resubmitted id gets frames the previous
request could not have produced.
"""
prompt_ids = build_gepard_prompt_ids(list(range(10, 10 + text_len)), config=self.talker.config)
self._tags_handed_out += 1
self.reqs[req_id] = {
"prompt_ids": torch.tensor(prompt_ids, dtype=torch.long),
"prompt_len": len(prompt_ids),
"max_tokens": max_tokens,
"stop_after": stop_after,
"stop_fired": False,
"seed": seed,
"computed": 0,
"generated": 0,
"finished": False,
"ghost_step_pending": False,
"next_token": None,
"tag_base": _TAG_STRIDE * self._tags_handed_out,
}
if req_id not in self.order:
self.order.append(req_id)
self.delivered[req_id] = []
self.committed_frames[req_id] = 0
def abort(self, req_id: str) -> None:
"""Kill a request the way an aborted client does: no STOP, no budget."""
self.reqs[req_id]["finished"] = True
self._finished_last_step.append(req_id)
def _live(self) -> list[str]:
return [r for r in self.order if not self.reqs[r]["finished"]]
def step(self) -> bool:
"""Run one engine step. False when there was nothing to schedule."""
live = self._live()
if not live:
return False
talker = self.talker
self.steps += 1
# --- runner: notify the model of requests finished since last step ---
if self._finished_last_step:
talker.on_requests_finished(set(self._finished_last_step))
self._finished_last_step = []
# --- runner: per-request preprocess, in input_batch.req_ids order ---
embeds_parts, ids_parts = [], []
for req_id in live:
r = self.reqs[req_id]
is_prefill = r["computed"] < r["prompt_len"]
if is_prefill:
ids = r["prompt_ids"]
else:
ids = torch.tensor([r["next_token"] or 0], dtype=torch.long)
span = int(ids.shape[0])
out_ids, out_embeds, _ = talker.preprocess(
input_ids=ids,
input_embeds=None,
request_id=req_id,
_omni_prompt_len=r["prompt_len"],
_omni_num_computed_tokens=r["computed"],
_omni_is_prefill=is_prefill,
_omni_max_tokens=r["max_tokens"],
_omni_seed=r["seed"],
)
ids_parts.append(out_ids)
embeds_parts.append(out_embeds)
r["span"] = span
inputs_embeds = torch.cat(embeds_parts, dim=0)
input_ids = torch.cat(ids_parts, dim=0)
positions = torch.arange(int(input_ids.shape[0]), dtype=torch.long)
# --- runner: forward, then build the multimodal payload ---
hidden = talker.forward(input_ids, positions, inputs_embeds=inputs_embeds)
results = list(talker._results_queue)
omni_out = talker.make_omni_output(hidden)
mm = omni_out.multimodal_outputs
# compute_logits drains this; it does not touch the audio path.
talker._results_queue.clear()
for req_id in live:
state = talker._active_states.get(req_id)
if state is not None:
self.committed_frames[req_id] = state.frame_count
# --- runner: sparse-audio routing decides what reaches the caller ---
downstream, sparse_index, is_sparse = GPUARModelRunner._resolve_sparse_mm_routing(
engine_output_type="audio",
req_ids_output_copy=list(live),
downstream_req_ids=list(live),
multimodal_outputs=mm,
)
assert is_sparse, "gepard always marks meta.sparse_audio"
for rid in downstream:
self.delivered[rid].append(mm["model_outputs"][sparse_index[rid]].reshape(-1))
# --- runner/scheduler bookkeeping: advance and detect finishes ---
stopped = {rid for rid, _head0, do_stop in results if do_stop}
for req_id in live:
r = self.reqs[req_id]
r["computed"] += r["span"]
r["generated"] = r["computed"] - r["prompt_len"] + 1
r["next_token"] = 0
length_cap = min(r["max_tokens"], self.max_model_len - r["prompt_len"])
if r["ghost_step_pending"]:
# This step WAS the extra one. The engine finished the request
# on the stop token last step and discards whatever came back
# here, whether or not the model chose to stop again.
r["finished"] = True
self._finished_last_step.append(req_id)
elif req_id in stopped:
# Ends on the stop token: one more step is already scheduled.
r["ghost_step_pending"] = True
elif r["generated"] >= length_cap:
r["finished"] = True
self._finished_last_step.append(req_id)
return True
def run(self, max_steps: int = 4000) -> None:
for _ in range(max_steps):
if not self.step():
return
raise AssertionError("engine did not drain")
def audio_for(self, req_id: str) -> torch.Tensor:
chunks = self.delivered[req_id]
return torch.cat(chunks) if chunks else torch.empty(0)
def _make_talker(*, max_model_len: int = 4096) -> tuple[GepardTalkerForConditionalGeneration, _TagCodec]:
"""A real talker with only the audio-path collaborators populated.
``__init__`` needs a VllmConfig and builds the Qwen3.5 backbone; the seam
under test needs none of that, so the backbone and the frame sampler are
stubbed and everything else is the class's own code.
"""
cfg = GepardConfig()
# Fixed, not cfg.get_text_config().hidden_size: nothing in this seam reads
# the real width, and a small one keeps the test off the backbone config.
hidden = 64
codec = NanoCodec(codec_id="test/fake-codec", sample_rate=SAMPLE_RATE)
fake = _TagCodec()
codec._codec = fake # bypasses load(); NeMo is never imported
assert codec.is_loaded
talker = GepardTalkerForConditionalGeneration.__new__(GepardTalkerForConditionalGeneration)
nn.Module.__init__(talker)
talker.config = cfg
talker.model = _StubBackbone(hidden)
# requires_grad=False: preprocess writes the prefix into inputs_embeds
# in place, and production runs that under inference_mode.
talker.null_prefix = nn.Parameter(torch.zeros(cfg.num_speaker_prefix, hidden), requires_grad=False)
talker._codec = codec
talker._sample_rate = SAMPLE_RATE
talker._max_model_len = max_model_len
talker._active_states = {}
talker._pending_requests = []
talker._results_queue = []
talker._audio_queue = []
talker._deferred_cleanup_ids = set()
return talker, fake
def _install_scripted_sampling(talker: GepardTalkerForConditionalGeneration, engine: _MiniEngine) -> None:
"""Replace the 32-head sampler with a scripted one.
Frames carry a per-request, per-step tag so the caller's audio is
self-describing. STOP fires once, on the request's configured frame: on the
extra step the engine then runs, re-firing is likely but not guaranteed,
and the model may not assume it.
"""
head0_vocab = talker.config.head0_vocab_size
# Signature mirrors the real ``_sample_frame`` minus ``self``; the scripted
# sampler ignores ``generators`` because seeding is decided in
# ``_build_with_real_sampler``, which keeps the model's own sampler.
def _sample_frame(rows: torch.Tensor, generators: list[torch.Generator | None] | None = None):
n = int(rows.shape[0])
head0 = torch.zeros(n, dtype=torch.long)
heads = torch.zeros(n, NUM_HEADS - 1, dtype=torch.long)
stop = torch.zeros(n, dtype=torch.bool)
for i, (req_id, _span, _samples_frame, *_rest) in enumerate(talker._pending_requests):
state = talker._active_states.get(req_id)
frame_index = state.frame_count if state is not None else 0
tag = engine.reqs[req_id]["tag_base"] + frame_index
head0[i] = tag % head0_vocab
heads[i] = tag
r = engine.reqs[req_id]
if r["stop_after"] is not None and frame_index >= r["stop_after"] and not r["stop_fired"]:
r["stop_fired"] = True
stop[i] = True
return head0, heads, stop
talker._sample_frame = _sample_frame
talker._audio_frame_embed = lambda h0, h: torch.zeros(int(h0.shape[0]), talker.model.hidden)
def _build(*, max_model_len: int = 4096) -> tuple[GepardTalkerForConditionalGeneration, _TagCodec, _MiniEngine]:
talker, codec = _make_talker(max_model_len=max_model_len)
engine = _MiniEngine(talker, max_model_len=max_model_len)
_install_scripted_sampling(talker, engine)
return talker, codec, engine
def _build_with_real_sampler(*, max_model_len: int = 4096) -> tuple[GepardTalkerForConditionalGeneration, _MiniEngine]:
"""Same seam, but ``_sample_frame`` is the model's own 32-head sampler.
The stub backbone hands every row the same hidden state, so the sampled
codes — and therefore the audio the codec tags them into — vary with
nothing but the Gumbel draws. That makes this the one place where "did the
caller's seed reach the audio" is a decidable question.
"""
talker, _codec = _make_talker(max_model_len=max_model_len)
engine = _MiniEngine(talker, max_model_len=max_model_len)
cfg = talker.config
talker.vocab_sizes = list(cfg.audio_head_levels)
talker.num_heads = cfg.num_audio_heads
talker.head0_vocab = cfg.head0_vocab_size
talker.stop_token = cfg.stop_token
talker.stop_threshold = cfg.stop_threshold
talker.temperature = cfg.temperature
talker._greedy = False
gather_idx, mask = GepardTalkerForConditionalGeneration._build_gather_mask(talker.vocab_sizes)
talker._cb_gather_idx = gather_idx
talker._cb_mask = mask
# Fixed weights: two engine runs must differ only by their RNG.
torch.manual_seed(0)
talker.fused_codebook_head = nn.Linear(talker.model.hidden, sum(talker.vocab_sizes))
talker.stop_head = nn.Linear(talker.model.hidden, 1)
# Never stop: these requests end on their token budget, so the frame count
# is fixed and only the sampled codes can differ.
torch.nn.init.constant_(talker.stop_head.bias, -20.0)
talker._audio_frame_embed = lambda h0, h: torch.zeros(int(h0.shape[0]), talker.model.hidden)
return talker, engine
# --------------------------------------------------------------------------
# The tail must reach the caller, whichever way the request ends.
# --------------------------------------------------------------------------
def test_last_in_flight_request_delivers_every_committed_frame() -> None:
"""The only request runs out of budget: there is no later step to flush in.
This is the case a deferred flush cannot serve at all — nothing runs after
the final forward.
"""
_talker, _codec, engine = _build()
engine.add_request("req-0", max_tokens=FIRST_CHUNK_FRAMES + CHUNK_FRAMES + 5)
engine.run()
frames = engine.committed_frames["req-0"]
assert frames > 0
torch.testing.assert_close(
engine.audio_for("req-0"),
_expected_samples(engine.reqs["req-0"]["tag_base"], frames),
)
@pytest.mark.parametrize(
"max_tokens",
[
1,
FIRST_CHUNK_FRAMES,
FIRST_CHUNK_FRAMES + 1,
30,
FIRST_CHUNK_FRAMES + CHUNK_FRAMES,
100,
150,
],
)
def test_budget_truncated_request_delivers_every_committed_frame(max_tokens: int) -> None:
"""Lowering max_tokens must shorten the clip, never silently cut it back
to the last cadence boundary."""
_talker, _codec, engine = _build()
engine.add_request("req-0", max_tokens=max_tokens)
engine.run()
frames = engine.committed_frames["req-0"]
assert frames == max_tokens, "every sampled step commits one frame when STOP never fires"
torch.testing.assert_close(
engine.audio_for("req-0"),
_expected_samples(engine.reqs["req-0"]["tag_base"], frames),
)
def test_stop_head_path_still_delivers_every_committed_frame() -> None:
"""Regression guard: the path that already worked must keep working."""
_talker, _codec, engine = _build()
engine.add_request("req-0", max_tokens=4000, stop_after=FIRST_CHUNK_FRAMES + 7)
engine.run()
frames = engine.committed_frames["req-0"]
assert frames == FIRST_CHUNK_FRAMES + 7, "the STOP frame itself is not committed"
torch.testing.assert_close(
engine.audio_for("req-0"),
_expected_samples(engine.reqs["req-0"]["tag_base"], frames),
)
def test_the_step_after_a_stop_token_commits_nothing(caplog: pytest.LogCaptureFixture) -> None:
"""Async scheduling runs one more step on a request that already stopped.
Committing its frame would strand it — nothing ships after the stop step —
and would make the truncation warning fire on a request that delivered
everything it produced, which is the one thing that warning must not do.
"""
talker, _codec, engine = _build()
stop_after = FIRST_CHUNK_FRAMES + 6
engine.add_request("req-0", max_tokens=4000, stop_after=stop_after)
target = logging.getLogger(LOGGER_NAME)
target.addHandler(caplog.handler)
prev = target.level
target.setLevel(logging.WARNING)
try:
engine.run()
# The cleanup that would warn runs on the step after the request
# leaves, and here there is none. Drive it the way the runner would.
talker.on_requests_finished({"req-0"})
talker._flush_deferred_cleanup()
finally:
target.removeHandler(caplog.handler)
target.setLevel(prev)
assert engine.reqs["req-0"]["ghost_step_pending"], "the harness did not run the extra step"
frames = engine.committed_frames["req-0"]
assert frames == stop_after, f"the extra step committed a frame: {frames} > {stop_after}"
torch.testing.assert_close(
engine.audio_for("req-0"),
_expected_samples(engine.reqs["req-0"]["tag_base"], frames),
)
assert not [m for m in caplog.messages if "undelivered frame" in m], (
f"a fully delivered request was reported as truncated; saw {caplog.messages}"
)
def test_a_resubmitted_request_id_keeps_its_new_state(caplog: pytest.LogCaptureFixture) -> None:
"""An aborted id can come back before its cleanup has run.
The runner treats the two as distinct requests, so the pending free must
not take the second one's state: without that, the state is popped at the
end of the very forward that created it and every later decode falls back
to the zero-embedding path — the request keeps producing frames, so only
the audio is wrong.
"""
_talker, _codec, engine = _build()
engine.add_request("req-0", max_tokens=4000)
for _ in range(FIRST_CHUNK_FRAMES + 2):
engine.step()
engine.abort("req-0")
# Same id, resubmitted before the model has been told about the abort.
engine.add_request("req-0", max_tokens=FIRST_CHUNK_FRAMES + CHUNK_FRAMES + 5)
target = logging.getLogger(LOGGER_NAME)
target.addHandler(caplog.handler)
prev = target.level
target.setLevel(logging.WARNING)
try:
engine.run()
finally:
target.removeHandler(caplog.handler)
target.setLevel(prev)
frames = engine.committed_frames["req-0"]
assert frames == engine.reqs["req-0"]["max_tokens"], "the second request lost frames"
torch.testing.assert_close(
engine.audio_for("req-0"),
_expected_samples(engine.reqs["req-0"]["tag_base"], frames),
msg="the second request did not receive its own audio",
)
# The abort still gets reported: the cleanup that would have said so is the
# one this cancelled.
assert any("resubmitted" in m for m in caplog.messages), (
f"the aborted request's truncation went unreported; saw {caplog.messages}"
)
def test_a_preempted_request_is_reported_rather_than_resumed(caplog: pytest.LogCaptureFixture) -> None:
"""Recompute preemption re-prefills a request that was mid-generation.
This model cannot survive that, and the point of the test is that it says
so. The recomputed ids are the prompt plus the head0 codes sampled so far,
which go through the text embedding table, and each frame's other 31 codes
are never in the token stream at all — there is nothing to stitch the two
halves back together with, so generation restarts from frame 0.
The harness re-prefills with the bare prompt, which understates the damage.
What is under test is the report and the reset, not the corruption, which
needs a real backbone to observe.
"""
_talker, _codec, engine = _build()
engine.add_request("req-0", max_tokens=4000)
for _ in range(FIRST_CHUNK_FRAMES + 2):
engine.step()
assert engine.committed_frames["req-0"] > 0
# Preemption the way the scheduler does it: computed tokens back to 0, so
# the next step re-prefills, and no id reaches on_requests_finished.
engine.reqs["req-0"]["computed"] = 0
target = logging.getLogger(LOGGER_NAME)
target.addHandler(caplog.handler)
prev = target.level
target.setLevel(logging.WARNING)
try:
engine.step()
finally:
target.removeHandler(caplog.handler)
target.setLevel(prev)
assert any("preempted" in m for m in caplog.messages), (
f"a preempted request restarted its audio silently; saw {caplog.messages}"
)
assert not any("resubmitted" in m for m in caplog.messages), (
"a preemption was reported as an id resubmit; the two need different words"
)
assert engine.committed_frames["req-0"] == 1, "the re-prefill must start the frame history over"
def test_concurrent_requests_each_receive_their_own_full_audio() -> None:
"""One request finishes while another stays live.
The earlier finisher is the case where a deferred flush *does* run but the
payload is filtered out again, because its id has left the output batch.
"""
_talker, _codec, engine = _build()
engine.add_request("req-short", max_tokens=FIRST_CHUNK_FRAMES + 3)
engine.add_request("req-long", max_tokens=FIRST_CHUNK_FRAMES + CHUNK_FRAMES + 11)
engine.run()
for req_id in ("req-short", "req-long"):
frames = engine.committed_frames[req_id]
assert frames == engine.reqs[req_id]["max_tokens"]
torch.testing.assert_close(
engine.audio_for(req_id),
_expected_samples(engine.reqs[req_id]["tag_base"], frames),
msg=f"{req_id} lost or gained samples",
)
def test_codec_still_runs_once_per_chunk_not_once_per_frame() -> None:
"""Emitting on the final step must not degrade into per-frame decoding."""
_talker, codec, engine = _build()
num_frames = FIRST_CHUNK_FRAMES + 3 * CHUNK_FRAMES
engine.add_request("req-0", max_tokens=num_frames)
engine.run()
assert codec.decode_calls == 4, f"expected one decode per chunk, got {codec.decode_calls}"
# --------------------------------------------------------------------------
# Deferred cleanup is cleanup: it must not try to produce output.
# --------------------------------------------------------------------------
def test_deferred_cleanup_never_queues_audio() -> None:
"""Anything queued from the cleanup hook is unroutable by construction.
By the time it runs the id has left ``req_ids_output_copy``, so a payload
under it is dropped; queueing there only risks leaking one request's audio
into the next step's payload for a different request.
"""
talker, _codec, engine = _build()
engine.add_request("req-0", max_tokens=FIRST_CHUNK_FRAMES + 4)
engine.add_request("req-1", max_tokens=FIRST_CHUNK_FRAMES + CHUNK_FRAMES)
while engine.step():
if engine.reqs["req-0"]["finished"] and not engine.reqs["req-1"]["finished"]:
break
talker._audio_queue.clear()
talker.on_requests_finished({"req-0"})
talker._flush_deferred_cleanup()
assert talker._audio_queue == [], "cleanup emitted audio that routing cannot deliver"
assert "req-0" not in talker._active_states, "cleanup must still free the state"
def test_cleanup_warns_when_frames_are_left_undelivered(caplog: pytest.LogCaptureFixture) -> None:
"""An abort is the one case with no last step to emit on. Say so."""
_talker, _codec, engine = _build()
engine.add_request("req-0", max_tokens=4000)
engine.add_request("req-1", max_tokens=4000)
for _ in range(FIRST_CHUNK_FRAMES + 3):
engine.step()
engine.abort("req-0")
target = logging.getLogger(LOGGER_NAME)
target.addHandler(caplog.handler)
prev = target.level
target.setLevel(logging.WARNING)
try:
engine.step() # cleanup for req-0 runs at the start of this step
finally:
target.removeHandler(caplog.handler)
target.setLevel(prev)
assert any("undelivered frame" in m for m in caplog.messages), (
f"aborted request dropped audio without a warning; saw {caplog.messages}"
)
# --------------------------------------------------------------------------
# A request's seed must reach the in-model sampler.
# --------------------------------------------------------------------------
_SEEDED_FRAMES = FIRST_CHUNK_FRAMES + 5
def _run_seeded(seed: int | None, *, neighbour: bool = False, neighbour_seed: int | None = None) -> torch.Tensor:
"""Audio delivered to ``req-a``, optionally sharing the batch with another."""
_talker, engine = _build_with_real_sampler()
engine.add_request("req-a", max_tokens=_SEEDED_FRAMES, seed=seed)
if neighbour:
engine.add_request("req-b", max_tokens=_SEEDED_FRAMES, seed=neighbour_seed)
engine.run()
return engine.audio_for("req-a")
def test_a_seeded_request_is_reproducible_within_one_process() -> None:
"""The bug this pins: the 32 heads are sampled in ``forward``, so a seed
that only reaches vLLM's sampler leaves the audio at the mercy of the
global RNG — two identical requests in one process then diverge."""
torch.testing.assert_close(_run_seeded(1234), _run_seeded(1234))
def test_different_seeds_produce_different_audio() -> None:
"""Guards the opposite failure: a seed that is threaded but never used."""
assert not torch.equal(_run_seeded(1234), _run_seeded(4321))
def test_a_seeded_request_is_unaffected_by_its_batch() -> None:
"""Each seeded request draws its own noise, so the noise it sees cannot
depend on who else was scheduled alongside it.
The stub backbone is what makes that checkable. A real one is a bf16
batched matmul whose rows are not reproducible across batch shapes, so
end-to-end batch invariance is not attainable there; this pins the half
the model actually controls.
"""
torch.testing.assert_close(_run_seeded(1234), _run_seeded(1234, neighbour=True, neighbour_seed=999))
def test_an_unseeded_neighbour_does_not_disturb_a_seeded_request() -> None:
"""The mixed case: one row draws from the global RNG, the other must not."""
torch.testing.assert_close(_run_seeded(1234), _run_seeded(1234, neighbour=True))
# --------------------------------------------------------------------------
# Budget arithmetic, straight through the helper.
# --------------------------------------------------------------------------
def _info(**kwargs) -> _ReqInfo:
base = {
"request_id": "req-0",
"is_prefill": False,
"prompt_len": 20,
"num_computed_tokens": 20,
"max_tokens": 10,
"seed": None,
}
base.update(kwargs)
return _ReqInfo(**base)
def test_prefill_step_is_the_first_output_token() -> None:
talker, _codec, _engine = _build()
first = _info(is_prefill=True, num_computed_tokens=0, max_tokens=1)
assert talker._is_last_output_token(first, span=20, samples_frame=True) is True
more = _info(is_prefill=True, num_computed_tokens=0, max_tokens=2)
assert talker._is_last_output_token(more, span=20, samples_frame=True) is False
def test_partial_prefill_chunk_does_not_consume_budget() -> None:
"""vLLM discards that row's token, so it must not count against max_tokens."""
talker, _codec, _engine = _build()
info = _info(is_prefill=True, num_computed_tokens=0, prompt_len=40, max_tokens=1)
assert talker._is_last_output_token(info, span=10, samples_frame=False) is False
def test_budget_is_capped_by_max_model_len() -> None:
"""A max_tokens larger than the context window never gets reached."""
talker, _codec, _engine = _build(max_model_len=30)
# prompt 20 + 10 generated fills the window, whatever max_tokens says.
at_cap = _info(num_computed_tokens=28, max_tokens=1000)
assert talker._is_last_output_token(at_cap, span=1, samples_frame=True) is True
below_cap = _info(num_computed_tokens=27, max_tokens=1000)
assert talker._is_last_output_token(below_cap, span=1, samples_frame=True) is False
def test_missing_budget_is_not_treated_as_exhausted() -> None:
"""A runner that does not thread max_tokens must not trigger a fade."""
talker, _codec, _engine = _build(max_model_len=0)
assert talker._is_last_output_token(_info(max_tokens=None), span=1, samples_frame=True) is False
+2
View File
@@ -51,6 +51,7 @@ from vllm_omni.model_executor.models.dots_tts.pipeline import DOTS_TTS_PIPELINE
from vllm_omni.model_executor.models.dreamzero.pipeline import DREAMZERO_PIPELINE
from vllm_omni.model_executor.models.dynin_omni.pipeline import DYNIN_OMNI_PIPELINE
from vllm_omni.model_executor.models.fish_speech.pipeline import FISH_SPEECH_PIPELINE
from vllm_omni.model_executor.models.gepard.pipeline import GEPARD_PIPELINE
from vllm_omni.model_executor.models.glm_image.pipeline import GLM_IMAGE_PIPELINE
from vllm_omni.model_executor.models.glm_tts.pipeline import GLM_TTS_PIPELINE
from vllm_omni.model_executor.models.gr00t.pipeline import GR00T_N1D7_PIPELINE
@@ -138,6 +139,7 @@ OMNI_PIPELINES: dict[str, PipelineConfig | PipelineResolverFunc] = {
"lance": LANCE_PIPELINE,
"dreamzero": DREAMZERO_PIPELINE,
"Gr00tN1d7": GR00T_N1D7_PIPELINE,
"gepard": GEPARD_PIPELINE,
"glm_image": GLM_IMAGE_PIPELINE,
"hunyuan_image_3_moe": HUNYUAN_IMAGE3_PIPELINE,
"hunyuan_image3_ar": HUNYUAN_IMAGE3_AR_PIPELINE,
+40
View File
@@ -0,0 +1,40 @@
# Gepard-1.0: single-stage native-AR FSQ/NanoCodec TTS (~0.5B).
# Target 1x L4 24GB. Zero-shot only; enforce_eager until the perf PR.
#
# The pipeline pin is required, not cosmetic: the checkpoint's config.json
# self-identifies as qwen3_5_text, so model_type inference cannot reach the
# gepard registry entry, and the architectures fallback would match
# Qwen3_5ForCausalLM against the diffusion pipeline.
pipeline: gepard
async_chunk: false
dtype: bfloat16
trust_remote_code: true
stages:
- stage_id: 0
max_num_seqs: 4
gpu_memory_utilization: 0.3 # AR LM + NanoCodec fit in ~2 GiB
enforce_eager: true
enable_prefix_caching: false
async_scheduling: true
max_num_batched_tokens: 4096
max_model_len: 4096
devices: "0"
# Override architectures so vLLM resolves our talker rather than the native
# Qwen3.5 text model. Do NOT override model_type: the backbone's own
# model_type check needs it to stay qwen3_5_text.
hf_overrides:
architectures: ["GepardTalkerForConditionalGeneration"]
default_sampling_params:
# All 32 heads are sampled in-model, so vLLM's sampler is pinned
# deterministic and just argmaxes what forward chose.
temperature: 0.0
top_p: 1.0
top_k: -1
# One output token is one frame, so this is the frame budget: 1000 frames
# is ~46 s at 21.5 fps. A backstop, not a target -- normal utterances are
# 40-420 frames -- but a load-bearing one, because this checkpoint's stop
# head does not fire on every batched trajectory.
max_tokens: 1000
seed: 42
detokenize: false
@@ -0,0 +1,8 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# NOTE: Do not import model classes in this file. Importing any
# submodule in this package triggers __init__.py execution, and
# both the model registry and pipeline registry import submodules
# directly — heavy imports here would be loaded as a side effect
# even though nothing depends on these re-exports.
@@ -0,0 +1,215 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Config for Gepard-1.0, a single-stage autoregressive TTS.
Text tokens -> one 32-code FSQ audio frame per step -> NeMo NanoCodec ->
waveform. The backbone is a vLLM-native ``Qwen3_5ForCausalLM``; the codebook
heads, binary stop head and voice-clone ref_compressor are Gepard additions.
Parses the model's ``gepard_config.json`` sidecar, which nests the LM
parameters under ``backbone_config`` and carries audio-head cardinalities,
special tokens, codec settings and the short-text repetition layout.
"""
from __future__ import annotations
from transformers import AutoConfig
from transformers.configuration_utils import PretrainedConfig
from transformers.utils import logging
logger = logging.get_logger(__name__)
_BACKBONE_MODEL_TYPE = "qwen3_5_text"
# Stripped from the backbone config: the model uses standard 1D RoPE, and these
# would flip vLLM's uses_mrope() to True.
_MROPE_KEYS = ("mrope_section", "mrope_interleaved")
class GepardConfig(PretrainedConfig):
"""Configuration for the Gepard-1.0 native-AR TTS model.
Args mirror ``gepard_config.json``. Defaults match the trained
``nineninesix/gepard-1.0`` checkpoint so an instance built with no
arguments (e.g. dummy/profiling loads) is still self-consistent.
"""
model_type = "gepard"
keys_to_ignore_at_inference = ["past_key_values"]
def __init__(
self,
backbone_config: dict | None = None,
audio_heads: dict | None = None,
audio_embed_dim: int = 32,
special_tokens: dict | None = None,
text_repetition: dict | None = None,
codec: dict | None = None,
voice_cloning: dict | None = None,
stop_threshold: float = 0.5,
stop_loss_weight: float = 2.0,
stop_pos_weight: float = 25.0,
temperature: float = 0.3,
**kwargs,
):
# Everything is assigned before super().__init__() at the bottom:
# transformers 5.x runs validators there that call get_text_config().
self.backbone_config = self._normalize_backbone(backbone_config)
self.audio_head_levels = self._parse_audio_heads(audio_heads)
self.num_audio_heads = len(self.audio_head_levels)
self.audio_embed_dim = audio_embed_dim
# head0 is the token vLLM samples; STOP is a synthetic sentinel one past
# its valid range.
self.head0_vocab_size = self.audio_head_levels[0] if self.audio_head_levels else 8
self.stop_token = self.head0_vocab_size
st = special_tokens or {}
self.start_of_text = st.get("start_of_text", 248073)
self.end_of_text = st.get("end_of_text", 248074)
self.start_of_speech = st.get("start_of_speech", 248070)
self.end_of_speech = st.get("end_of_speech", 248071)
self.tts_pad = st.get("tts_pad", 248076)
# The speaker placeholder slots begin at tokeniser_length.
self.tokeniser_length = st.get("tokeniser_length", 248077)
self.speaker_token_base = self.tokeniser_length
# Prompt layout, read by prompt.py. These must match the training
# layout, so they come from the checkpoint rather than a literal.
tr = text_repetition or {}
self.text_repetition_enabled = tr.get("enabled", True)
self.text_repetition_target_tokens = tr.get("target_text_tokens", 16)
self.text_repetition_apply_below = tr.get("apply_below", 13)
self.text_repetition_max_repeats = tr.get("max_repeats", 8)
# NeMo NanoCodec, which runs outside vLLM.
cc = codec or {}
self.codec_id = cc.get("codec_id", "nvidia/nemo-nano-codec-22khz-1.89kbps-21.5fps")
self.codec_sample_rate = cc.get("sample_rate", 22050)
self.codec_frame_rate_hz = cc.get("frame_rate_hz", 21.5)
# Per-group FSQ levels; num_codec_groups * len(fsq_levels) == 32.
self.fsq_levels = cc.get("fsq_levels", [8, 7, 6, 6])
self.num_codec_groups = cc.get("num_layers", 8)
self.codec_do_unfold = cc.get("do_unfold", True)
self.stop_threshold = stop_threshold
self.stop_loss_weight = stop_loss_weight
self.stop_pos_weight = stop_pos_weight
# head0 goes through vLLM's SamplingParams; the other 31 heads use this
# in-model. There is no top_p: Gumbel-max has no nucleus step.
self.temperature = temperature
# Carried for the cloning follow-up; zero-shot uses null_prefix only.
vc = voice_cloning or {}
self.voice_cloning_enabled = vc.get("enabled", True)
comp = vc.get("compressor", {}) or {}
self.num_speaker_prefix = comp.get("num_queries", 8)
self.ref_compressor_num_blocks = comp.get("num_layers", 2)
self.ref_compressor_num_heads = comp.get("num_heads", 8)
self.ref_compressor_d_model = comp.get("d_model", 1024)
self.ref_compressor_ffn_mult = comp.get("ffn_hidden_size_multiplier", 4)
# Last on purpose — triggers transformers-5.x validators that call
# get_text_config(); every attribute they touch must already exist.
super().__init__(**kwargs)
@classmethod
def from_checkpoint(
cls,
model: str,
backbone_config: dict | None = None,
revision: str | None = None,
) -> GepardConfig:
"""Build the full config for a checkpoint that self-identifies as the
bare backbone: audio fields from the sidecar, backbone fields from the
loaded config.
``revision`` must be the one the weights came from — a revision that
moves the audio-head cardinalities or the special tokens moves the
prompt layout and the STOP sentinel with them.
"""
sidecar: dict = {}
try:
from vllm.transformers_utils.config import get_hf_file_to_dict
sidecar = dict(get_hf_file_to_dict("gepard_config.json", model, revision=revision) or {})
except (OSError, ValueError) as e:
# A missing or malformed sidecar is expected; the defaults match the
# trained checkpoint. Anything else must surface.
logger.warning(
"GepardConfig: could not read gepard_config.json from %s (%s: %s); "
"audio fields fall back to trained-checkpoint defaults.",
model,
type(e).__name__,
e,
)
if backbone_config:
bb = dict(backbone_config)
# The loaded config carries the hf_overrides-patched identity; the
# backbone must stay qwen3_5_text or get_text_config() recurses.
bb["model_type"] = _BACKBONE_MODEL_TYPE
bb.pop("architectures", None)
sidecar["backbone_config"] = bb
return cls(**sidecar)
@staticmethod
def _parse_audio_heads(audio_heads: dict | None) -> list[int]:
"""``{level_audio_0: 8, level_audio_1: 7, ...}`` -> ``[8, 7, ...]``.
Sorted by the numeric suffix, so channel order does not depend on dict
insertion order.
"""
if not audio_heads:
return [8, 7, 6, 6] * 8
return [audio_heads[k] for k in sorted(audio_heads, key=lambda s: int(str(s).rsplit("_", 1)[-1]))]
@classmethod
def _normalize_backbone(cls, backbone_config: dict | None) -> dict:
"""Return the backbone dict with vestigial mRoPE fields removed.
They are template leftovers that the trained checkpoint's own
``config.json`` does not carry. Left in, they flip vLLM's
``uses_mrope()`` to True and wire ``positions`` wrongly.
"""
bb = dict(backbone_config or {})
rope = bb.get("rope_parameters")
if isinstance(rope, dict) and any(k in rope for k in _MROPE_KEYS):
rope = {k: v for k, v in rope.items() if k not in _MROPE_KEYS}
bb["rope_parameters"] = rope
logger.info(
"GepardConfig: stripped vestigial mRoPE keys from backbone rope_parameters "
"(model uses standard 1D default RoPE)."
)
return bb
def get_text_config(self, **kwargs) -> PretrainedConfig:
"""Return the Qwen3.5 backbone config, which vLLM runs the backbone on."""
cached = getattr(self, "_text_config", None)
if cached is not None:
return cached
bb = dict(self.backbone_config)
# pop, not setdefault: for_model()'s first positional is named
# model_type, so leaving the key in **bb passes it twice.
model_type = bb.pop("model_type", _BACKBONE_MODEL_TYPE)
try:
text_config = AutoConfig.for_model(model_type, **bb)
except (KeyError, ValueError) as e:
# Only an unknown model_type is a legitimate fallback. A TypeError
# means the kwargs are wrong and must not degrade silently.
text_config = PretrainedConfig(**bb)
logger.warning(
"GepardConfig: AutoConfig.for_model(%s) failed (%s: %s); using a plain "
"PretrainedConfig for the backbone. Verify vLLM still resolves "
"the Qwen3.5 model.",
model_type,
type(e).__name__,
e,
)
self._text_config = text_config
return text_config
AutoConfig.register("gepard", GepardConfig)
__all__ = ["GepardConfig"]
@@ -0,0 +1,526 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Gepard-1.0 native-AR talker.
Single-stage AR TTS on a vLLM-native Qwen3.5 backbone. Each step samples one
32-channel FSQ frame; head0 is the vLLM-facing token and the other 31 are
side-channel. Generation ends on a learned binary stop head, not an EOS token.
The NeMo NanoCodec decodes committed frames to a waveform outside vLLM.
Zero-shot only, and ``enforce_eager``; CUDA graph is a perf follow-up.
"""
from __future__ import annotations
import dataclasses
import os
from collections.abc import Iterable
from typing import Any
import torch
import torch.nn as nn
from vllm.config import VllmConfig
from vllm.logger import init_logger
from vllm.model_executor.models.qwen3_5 import Qwen3_5ForCausalLM
from vllm.model_executor.models.utils import AutoWeightsLoader, maybe_prefix
from vllm.sequence import IntermediateTensors
from vllm_omni.model_executor.models.gepard.configuration_gepard import GepardConfig
from vllm_omni.model_executor.models.gepard.nanocodec import NanoCodec
from vllm_omni.model_executor.models.output_templates import OmniOutput
from vllm_omni.platforms import current_omni_platform
logger = init_logger(__name__)
# Decode cadence: a short first chunk for early audio, then larger ones to
# amortize the codec call. Does not affect the samples produced.
FIRST_CHUNK_FRAMES = int(os.environ.get("VLLM_GEPARD_FIRST_CHUNK_FRAMES", "10"))
CHUNK_FRAMES = int(os.environ.get("VLLM_GEPARD_CHUNK_FRAMES", "86"))
@dataclasses.dataclass(frozen=True)
class _ReqInfo:
"""Scheduler state the runner threads into ``preprocess``.
The hook key strings are read only here, so a rename fails in one place.
"""
request_id: str
is_prefill: bool
prompt_len: int
num_computed_tokens: int
max_tokens: int | None
seed: int | None
@classmethod
def from_hook_kwargs(cls, kwargs: dict[str, Any], *, span: int) -> _ReqInfo:
# Defaults cover direct calls, which thread no scheduler state.
max_tokens = kwargs.get("_omni_max_tokens")
seed = kwargs.get("_omni_seed")
return cls(
request_id=str(kwargs.get("request_id", "default")),
is_prefill=bool(kwargs.get("_omni_is_prefill", span > 1)),
prompt_len=int(kwargs.get("_omni_prompt_len") or 0),
num_computed_tokens=int(kwargs.get("_omni_num_computed_tokens") or 0),
max_tokens=None if max_tokens is None else int(max_tokens),
seed=None if seed is None else int(seed),
)
@dataclasses.dataclass
class _GepardState:
"""Per-request generation state, keyed by request_id."""
request_id: str
# Embedding of the last frame's 32 codes; the next step's input.
curr_embed_for_next: torch.Tensor | None = None
# Frame history (T, 32) handed to the codec.
frames: list[torch.Tensor] = dataclasses.field(default_factory=list)
emitted_frames: int = 0
# None falls back to null_prefix; the cloning follow-up sets it.
speaker_prefix: torch.Tensor | None = None
# None means the request carried no seed and draws from the global RNG.
generator: torch.Generator | None = None
frame_count: int = 0
past_first_step: bool = False
is_stopping: bool = False
class GepardTalkerForConditionalGeneration(nn.Module):
"""Gepard native-AR TTS talker (Qwen3.5 backbone + 32 FSQ heads + stop)."""
have_multimodal_outputs = True
has_preprocess = True
has_postprocess = False
def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
super().__init__()
self.vllm_config = vllm_config
cfg = vllm_config.model_config.hf_config
if not isinstance(cfg, GepardConfig):
# hf_overrides patches the loaded config instance rather than
# re-classing it, so what arrives here is the backbone config.
cfg = GepardConfig.from_checkpoint(
vllm_config.model_config.model,
backbone_config=cfg.to_dict(),
revision=vllm_config.model_config.revision,
)
self.config = cfg
self._device = current_omni_platform.get_torch_device()
# Deterministic argmax sampling, for reproducible comparisons.
self._greedy = os.environ.get("VLLM_GEPARD_GREEDY", "0") == "1"
self.vocab_sizes: list[int] = list(cfg.audio_head_levels) # [8,7,6,6]*8
self.num_heads: int = cfg.num_audio_heads
self.head0_vocab: int = cfg.head0_vocab_size
self.stop_token: int = cfg.stop_token # sentinel, one past head0's range
self.total_vocab: int = sum(self.vocab_sizes)
self.stop_threshold: float = cfg.stop_threshold
self.temperature: float = cfg.temperature
hidden = self.config.get_text_config().hidden_size
self.fused_codebook_head = nn.Linear(hidden, self.total_vocab, bias=True)
self.stop_head = nn.Linear(hidden, 1, bias=True)
self.audio_embeddings = nn.ModuleList([nn.Embedding(v, cfg.audio_embed_dim) for v in self.vocab_sizes])
self.audio_embed_proj = nn.Sequential(
nn.Linear(self.num_heads * cfg.audio_embed_dim, hidden),
nn.GELU(),
nn.Linear(hidden, hidden),
nn.LayerNorm(hidden, elementwise_affine=False),
)
self.register_buffer("audio_embed_scale", torch.tensor(1.0))
# Learned default-voice fallback.
self.null_prefix = nn.Parameter(torch.zeros(cfg.num_speaker_prefix, hidden))
gather_idx, mask = self._build_gather_mask(self.vocab_sizes)
self.register_buffer("_cb_gather_idx", gather_idx, persistent=False)
self.register_buffer("_cb_mask", mask, persistent=False)
self.model = Qwen3_5ForCausalLM(vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model"))
# Cheap and NeMo-free to construct; load() does the heavy import.
self._codec = NanoCodec(codec_id=cfg.codec_id, sample_rate=cfg.codec_sample_rate)
self._max_model_len: int = int(vllm_config.model_config.max_model_len or 0)
self._active_states: dict[str, _GepardState] = {}
# These queues stay index-aligned across preprocess -> forward ->
# compute_logits: preprocess appends in input_batch.req_ids order.
# (req_id, span, samples_frame, is_last_token)
self._pending_requests: list[tuple[str, int, bool, bool]] = []
# (req_id, head0, do_stop); head0 < 0 marks a discarded placeholder row.
self._results_queue: list[tuple[str, int, bool]] = []
self._audio_queue: list[tuple[str, torch.Tensor | None]] = []
self._deferred_cleanup_ids: set[str] = set()
self._sample_rate: int = cfg.codec_sample_rate
@staticmethod
def _build_gather_mask(vocab_sizes: list[int]) -> tuple[torch.Tensor, torch.Tensor]:
"""Gather indices + -inf pad mask so one argmax samples all 31 heads."""
num_heads = len(vocab_sizes)
max_cb_vocab = max(vocab_sizes[1:]) # == 8 for Gepard; do NOT hardcode 7
gather_rows, mask_rows = [], []
cum = vocab_sizes[0]
for i in range(1, num_heads):
v = vocab_sizes[i]
gather_rows.append(list(range(cum, cum + v)) + [cum] * (max_cb_vocab - v))
mask_rows.append([0.0] * v + [float("-inf")] * (max_cb_vocab - v))
cum += v
return (
torch.tensor(gather_rows, dtype=torch.long),
torch.tensor(mask_rows, dtype=torch.float32),
)
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
"""Fuse codebook_heads.{0..31} into fused_codebook_head; delegate the rest."""
cb_w: dict[str, torch.Tensor] = {}
rest: list[tuple[str, torch.Tensor]] = []
for name, tensor in weights:
if name.startswith(("aligner.", "supcon_head.")):
continue # training-only
if name.startswith("codebook_heads."):
cb_w[name] = tensor
continue
if name.startswith(("model.", "lm_head.")):
# We nest the backbone one level deeper than the checkpoint
# keys assume, so prepend the extra hop.
name = "model." + name
rest.append((name, tensor))
w_parts, b_parts = [], []
for i in range(self.num_heads):
if (wk := f"codebook_heads.{i}.weight") in cb_w:
w_parts.append(cb_w[wk])
if (bk := f"codebook_heads.{i}.bias") in cb_w:
b_parts.append(cb_w[bk])
if w_parts:
rest.append(("fused_codebook_head.weight", torch.cat(w_parts, dim=0)))
if b_parts:
rest.append(("fused_codebook_head.bias", torch.cat(b_parts, dim=0)))
# NanoCodec loads lazily on first decode, so load_format=dummy and
# NeMo-less environments never pay for it.
loader = AutoWeightsLoader(self, skip_prefixes=["mtp.", "ref_compressor."])
return loader.load_weights(iter(rest))
def _get_or_create_state(self, request_id: str) -> _GepardState:
st = self._active_states.get(request_id)
if st is None:
st = _GepardState(request_id=request_id)
self._active_states[request_id] = st
return st
def on_requests_finished(self, finished_req_ids: set[str] | list[str]) -> None:
# Fires BEFORE forward -> defer the actual free until after forward runs,
# else we'd drop state the in-flight forward still reads.
self._deferred_cleanup_ids.update(str(r) for r in finished_req_ids)
def _flush_deferred_cleanup(self) -> None:
# Cleanup only. Audio queued here is never delivered — the id has left
# the output batch by now — so the tail is emitted in forward instead,
# and anything still pending here is genuinely lost. Say so.
for req_id in self._deferred_cleanup_ids:
state = self._active_states.pop(req_id, None)
if state is not None and state.frame_count > state.emitted_frames:
logger.warning(
"Gepard request %s ended with %d undelivered frame(s): it finished "
"without the stop head firing and without reaching its token budget "
"(aborted or preempted). Its audio is truncated.",
req_id,
state.frame_count - state.emitted_frames,
)
self._deferred_cleanup_ids.clear()
def _is_last_output_token(self, info: _ReqInfo, *, span: int, samples_frame: bool) -> bool:
"""Whether the token sampled this step is the request's budget-final one.
That step is the last one on which the request is still in the output
batch, so it is also the last chance to ship its audio. Counted off the
engine's cursor rather than a local tally, so a re-prefill after
preemption cannot desync it.
"""
if not samples_frame:
# A partial prefill chunk's token is discarded, so it costs no budget.
return False
budget = info.max_tokens
if self._max_model_len:
len_cap = self._max_model_len - info.prompt_len
budget = len_cap if budget is None else min(budget, len_cap)
if budget is None or budget <= 0:
return False
produced = info.num_computed_tokens + span - info.prompt_len + 1
return produced >= budget
def _emit_audio(self, state: _GepardState, *, is_final: bool) -> None:
"""Decode this request's un-emitted frames and queue the new samples.
Queues deltas: each call contributes only samples not queued before.
"""
pending = state.frame_count - state.emitted_frames
if pending <= 0:
return
if not is_final:
threshold = FIRST_CHUNK_FRAMES if state.emitted_frames == 0 else CHUNK_FRAMES
if pending < threshold:
return
frames = torch.stack(state.frames) # (T, 32)
if not self._codec.is_loaded:
# The first decode pays the codec load; warmup is a perf follow-up.
self._codec.load(frames.device)
audio = self._codec.decode_stream(frames, start_idx=state.emitted_frames, is_final=is_final)
if audio is None:
return
self._audio_queue.append((state.request_id, audio))
state.emitted_frames = state.frame_count
def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor:
return self.model.embed_input_ids(input_ids)
@staticmethod
def _gumbel_noise(ref: torch.Tensor, generators: list[torch.Generator | None] | None) -> torch.Tensor:
"""Gumbel noise shaped like ``ref``; row i uses ``generators[i]`` if set.
A seeded row draws its own noise of a fixed shape, so the noise it sees
does not depend on who shared its batch. Only the noise: the logits come
from a bf16 matmul that is not row-wise reproducible, so a batched
request can still sample differently from a solo one.
"""
if not generators or all(g is None for g in generators):
u = torch.rand_like(ref)
else:
u = torch.stack(
[
torch.rand(ref.shape[1:], generator=g, device=ref.device, dtype=ref.dtype)
if g is not None
else torch.rand(ref.shape[1:], device=ref.device, dtype=ref.dtype)
for g in generators
]
)
return -torch.log(-torch.log(u.clamp_min_(1e-20)))
def _sample_frame(
self,
hidden: torch.Tensor,
generators: list[torch.Generator | None] | None = None,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""(N, hidden) -> head0 (N,), heads_1_31 (N, 31), do_stop (N,).
``generators`` is row-aligned with ``hidden``. The 32 heads are sampled
here rather than by vLLM's sampler, so a request's seed only reaches
the audio through this.
"""
fused = self.fused_codebook_head(hidden)
gathered = fused[:, self._cb_gather_idx].float() + self._cb_mask # (N,31,8)
h0_logits = fused[:, : self.head0_vocab].float()
if self._greedy:
heads = gathered.argmax(dim=-1)
head0 = h0_logits.argmax(dim=-1)
else:
heads = (gathered / self.temperature + self._gumbel_noise(gathered, generators)).argmax(dim=-1)
head0 = (h0_logits / self.temperature + self._gumbel_noise(h0_logits, generators)).argmax(dim=-1)
p_stop = torch.sigmoid(self.stop_head(hidden)).squeeze(-1)
do_stop = p_stop > self.stop_threshold
return head0, heads, do_stop
def _audio_frame_embed(self, head0: torch.Tensor, heads_1_31: torch.Tensor) -> torch.Tensor:
"""Rebuild the next step's input embedding from the 32 sampled codes."""
codes = torch.cat([head0.clamp(max=self.head0_vocab - 1).unsqueeze(-1), heads_1_31], dim=-1)
parts = [self.audio_embeddings[i](codes[:, i]) for i in range(self.num_heads)]
return self.audio_embed_proj(torch.cat(parts, dim=-1)) * self.audio_embed_scale
def preprocess(self, input_ids: torch.Tensor, input_embeds: torch.Tensor | None = None, **req_infos: Any):
# Prefill injects the speaker prefix at the placeholder slots. The
# prompt ends on SOS, so forward samples the first frame there.
dev = input_ids.device
span = int(input_ids.shape[0])
info = _ReqInfo.from_hook_kwargs(req_infos, span=span)
req_id = info.request_id
is_prefill = info.is_prefill
prompt_len = info.prompt_len
computed = info.num_computed_tokens
# A prefill chunk that does not finish the prompt commits no frame:
# vLLM discards its sampled token.
samples_frame = (not is_prefill) or prompt_len <= 0 or computed + span >= prompt_len
if is_prefill:
state = self._get_or_create_state(req_id)
# An aborted id can be resubmitted, and the runner treats the two as
# distinct requests. Drop the pending free, or the cleanup at the
# end of this forward takes the new request's state with it.
resubmitted = req_id in self._deferred_cleanup_ids
self._deferred_cleanup_ids.discard(req_id)
if resubmitted:
# Stand in for the cleanup just cancelled.
undelivered = state.frame_count - state.emitted_frames
if undelivered > 0:
logger.warning(
"Gepard request %s ended with %d undelivered frame(s) and its id has been "
"resubmitted; the first request's audio is truncated.",
req_id,
undelivered,
)
elif state.frame_count:
# Not a resubmit, so this is a preemption recompute, and it
# cannot be recovered: a frame's 31 side-channel codes are not
# in the token stream, so the replayed prompt cannot rebuild the
# KV that produced the audio already delivered. Generation
# restarts from frame 0 and the caller gets two unrelated halves.
logger.warning(
"Gepard request %s was preempted after %d frame(s) and is being recomputed; "
"its audio will not continue from where it stopped. Raise the stage's "
"gpu_memory_utilization or lower max_num_seqs to keep requests from being "
"preempted.",
req_id,
state.frame_count,
)
state.curr_embed_for_next = None
state.frames.clear()
state.emitted_frames = 0
state.frame_count = 0
state.past_first_step = False
state.is_stopping = False
# SamplingParams.seed only reaches vLLM's sampler, so it never
# touches the audio unless it is threaded into a generator here.
state.generator = None
if info.seed is not None:
state.generator = torch.Generator(device=dev)
state.generator.manual_seed(info.seed)
embeds = self.model.embed_input_ids(input_ids)
prefix = state.speaker_prefix if state.speaker_prefix is not None else self.null_prefix
base = self.config.speaker_token_base
spk = (input_ids >= base) & (input_ids < base + self.config.num_speaker_prefix)
if bool(spk.any()):
embeds[spk] = prefix[input_ids[spk] - base].to(dtype=embeds.dtype)
else:
state = self._active_states.get(req_id)
curr = state.curr_embed_for_next if state is not None else None
if curr is not None:
embeds = curr.to(device=dev).reshape(1, -1)
else:
# Defensive only: a decode step always follows a forward that
# stashed curr_embed_for_next.
hidden = self.config.get_text_config().hidden_size
embeds = torch.zeros(1, hidden, device=dev, dtype=self.null_prefix.dtype)
is_last_token = self._is_last_output_token(info, span=span, samples_frame=samples_frame)
self._pending_requests.append((req_id, span, samples_frame, is_last_token))
return input_ids, embeds, {}
def forward(
self,
input_ids: torch.Tensor,
positions: torch.Tensor,
intermediate_tensors: IntermediateTensors | None = None,
inputs_embeds: torch.Tensor | None = None,
**kwargs: Any,
) -> torch.Tensor | OmniOutput:
# positions is plain 1D: Gepard uses default RoPE, not mRoPE. input_ids
# passes through only when no embeds were spliced (the profile run).
hidden = self.model(
input_ids=input_ids if inputs_embeds is None else None,
positions=positions,
intermediate_tensors=intermediate_tensors,
inputs_embeds=inputs_embeds,
)
# Empty _pending_requests means the engine-init profile run: skip.
if self._pending_requests:
# One sampling row per request: the last scheduled position of its
# span. Same row set compute_logits sees at logits_indices.
ends, generators, offset = [], [], 0
for req_id, span, _samples_frame, _is_last in self._pending_requests:
offset += span
ends.append(offset - 1)
state = self._active_states.get(req_id)
generators.append(state.generator if state is not None else None)
rows = hidden[torch.tensor(ends, device=hidden.device)]
head0, heads_1_31, want_stop = self._sample_frame(rows, generators)
frame_embeds = self._audio_frame_embed(head0, heads_1_31)
head0_l = head0.tolist()
stop_l = want_stop.tolist()
for i, (req_id, _span, samples_frame, is_last_token) in enumerate(self._pending_requests):
if not samples_frame:
# Partial prefill chunk: vLLM discards this row's token.
self._results_queue.append((req_id, -1, False))
continue
state = self._get_or_create_state(req_id)
if state.is_stopping:
# Async scheduling runs one more step after a stop token —
# only the max_tokens finish is guarded against it. This
# step's output is discarded, so a frame committed here
# would be stranded and reported as lost audio.
self._results_queue.append((req_id, int(head0_l[i]), True))
continue
# Stop may only fire past the first audio frame, so the
# prefill-sampled frame and the one after it always commit.
do_stop = bool(stop_l[i]) and state.past_first_step
if do_stop:
state.is_stopping = True
else:
state.frames.append(torch.cat([head0[i : i + 1], heads_1_31[i]]))
state.frame_count += 1
state.curr_embed_for_next = frame_embeds[i]
state.past_first_step = True
self._results_queue.append((req_id, int(head0_l[i]), do_stop))
# Both ways a request ends have to flush here: this is the last
# step on which it is still in the output batch, and a payload
# queued after it is dropped by routing.
self._emit_audio(state, is_final=do_stop or is_last_token)
self._pending_requests.clear()
self._flush_deferred_cleanup()
return hidden
def compute_logits(self, hidden_states: torch.Tensor | OmniOutput, sampling_metadata: Any = None):
# Vocab-wide logits carrying head0, or STOP for a stopped row. Requires
# the pipeline's sampling_constraints, without which vLLM never acts on
# the sentinel.
if isinstance(hidden_states, OmniOutput):
hidden_states = hidden_states.text_hidden_states
if hidden_states is None:
return None
bsz = hidden_states.shape[0]
vocab = self.config.get_text_config().vocab_size
logits = torch.full((bsz, vocab), float("-inf"), device=hidden_states.device, dtype=hidden_states.dtype)
if not self._results_queue:
# Engine-init dummy sampler run: neutral one-hot logits so KV-cache
# profiling can complete.
logits[:, 0] = 0.0
return logits
# Row i belongs to _results_queue[i]. The pipeline pins temperature=0.0,
# so vLLM's sampler argmaxes the single finite entry.
for i, (_req_id, head0, do_stop) in enumerate(self._results_queue):
if i >= bsz:
break
if head0 < 0:
logits[i, 0] = 0.0 # partial-prefill placeholder; vLLM discards it
continue
logits[i, self.stop_token if do_stop else head0] = 0.0
self._results_queue.clear()
return logits
def make_omni_output(self, model_outputs: torch.Tensor | OmniOutput, **kwargs: Any) -> OmniOutput:
if isinstance(model_outputs, OmniOutput):
return model_outputs
by_req: dict[str, torch.Tensor] = {}
for req_id, audio in self._audio_queue:
if audio is None:
continue
by_req[req_id] = (
torch.cat([by_req[req_id].reshape(-1), audio.reshape(-1)], dim=0) if req_id in by_req else audio
)
self._audio_queue.clear()
# The sparse marker is always carried, even on a step that decoded
# nothing: without it the runner ships the pooler payload instead, whose
# hidden states land under this stage's "audio" key.
ready_req_ids = list(by_req)
sr = torch.tensor(self._sample_rate, dtype=torch.int32)
mm: dict[str, Any] = {
"model_outputs": [by_req[r].reshape(-1) for r in ready_req_ids],
"sr": [sr for _ in ready_req_ids],
"meta": {"req_id": ready_req_ids, "sparse_audio": ["1"]},
}
# Deltas, not the whole history: callers concatenate across steps.
return OmniOutput(text_hidden_states=model_outputs, multimodal_outputs=mm)
@@ -0,0 +1,172 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""NeMo NanoCodec wrapper for Gepard — per-dimension FSQ decode.
Gepard's 32 FSQ heads emit per-dimension codes (8 groups x 4 dims), while stock
NeMo decode wants composed per-group mixed-radix indices. ``decode_from_codes``
dequantizes per-dim instead and hands the result to stock ``decode_audio`` — a
few lines on public NeMo API, no fork.
NeMo is a heavy optional dependency, imported lazily inside ``load``. Decode
only; ``encode_to_dequantized`` belongs to the voice-cloning follow-up.
"""
from __future__ import annotations
import os
import torch
import torch.nn as nn
from vllm.logger import init_logger
logger = init_logger(__name__)
# Left-context frames prepended to every streaming decode window. NanoCodec is
# convolutional, so decoding a bare chunk boundary yields a discontinuity; the
# extra frames are decoded for receptive-field context and trimmed afterwards.
# Mirrors the reference server's TTS_LOOKBACK_FRAMES.
LOOKBACK_FRAMES = int(os.environ.get("VLLM_GEPARD_LOOKBACK_FRAMES", "8"))
# End-of-clip shaping. The codec leaves the waveform on a non-zero sample and
# the hard step to silence is an audible click; a linear fade ramps it out.
# Both default to 0.0 (off), matching the reference server's defaults.
END_OF_SPEECH_FADE_MS = float(os.environ.get("VLLM_GEPARD_END_FADE_MS", "0.0"))
END_OF_SPEECH_SILENCE_MS = float(os.environ.get("VLLM_GEPARD_END_SILENCE_MS", "0.0"))
def apply_end_of_speech_tail(
audio: torch.Tensor,
sample_rate: int,
fade_ms: float = END_OF_SPEECH_FADE_MS,
silence_ms: float = END_OF_SPEECH_SILENCE_MS,
) -> torch.Tensor:
"""Fade the final ``fade_ms`` to zero, then append ``silence_ms`` of pad.
The fade is what removes the end-of-clip click; padding alone does not.
"""
if audio.numel() == 0 or (fade_ms <= 0 and silence_ms <= 0):
return audio
out = audio
if fade_ms > 0:
n = min(out.shape[0], int(sample_rate * fade_ms / 1000.0))
if n > 0:
# Copy: the caller's tensor is still referenced by the audio queue.
out = out.clone()
out[-n:] *= torch.linspace(1.0, 0.0, n, dtype=out.dtype, device=out.device)
if silence_ms > 0:
pad = torch.zeros(int(sample_rate * silence_ms / 1000.0), dtype=out.dtype, device=out.device)
out = torch.cat([out, pad], dim=0)
return out
def _make_unfolded_codec_cls():
"""Build the ``UnfoldedCodecModel`` subclass. Imports NeMo (optional dep).
Defined lazily so importing this module never requires NeMo. The subclass
is OUR code layered on stock ``AudioCodecModel`` — not a NeMo fork.
"""
try:
from nemo.collections.tts.models import AudioCodecModel # noqa: PLC0415
from omegaconf import open_dict # noqa: PLC0415
except ImportError as e: # pragma: no cover - env-dependent
raise ImportError(
"Gepard TTS needs NeMo for the NanoCodec decoder. Install it with "
"`pip install nemo_toolkit[tts]` (heavy; NVIDIA Open Model License, "
"commercial use OK), or gate the model behind that extra."
) from e
class UnfoldedCodecModel(AudioCodecModel):
"""AudioCodecModel + direct decode from per-dimension FSQ codes."""
def __init__(self, cfg, trainer=None):
# SLMDiscriminator pulls microsoft/wavlm-base-plus (~360 MB) and is
# training-only — strip it from the config before init.
with open_dict(cfg):
disc = cfg.get("discriminator", None)
if disc is not None and "discriminators" in disc:
disc.discriminators = [d for d in disc.discriminators if "SLM" not in d._target_]
super().__init__(cfg, trainer)
def decode_from_codes(self, codes: torch.Tensor, codes_len: torch.Tensor):
"""Decode per-dimension discrete FSQ codes to a waveform.
codes: (B, D, T) per-dim discrete values, D = num_groups * dims.
codes_len: (B,) valid frame count per batch element.
Returns (audio (B, T_audio), audio_len (B,)).
"""
num_levels = self.vector_quantizer.fsqs[0].num_levels.squeeze()
scale = (num_levels // 2).float().to(codes.device) # e.g. [4,3,3,3]
groups = codes.chunk(self.vector_quantizer.num_groups, dim=1)
dequantized = torch.cat(
[(g - scale[None, :, None]) / scale[None, :, None] for g in groups],
dim=1,
)
return self.decode_audio(inputs=dequantized, input_len=codes_len)
return UnfoldedCodecModel
class NanoCodec(nn.Module):
"""Thin wrapper owning the (optional) NeMo NanoCodec decoder.
Constructing this is cheap and NeMo-free; ``load`` does the heavy import +
weight load so ``load_format=dummy`` and NeMo-less environments don't pay
for it at model construction.
"""
def __init__(self, codec_id: str, sample_rate: int) -> None:
super().__init__()
self.codec_id = codec_id
self.sample_rate = sample_rate
self._codec: nn.Module | None = None
@property
def is_loaded(self) -> bool:
return self._codec is not None
def load(self, device: torch.device) -> None:
"""Import NeMo, build the UnfoldedCodecModel, move to device, eval()."""
cls = _make_unfolded_codec_cls()
codec = cls.from_pretrained(self.codec_id)
codec.to(device=device)
codec.eval()
self._codec = codec
logger.info("Gepard NanoCodec loaded from %s on %s", self.codec_id, device)
@torch.inference_mode()
def decode_from_codes(self, codes: torch.Tensor, codes_len: torch.Tensor) -> torch.Tensor:
"""(B, 32, T) per-dim codes -> waveform (B, T_audio). Returns audio only."""
if self._codec is None:
raise RuntimeError("NanoCodec.decode_from_codes called before load()")
audio, _audio_len = self._codec.decode_from_codes(codes, codes_len)
return audio
@torch.inference_mode()
def decode_stream(
self,
frames: torch.Tensor,
*,
start_idx: int,
lookback: int = LOOKBACK_FRAMES,
is_final: bool = False,
) -> torch.Tensor | None:
"""Decode the tail of a ``(T, 32)`` code history past ``start_idx``.
The window starts ``lookback`` frames earlier for receptive-field
context and those samples are trimmed after, so successive calls
concatenate to a single whole-history decode. None if nothing is new.
"""
count = int(frames.shape[0])
if count <= start_idx:
return None
decode_start = max(0, start_idx - lookback)
window = frames[decode_start:count] # (T_win, 32)
codes = window.t().unsqueeze(0).contiguous().long() # (1, 32, T_win)
codes_len = torch.full((1,), codes.shape[-1], dtype=torch.long, device=codes.device)
audio = self.decode_from_codes(codes, codes_len).reshape(-1)
# Derive samples_per_frame from this decode rather than assuming it.
samples_per_frame = audio.shape[0] // (count - decode_start)
audio = audio[(start_idx - decode_start) * samples_per_frame :]
if is_final:
audio = apply_end_of_speech_tail(audio, self.sample_rate)
return audio
@@ -0,0 +1,45 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Gepard-1.0 pipeline topology.
Single-stage AR TTS. The Qwen3.5 backbone runs under paged attention, the 32
codebook heads and the binary stop head are sampled inside the talker, and the
NeMo NanoCodec decodes the accumulated frames outside vLLM.
``compute_logits`` scatters head0 into vLLM's vocab-wide logits; head0's valid
range is 0..7, so the synthetic STOP sentinel is 8. ``stop_token_ids=[8]``
couples it to vLLM's stop machinery, with ``detokenize=False`` because head0 is
an audio code. Same idiom as VoxCPM2/MOSS.
"""
from vllm_omni.config.stage_config import (
PipelineConfig,
StageExecutionType,
StagePipelineConfig,
)
# head0 valid range is 0..7 (its FSQ cardinality is 8); STOP is the sentinel
# one past that range. Mirrors the reference config.py STOP_TOKEN = 8.
_GEPARD_STOP_TOKEN_ID = 8
GEPARD_PIPELINE = PipelineConfig(
model_type="gepard",
default_deploy_config_name="gepard.yaml",
model_arch="GepardTalkerForConditionalGeneration",
stages=(
StagePipelineConfig(
stage_id=0,
model_stage="gepard",
execution_type=StageExecutionType.LLM_AR,
input_sources=(),
final_output=True,
final_output_type="audio",
owns_tokenizer=True,
engine_output_type="audio",
sampling_constraints={
"detokenize": False,
"stop_token_ids": [_GEPARD_STOP_TOKEN_ID],
},
),
),
)
@@ -0,0 +1,55 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Gepard-1.0 prompt layout.
``preprocess`` only consumes this layout — it injects the speaker prefix at the
placeholder slots and samples the first audio frame from the SOS position. The
layout itself is built here, by whichever entry point is assembling a request.
"""
from __future__ import annotations
import math
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from vllm_omni.model_executor.models.gepard.configuration_gepard import GepardConfig
def build_gepard_prompt_ids(text_token_ids: list[int], *, config: GepardConfig) -> list[int]:
"""Assemble ``[speaker_slots] + [SOT, *text, EOT]*(R-1) + [SOT, *text, EOT, SOS]``.
Only the final copy carries SOS, the learned "audio starts now" gate, so
the earlier copies are read as context and never voiced. Short texts repeat
because a couple of text tokens carry too little mass against the speaker
prefix. This must match the training layout or WER collapses, which is why
the thresholds come from the checkpoint's ``text_repetition`` block rather
than from literals here.
Raises ``ValueError`` on empty text: the layout would still be structurally
valid and the model would voice it as arbitrary audio, so an empty request
has to fail loudly. This is the only entry point in the offline path — there
is no adapter above it to check first.
"""
n = len(text_token_ids)
if n == 0:
raise ValueError("text_token_ids is empty; Gepard needs at least one text token to voice")
if (
not config.text_repetition_enabled
or n >= config.text_repetition_apply_below
or n >= config.text_repetition_target_tokens
):
repeats = 1
else:
repeats = max(
1,
min(
math.ceil(config.text_repetition_target_tokens / n),
config.text_repetition_max_repeats,
),
)
block = [config.start_of_text, *text_token_ids, config.end_of_text]
text_region = block * (repeats - 1) + [*block, config.start_of_speech]
speaker_slots = [config.speaker_token_base + i for i in range(config.num_speaker_prefix)]
return speaker_slots + text_region
@@ -295,6 +295,12 @@ _OMNI_MODELS = {
"fish_speech_dac_decoder",
"FishSpeechDACDecoder",
),
## Gepard-1.0 (nineninesix) — single-stage native-AR FSQ/NanoCodec TTS
"GepardTalkerForConditionalGeneration": (
"gepard",
"gepard_talker",
"GepardTalkerForConditionalGeneration",
),
## VoxCPM2
"VoxCPM2TalkerForConditionalGeneration": (
"voxcpm2",
+10
View File
@@ -1772,6 +1772,16 @@ class OmniGPUModelRunner(GPUModelRunner):
req_infos["_omni_prompt_len"] = prompt_len
req_infos["_omni_num_computed_tokens"] = num_computed_tokens
req_infos["_omni_is_prefill"] = is_prefill
# Output-token cap, so a model that must ship a payload on the
# request's final step can tell which step that is. A finished
# request drops out of req_ids_output_copy, and downstream
# routing keys off that list, so a payload produced after its
# last step never reaches the caller.
sampling_params = getattr(req_state, "sampling_params", None)
req_infos["_omni_max_tokens"] = getattr(sampling_params, "max_tokens", None)
# Seed, so a model that samples inside forward() can be
# reproducible: vLLM's own sampler seeding does not reach it.
req_infos["_omni_seed"] = getattr(sampling_params, "seed", None)
if callable(batch_decode_preprocess) and self.has_talker_mtp and span_len == 1 and not is_prefill:
decode_batch_items.append((req_id, s, req_infos))
continue